[
  {
    "path": ".actrc",
    "content": "-P ubuntu-latest=catthehacker/ubuntu:act-latest\n"
  },
  {
    "path": ".changeset/README.md",
    "content": "# Changesets\n\nThis directory is managed by [Changesets](https://github.com/changesets/changesets).\n\n## Quick Start\n\n```bash\npnpm changeset\n```\n\nFollow the prompts to select version bump type and describe your changes.\n\n## Workflow\n\n1. **Add a changeset** — Run `pnpm changeset` locally before or after your PR\n2. **Version PR** — CI opens/updates a \"Version Packages\" PR when changesets merge to main\n3. **Release** — Merging the Version PR triggers npm publish and GitHub Release\n\n> **Note:** Contributors only need to run `pnpm changeset`. Versioning (`changeset version`) and publishing happen automatically in CI.\n\n## Template\n\nUse this structure for your changeset content:\n\n```markdown\n---\n\"@fission-ai/openspec\": patch\n---\n\n### New Features\n\n- **Feature name** — What users can now do\n\n### Bug Fixes\n\n- Fixed issue where X happened when Y\n\n### Breaking Changes\n\n- `oldMethod()` has been removed, use `newMethod()` instead\n\n### Deprecations\n\n- `legacyOption` is deprecated and will be removed in v2.0\n\n### Other\n\n- Internal refactoring of X for better performance\n```\n\nInclude only the sections relevant to your change.\n\n## Version Bump Guide\n\n| Type | When to use | Example |\n|------|-------------|---------|\n| `patch` | Bug fixes, small improvements | Fixed crash when config missing |\n| `minor` | New features, non-breaking additions | Added `--verbose` flag |\n| `major` | Breaking changes, removed features | Renamed `init` to `setup` |\n\n## When to Create a Changeset\n\n**Create one for:**\n- New features or commands\n- Bug fixes that affect users\n- Breaking changes or deprecations\n- Performance improvements users would notice\n\n**Skip for:**\n- Documentation-only changes\n- Test additions/fixes\n- Internal refactoring with no user impact\n- CI/tooling changes\n\n## Writing Good Descriptions\n\n**Do:** Write for users, not developers\n```markdown\n- **Shell completions** — Tab completion now available for Bash, Fish, and PowerShell\n```\n\n**Don't:** Write implementation details\n```markdown\n- Added ShellCompletionGenerator class with Bash/Fish/PowerShell subclasses\n```\n\n**Do:** Explain the impact\n```markdown\n- Fixed config loading to respect `XDG_CONFIG_HOME` on Linux\n```\n\n**Don't:** Just reference the fix\n```markdown\n- Fixed #123\n```\n"
  },
  {
    "path": ".changeset/config.json",
    "content": "{\n  \"$schema\": \"https://unpkg.com/@changesets/config/schema.json\",\n  \"changelog\": [\n    \"@changesets/changelog-github\",\n    { \"repo\": \"Fission-AI/OpenSpec\" }\n  ],\n  \"commit\": false,\n  \"fixed\": [],\n  \"linked\": [],\n  \"access\": \"public\",\n  \"baseBranch\": \"main\",\n  \"updateInternalDependencies\": \"patch\",\n  \"ignore\": []\n}\n\n"
  },
  {
    "path": ".changeset/fix-opencode-commands-directory.md",
    "content": "---\n\"@fission-ai/openspec\": patch\n---\n\nfix: OpenCode adapter now uses `.opencode/commands/` (plural) to match OpenCode's official directory convention. Fixes #748.\n"
  },
  {
    "path": ".changeset/graceful-status-no-changes.md",
    "content": "---\n\"@fission-ai/openspec\": patch\n---\n\nfix: `openspec status` now exits gracefully when no changes exist instead of throwing a fatal error. Fixes #714.\n"
  },
  {
    "path": ".coderabbit.yaml",
    "content": "# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json\n# Minimal configuration for getting started\nlanguage: \"en-US\"\nreviews:\n  profile: \"chill\"\n  high_level_summary: true\n  auto_review:\n    enabled: true\n    drafts: false\n    base_branches:\n      - \".*\""
  },
  {
    "path": ".devcontainer/README.md",
    "content": "# Dev Container Setup\n\nThis directory contains the VS Code dev container configuration for OpenSpec development.\n\n## What's Included\n\n- **Node.js 20 LTS** (>=20.19.0) - TypeScript/JavaScript runtime\n- **pnpm** - Fast, disk space efficient package manager\n- **Git + GitHub CLI** - Version control tools\n- **VS Code Extensions**:\n  - ESLint & Prettier for code quality\n  - Vitest Explorer for running tests\n  - GitLens for enhanced git integration\n  - Error Lens for inline error highlighting\n  - Code Spell Checker\n  - Path IntelliSense\n\n## How to Use\n\n### First Time Setup\n\n1. **Install Prerequisites** (on your local machine):\n   - [VS Code](https://code.visualstudio.com/)\n   - [Docker Desktop](https://www.docker.com/products/docker-desktop)\n   - [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)\n\n2. **Open in Container**:\n   - Open this project in VS Code\n   - You'll see a notification: \"Folder contains a Dev Container configuration file\"\n   - Click \"Reopen in Container\"\n\n   OR\n\n   - Open Command Palette (`Cmd/Ctrl+Shift+P`)\n   - Type \"Dev Containers: Reopen in Container\"\n   - Press Enter\n\n3. **Wait for Setup**:\n   - The container will build (first time takes a few minutes)\n   - `pnpm install` runs automatically via `postCreateCommand`\n   - All extensions install automatically\n\n### Daily Development\n\nOnce set up, the container preserves your development environment:\n\n```bash\n# Run development build\npnpm run dev\n\n# Run CLI in development\npnpm run dev:cli\n\n# Run tests\npnpm test\n\n# Run tests in watch mode\npnpm test:watch\n\n# Build the project\npnpm run build\n```\n\n### SSH Keys\n\nYour SSH keys are mounted read-only from `~/.ssh`, so git operations work seamlessly with GitHub/GitLab.\n\n### Rebuilding the Container\n\nIf you modify `.devcontainer/devcontainer.json`:\n- Command Palette → \"Dev Containers: Rebuild Container\"\n\n## Benefits\n\n- No need to install Node.js or pnpm on your local machine\n- Consistent development environment across team members\n- Isolated from other Node.js projects on your machine\n- All dependencies and tools containerized\n- Easy onboarding for new developers\n\n## Troubleshooting\n\n**Container won't build:**\n- Ensure Docker Desktop is running\n- Check Docker has enough memory allocated (recommend 4GB+)\n\n**Extensions not appearing:**\n- Rebuild the container: \"Dev Containers: Rebuild Container\"\n\n**Permission issues:**\n- The container runs as the `node` user (non-root)\n- Files created in the container are owned by this user\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "{\n  \"name\": \"OpenSpec Development\",\n  \"image\": \"mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm\",\n\n  // Additional tools and features\n  \"features\": {\n    \"ghcr.io/devcontainers/features/git:1\": {\n      \"version\": \"latest\",\n      \"ppa\": true\n    },\n    \"ghcr.io/devcontainers/features/github-cli:1\": {\n      \"version\": \"latest\"\n    }\n  },\n\n  // Configure tool-specific properties\n  \"customizations\": {\n    \"vscode\": {\n      // Set default container specific settings\n      \"settings\": {\n        \"typescript.tsdk\": \"node_modules/typescript/lib\",\n        \"typescript.enablePromptUseWorkspaceTsdk\": true,\n        \"editor.formatOnSave\": true,\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n        \"editor.codeActionsOnSave\": {\n          \"source.fixAll\": \"explicit\"\n        },\n        \"files.eol\": \"\\n\",\n        \"terminal.integrated.defaultProfile.linux\": \"bash\"\n      },\n\n      // Add extensions you want installed when the container is created\n      \"extensions\": [\n        // TypeScript/JavaScript essentials\n        \"dbaeumer.vscode-eslint\",\n        \"esbenp.prettier-vscode\",\n\n        // Testing\n        \"vitest.explorer\",\n\n        // Git\n        \"eamodio.gitlens\",\n\n        // Utilities\n        \"streetsidesoftware.code-spell-checker\",\n        \"usernamehw.errorlens\",\n        \"christian-kohler.path-intellisense\"\n      ]\n    }\n  },\n\n  // Use 'forwardPorts' to make a list of ports inside the container available locally\n  // \"forwardPorts\": [],\n\n  // Use 'postCreateCommand' to run commands after the container is created\n  \"postCreateCommand\": \"corepack enable && corepack prepare pnpm@latest --activate && pnpm install\",\n\n  // Configure mounts to preserve SSH keys for git operations\n  \"mounts\": [\n    \"source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/node/.ssh,readonly,type=bind,consistency=cached\"\n  ],\n\n  // Set the default user to 'node' (non-root user)\n  \"remoteUser\": \"node\",\n\n  // Ensure git is properly configured\n  \"initializeCommand\": \"echo 'Initializing dev container...'\"\n}\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "# Default code ownership\n* @TabishB\n"
  },
  {
    "path": ".github/workflows/README.md",
    "content": "# Github Workflows\n\n## Testing CI Locally\n\nTest GitHub Actions workflows locally using [act](https://nektosact.com/):\n\n```bash\n# Test all PR checks\nact pull_request\n\n# Test specific job\nact pull_request -j nix-flake-validate\n\n# Dry run to see what would execute\nact pull_request --dryrun\n```\n\nThe `.actrc` file configures act to use the appropriate Docker image.\n\n\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  pull_request:\n    branches: [main]\n  push:\n    branches: [main]\n  workflow_dispatch:\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ci-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  # Detect which files changed to enable path-based filtering\n  changes:\n    name: Detect changes\n    runs-on: ubuntu-latest\n    outputs:\n      nix: ${{ steps.filter.outputs.nix }}\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Check for Nix-related changes\n        uses: dorny/paths-filter@v3\n        id: filter\n        with:\n          filters: |\n            nix:\n              - 'flake.nix'\n              - 'flake.lock'\n              - 'package.json'\n              - 'pnpm-lock.yaml'\n              - 'scripts/update-flake.sh'\n              - '.github/workflows/ci.yml'\n\n  test_pr:\n    name: Test\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n    if: github.event_name == 'pull_request'\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: 9\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n          cache: 'pnpm'\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Build project\n        run: pnpm run build\n\n      - name: Run tests\n        run: pnpm test\n\n      - name: Upload test coverage\n        uses: actions/upload-artifact@v4\n        with:\n          name: coverage-report-pr\n          path: coverage/\n          retention-days: 7\n\n  test_matrix:\n    name: Test (${{ matrix.label }})\n    runs-on: ${{ matrix.os }}\n    timeout-minutes: 15\n    if: github.event_name != 'pull_request'\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - os: ubuntu-latest\n            shell: bash\n            label: linux-bash\n          - os: macos-latest\n            shell: bash\n            label: macos-bash\n          - os: windows-latest\n            shell: pwsh\n            label: windows-pwsh\n\n    defaults:\n      run:\n        shell: ${{ matrix.shell }}\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: 9\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n          cache: 'pnpm'\n\n      - name: Print environment diagnostics\n        run: |\n          node -p \"JSON.stringify({ platform: process.platform, arch: process.arch, shell: process.env.SHELL || process.env.ComSpec || '' })\"\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Build project\n        run: pnpm run build\n\n      - name: Run tests\n        run: pnpm test\n\n      - name: Upload test coverage\n        if: matrix.os == 'ubuntu-latest'\n        uses: actions/upload-artifact@v4\n        with:\n          name: coverage-report-main\n          path: coverage/\n          retention-days: 7\n\n  lint:\n    name: Lint & Type Check\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: 9\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n          cache: 'pnpm'\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Build project\n        run: pnpm run build\n\n      - name: Type check\n        run: pnpm exec tsc --noEmit\n\n      - name: Lint\n        run: pnpm lint\n\n      - name: Check for build artifacts\n        run: |\n          if [ ! -d \"dist\" ]; then\n            echo \"Error: dist directory not found after build\"\n            exit 1\n          fi\n          if [ ! -f \"dist/cli/index.js\" ]; then\n            echo \"Error: CLI entry point not found\"\n            exit 1\n          fi\n\n  nix-flake-validate:\n    name: Nix Flake Validation\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n    needs: changes\n    if: needs.changes.outputs.nix == 'true'\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Install Nix\n        uses: DeterminateSystems/nix-installer-action@v21\n\n      - name: Setup Nix cache\n        uses: DeterminateSystems/magic-nix-cache-action@v13\n\n      - name: Build with Nix\n        run: nix build\n\n      - name: Verify build output\n        run: |\n          if [ ! -e \"result\" ]; then\n            echo \"Error: Nix build output 'result' symlink not found\"\n            exit 1\n          fi\n          if [ ! -f \"result/bin/openspec\" ]; then\n            echo \"Error: openspec binary not found in build output\"\n            exit 1\n          fi\n          echo \"✅ Build output verified\"\n\n      - name: Test binary execution\n        run: |\n          VERSION=$(nix run . -- --version)\n          echo \"OpenSpec version: $VERSION\"\n          if [ -z \"$VERSION\" ]; then\n            echo \"Error: Version command returned empty output\"\n            exit 1\n          fi\n          echo \"✅ Binary execution successful\"\n\n      - name: Validate update script\n        run: |\n          echo \"Testing update-flake.sh script...\"\n          bash scripts/update-flake.sh\n          echo \"✅ Update script executed successfully\"\n\n      - name: Check flake.nix modifications\n        run: |\n          if git diff --quiet flake.nix; then\n            echo \"ℹ️  flake.nix unchanged (hash already up-to-date)\"\n          else\n            echo \"✅ flake.nix was updated by script\"\n            git diff flake.nix\n          fi\n\n      - name: Restore flake.nix\n        if: always()\n        run: git checkout -- flake.nix || true\n\n  validate-changesets:\n    name: Validate Changesets\n    runs-on: ubuntu-latest\n    if: github.event_name == 'pull_request'\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: 9\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n          cache: 'pnpm'\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Validate changesets\n        run: |\n          if command -v changeset &> /dev/null; then\n            pnpm exec changeset status --since=origin/main\n          else\n            echo \"Changesets not configured, skipping validation\"\n          fi\n\n  required-checks-pr:\n    name: All checks passed\n    runs-on: ubuntu-latest\n    needs: [test_pr, lint, nix-flake-validate]\n    if: always() && github.event_name == 'pull_request'\n    steps:\n      - name: Verify all checks passed\n        run: |\n          if [[ \"${{ needs.test_pr.result }}\" != \"success\" ]]; then\n            echo \"Test job failed\"\n            exit 1\n          fi\n          if [[ \"${{ needs.lint.result }}\" != \"success\" ]]; then\n            echo \"Lint job failed\"\n            exit 1\n          fi\n          # Nix validation may be skipped if no Nix-related files changed\n          if [[ \"${{ needs.nix-flake-validate.result }}\" != \"success\" && \"${{ needs.nix-flake-validate.result }}\" != \"skipped\" ]]; then\n            echo \"Nix flake validation job failed\"\n            exit 1\n          fi\n          if [[ \"${{ needs.nix-flake-validate.result }}\" == \"skipped\" ]]; then\n            echo \"Nix flake validation skipped (no Nix-related changes)\"\n          fi\n          echo \"All required checks passed!\"\n\n  required-checks-main:\n    name: All checks passed\n    runs-on: ubuntu-latest\n    needs: [test_matrix, lint, nix-flake-validate]\n    if: always() && github.event_name != 'pull_request'\n    steps:\n      - name: Verify all checks passed\n        run: |\n          if [[ \"${{ needs.test_matrix.result }}\" != \"success\" ]]; then\n            echo \"Matrix test job failed\"\n            exit 1\n          fi\n          if [[ \"${{ needs.lint.result }}\" != \"success\" ]]; then\n            echo \"Lint job failed\"\n            exit 1\n          fi\n          # Nix validation may be skipped if no Nix-related files changed\n          if [[ \"${{ needs.nix-flake-validate.result }}\" != \"success\" && \"${{ needs.nix-flake-validate.result }}\" != \"skipped\" ]]; then\n            echo \"Nix flake validation job failed\"\n            exit 1\n          fi\n          if [[ \"${{ needs.nix-flake-validate.result }}\" == \"skipped\" ]]; then\n            echo \"Nix flake validation skipped (no Nix-related changes)\"\n          fi\n          echo \"All required checks passed!\"\n"
  },
  {
    "path": ".github/workflows/release-prepare.yml",
    "content": "name: Release (prepare)\n\non:\n  push:\n    branches: [main]\n\npermissions:\n  contents: write\n  pull-requests: write\n  id-token: write # Required for npm OIDC trusted publishing\n\nconcurrency:\n  group: release-${{ github.ref }}\n  cancel-in-progress: false\n\njobs:\n  prepare:\n    if: github.repository == 'Fission-AI/OpenSpec'\n    runs-on: ubuntu-latest\n    steps:\n      # Generate GitHub App token first - used for checkout and changesets\n      # This allows git operations to trigger CI workflows on the version PR\n      # (GITHUB_TOKEN cannot trigger workflows by design)\n      - name: Generate GitHub App Token\n        id: app-token\n        uses: actions/create-github-app-token@v2\n        with:\n          app-id: ${{ vars.APP_ID }}\n          private-key: ${{ secrets.APP_PRIVATE_KEY }}\n\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          token: ${{ steps.app-token.outputs.token }}\n\n      - uses: pnpm/action-setup@v4\n        with:\n          version: 9\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '24' # Node 24 includes npm 11.5.1+ required for OIDC\n          cache: 'pnpm'\n          registry-url: 'https://registry.npmjs.org'\n\n      - run: pnpm install --frozen-lockfile\n\n      # Opens/updates the Version Packages PR; publishes when the Version PR merges\n      - name: Create/Update Version PR\n        id: changesets\n        uses: changesets/action@v1\n        with:\n          title: 'chore(release): version packages'\n          createGithubReleases: true\n          # Use CI-specific release script: relies on version PR having been merged\n          # so package.json already contains the bumped version.\n          publish: pnpm run release:ci\n        env:\n          GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}\n          # npm authentication handled via OIDC trusted publishing (no token needed)\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Snowpack dependency directory (https://snowpack.dev/)\nweb_modules/\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional stylelint cache\n.stylelintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variable files\n.env\n.env.*\n!.env.example\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n.parcel-cache\n\n# Next.js build output\n.next\nout\n\n# Nuxt.js build / generate output\n.nuxt\n\n# Build output\ndist/\n\n# Gatsby files\n.cache/\n# Comment in the public line in if your project uses Gatsby and not Next.js\n# https://nextjs.org/blog/next-9-1#public-directory-support\n# public\n\n# vuepress build output\n.vuepress/dist\n\n# vuepress v2.x temp and cache directory\n.temp\n.cache\n\n# Sveltekit cache directory\n.svelte-kit/\n\n# vitepress build output\n**/.vitepress/dist\n\n# vitepress cache directory\n**/.vitepress/cache\n\n# Docusaurus cache and generated files\n.docusaurus\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# Firebase cache directory\n.firebase/\n\n# TernJS port file\n.tern-port\n\n# Stores VSCode versions used for testing VSCode extensions\n.vscode-test\n\n# yarn v3\n.pnp.*\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/sdks\n!.yarn/versions\n\n# Vite logs files\nvite.config.js.timestamp-*\nvite.config.ts.timestamp-*\n\n\n# Claude\n.claude/\nCLAUDE.md\n.DS_Store\n\n# Pnpm\n.pnpm-store/\nresult\n\n# OpenCode\n.opencode/\nopencode.json\n\n# Codex\n.codex/\n"
  },
  {
    "path": "AGENTS.md",
    "content": ""
  },
  {
    "path": "CHANGELOG.md",
    "content": "# @fission-ai/openspec\n\n## 1.2.0\n\n### Minor Changes\n\n- [#747](https://github.com/Fission-AI/OpenSpec/pull/747) [`1e94443`](https://github.com/Fission-AI/OpenSpec/commit/1e94443a3551b228eecbc89e95d96d3b9600a192) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features\n\n  - **Profile system** — Choose between `core` (4 essential workflows) and `custom` (pick any subset) profiles to control which skills get installed. Manage profiles with the new `openspec config profile` command\n  - **Propose workflow** — New one-step workflow creates a complete change proposal with design, specs, and tasks from a single request — no need to run `new` then `ff` separately\n  - **AI tool auto-detection** — `openspec init` now scans your project for existing tool directories (`.claude/`, `.cursor/`, etc.) and pre-selects detected tools\n  - **Pi (pi.dev) support** — Pi coding agent is now a supported tool with prompt and skill generation\n  - **Kiro support** — AWS Kiro IDE is now a supported tool with prompt and skill generation\n  - **Sync prunes deselected workflows** — `openspec update` now removes command files and skill directories for workflows you've deselected, keeping your project clean\n  - **Config drift warning** — `openspec config list` warns when global config is out of sync with the current project\n\n  ### Bug Fixes\n\n  - Fixed onboard preflight giving a false \"not initialized\" error on freshly initialized projects\n  - Fixed archive workflow stopping mid-way when syncing — it now properly resumes after sync completes\n  - Added Windows PowerShell alternatives for onboard shell commands\n\n## 1.1.1\n\n### Patch Changes\n\n- [#627](https://github.com/Fission-AI/OpenSpec/pull/627) [`afb73cf`](https://github.com/Fission-AI/OpenSpec/commit/afb73cf9ec59c6f8b26d0c538c0218c203ba3c56) Thanks [@TabishB](https://github.com/TabishB)! - ### Bug Fixes\n\n  - **OpenCode command references** — Command references in generated files now use the correct `/opsx-` hyphen format instead of `/opsx:` colon format, ensuring commands work properly in OpenCode\n\n## 1.1.0\n\n### Minor Changes\n\n- [#625](https://github.com/Fission-AI/OpenSpec/pull/625) [`53081fb`](https://github.com/Fission-AI/OpenSpec/commit/53081fb2a26ec66d2950ae0474b9a56cbc5b5a76) Thanks [@TabishB](https://github.com/TabishB)! - ### Bug Fixes\n\n  - **Codex global path support** — Codex adapter now resolves global paths correctly, fixing workflow file generation when run outside the project directory (#622)\n  - **Archive operations on cross-device or restricted paths** — Archive now falls back to copy+remove when rename fails with EPERM or EXDEV errors, fixing failures on networked/external drives (#605)\n  - **Slash command hints in workflow messages** — Workflow completion messages now display helpful slash command hints for next steps (#603)\n  - **Windsurf workflow file path** — Updated Windsurf adapter to use the correct `workflows` directory instead of the legacy `commands` path (#610)\n\n### Patch Changes\n\n- [#550](https://github.com/Fission-AI/OpenSpec/pull/550) [`86d2e04`](https://github.com/Fission-AI/OpenSpec/commit/86d2e04cae76a999dbd1b4571f52fa720036be0c) Thanks [@jerome-benoit](https://github.com/jerome-benoit)! - ### Improvements\n\n  - **Nix flake maintenance** — Version now read dynamically from package.json, reducing manual sync issues\n  - **Nix build optimization** — Source filtering excludes node_modules and artifacts, improving build times\n  - **update-flake.sh script** — Detects when hash is already correct, skipping unnecessary rebuilds\n\n  ### Other\n\n  - Updated Nix CI actions to latest versions (nix-installer v21, magic-nix-cache v13)\n\n## 1.0.2\n\n### Patch Changes\n\n- [#596](https://github.com/Fission-AI/OpenSpec/pull/596) [`e91568d`](https://github.com/Fission-AI/OpenSpec/commit/e91568deb948073f3e9d9bb2d2ab5bf8080d6cf4) Thanks [@TabishB](https://github.com/TabishB)! - ### Bug Fixes\n\n  - Clarified spec naming convention — Specs should be named after capabilities (`specs/<capability>/spec.md`), not changes\n  - Fixed task checkbox format guidance — Tasks now clearly require `- [ ]` checkbox format for apply phase tracking\n\n## 1.0.1\n\n### Patch Changes\n\n- [#587](https://github.com/Fission-AI/OpenSpec/pull/587) [`943e0d4`](https://github.com/Fission-AI/OpenSpec/commit/943e0d41026d034de66b9442d1276c01b293eb2b) Thanks [@TabishB](https://github.com/TabishB)! - ### Bug Fixes\n\n  - Fixed incorrect archive path in onboarding documentation — the template now shows the correct path `openspec/changes/archive/YYYY-MM-DD-<name>/` instead of the incorrect `openspec/archive/YYYY-MM-DD--<name>/`\n\n## 1.0.0\n\n### Major Changes\n\n- [#578](https://github.com/Fission-AI/OpenSpec/pull/578) [`0cc9d90`](https://github.com/Fission-AI/OpenSpec/commit/0cc9d9025af367faa1688a7b2606a2549053cd3f) Thanks [@TabishB](https://github.com/TabishB)! - ## OpenSpec 1.0 — The OPSX Release\n\n  The workflow has been rebuilt from the ground up. OPSX replaces the old phase-locked `/openspec:*` commands with an action-based system where AI understands what artifacts exist, what's ready to create, and what each action unlocks.\n\n  ### Breaking Changes\n\n  - **Old commands removed** — `/openspec:proposal`, `/openspec:apply`, and `/openspec:archive` no longer exist\n  - **Config files removed** — Tool-specific instruction files (`CLAUDE.md`, `.cursorrules`, `AGENTS.md`, `project.md`) are no longer generated\n  - **Migration** — Run `openspec init` to upgrade. Legacy artifacts are detected and cleaned up with confirmation.\n\n  ### From Static Prompts to Dynamic Instructions\n\n  **Before:** AI received the same static instructions every time, regardless of project state.\n\n  **Now:** Instructions are dynamically assembled from three layers:\n\n  1. **Context** — Project background from `config.yaml` (tech stack, conventions)\n  2. **Rules** — Artifact-specific constraints (e.g., \"propose spike tasks for unknowns\")\n  3. **Template** — The actual structure for the output file\n\n  AI queries the CLI for real-time state: which artifacts exist, what's ready to create, what dependencies are satisfied, and what each action unlocks.\n\n  ### From Phase-Locked to Action-Based\n\n  **Before:** Linear workflow — proposal → apply → archive. Couldn't easily go back or iterate.\n\n  **Now:** Flexible actions on a change. Edit any artifact anytime. The artifact graph tracks state automatically.\n\n  | Command              | What it does                                         |\n  | -------------------- | ---------------------------------------------------- |\n  | `/opsx:explore`      | Think through ideas before committing to a change    |\n  | `/opsx:new`          | Start a new change                                   |\n  | `/opsx:continue`     | Create one artifact at a time (step-through)         |\n  | `/opsx:ff`           | Create all planning artifacts at once (fast-forward) |\n  | `/opsx:apply`        | Implement tasks                                      |\n  | `/opsx:verify`       | Validate implementation matches artifacts            |\n  | `/opsx:sync`         | Sync delta specs to main specs                       |\n  | `/opsx:archive`      | Archive completed change                             |\n  | `/opsx:bulk-archive` | Archive multiple changes with conflict detection     |\n  | `/opsx:onboard`      | Guided 15-minute walkthrough of complete workflow    |\n\n  ### From Text Merging to Semantic Spec Syncing\n\n  **Before:** Spec updates required manual merging or wholesale file replacement.\n\n  **Now:** Delta specs use semantic markers that AI understands:\n\n  - `## ADDED Requirements` — New requirements to add\n  - `## MODIFIED Requirements` — Partial updates (add scenario without copying existing ones)\n  - `## REMOVED Requirements` — Delete with reason and migration notes\n  - `## RENAMED Requirements` — Rename preserving content\n\n  Archive parses these at the requirement level, not brittle header matching.\n\n  ### From Scattered Files to Agent Skills\n\n  **Before:** 8+ config files at project root + slash commands scattered across 21 tool-specific locations with different formats.\n\n  **Now:** Single `.claude/skills/` directory with YAML-fronted markdown files. Auto-detected by Claude Code, Cursor, Windsurf. Cross-editor compatible.\n\n  ### New Features\n\n  - **Onboarding skill** — `/opsx:onboard` walks new users through their first complete change with codebase-aware task suggestions and step-by-step narration (11 phases, ~15 minutes)\n\n  - **21 AI tools supported** — Claude Code, Cursor, Windsurf, Continue, Gemini CLI, GitHub Copilot, Amazon Q, Cline, RooCode, Kilo Code, Auggie, CodeBuddy, Qoder, Qwen, CoStrict, Crush, Factory, OpenCode, Antigravity, iFlow, and Codex\n\n  - **Interactive setup** — `openspec init` shows animated welcome screen and searchable multi-select for choosing tools. Pre-selects already-configured tools for easy refresh.\n\n  - **Customizable schemas** — Define custom artifact workflows in `openspec/schemas/` without touching package code. Teams can share workflows via version control.\n\n  ### Bug Fixes\n\n  - Fixed Claude Code YAML parsing failure when command names contained colons\n  - Fixed task file parsing to handle trailing whitespace on checkbox lines\n  - Fixed JSON instruction output to separate context/rules from template — AI was copying constraint blocks into artifact files\n\n  ### Documentation\n\n  - New getting-started guide, CLI reference, concepts documentation\n  - Removed misleading \"edit mid-flight and continue\" claims that weren't implemented\n  - Added migration guide for upgrading from pre-OPSX versions\n\n## 0.23.0\n\n### Minor Changes\n\n- [#540](https://github.com/Fission-AI/OpenSpec/pull/540) [`c4cfdc7`](https://github.com/Fission-AI/OpenSpec/commit/c4cfdc7c499daef30d8a218f5f59b8d9e5adb754) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features\n\n  - **Bulk archive skill** — Archive multiple completed changes in a single operation with `/opsx:bulk-archive`. Includes batch validation, spec conflict detection, and consolidated confirmation\n\n  ### Other\n\n  - **Simplified setup** — Config creation now uses sensible defaults with helpful comments instead of interactive prompts\n\n## 0.22.0\n\n### Minor Changes\n\n- [#530](https://github.com/Fission-AI/OpenSpec/pull/530) [`33466b1`](https://github.com/Fission-AI/OpenSpec/commit/33466b1e2a6798bdd6d0e19149173585b0612e6f) Thanks [@TabishB](https://github.com/TabishB)! - Add project-level configuration, project-local schemas, and schema management commands\n\n  **New Features**\n\n  - **Project-level configuration** — Configure OpenSpec behavior per-project via `openspec/config.yaml`, including custom rules injection, context files, and schema resolution settings\n  - **Project-local schemas** — Define custom artifact schemas within your project's `openspec/schemas/` directory for project-specific workflows\n  - **Schema management commands** — New `openspec schema` commands (`list`, `show`, `export`, `validate`) for inspecting and managing artifact schemas (experimental)\n\n  **Bug Fixes**\n\n  - Fixed config loading to handle null `rules` field in project configuration\n\n## 0.21.0\n\n### Minor Changes\n\n- [#516](https://github.com/Fission-AI/OpenSpec/pull/516) [`b5a8847`](https://github.com/Fission-AI/OpenSpec/commit/b5a884748be6156a7bb140b4941cfec4f20a9fc8) Thanks [@TabishB](https://github.com/TabishB)! - Add feedback command and Nix flake support\n\n  **New Features**\n\n  - **Feedback command** — Submit feedback directly from the CLI with `openspec feedback`, which creates GitHub Issues with automatic metadata inclusion and graceful fallback for manual submission\n  - **Nix flake support** — Install and develop openspec using Nix with the new `flake.nix`, including automated flake maintenance and CI validation\n\n  **Bug Fixes**\n\n  - **Explore mode guardrails** — Explore mode now explicitly prevents implementation, keeping the focus on thinking and discovery while still allowing artifact creation\n\n  **Other**\n\n  - Improved change inference in `opsx apply` — automatically detects the target change from conversation context or prompts when ambiguous\n  - Streamlined archive sync assessment with clearer delta spec location guidance\n\n## 0.20.0\n\n### Minor Changes\n\n- [#502](https://github.com/Fission-AI/OpenSpec/pull/502) [`9db74aa`](https://github.com/Fission-AI/OpenSpec/commit/9db74aa5ac6547efadaed795217cfa17444f2004) Thanks [@TabishB](https://github.com/TabishB)! - Add `/opsx:verify` command and fix vitest process storms\n\n  **New Features**\n\n  - **`/opsx:verify` command** — Validate that change implementations match their specifications\n\n  **Bug Fixes**\n\n  - Fixed vitest process storms by capping worker parallelism\n  - Fixed agent workflows to use non-interactive mode for validation commands\n  - Fixed PowerShell completions generator to remove trailing commas\n\n## 0.19.0\n\n### Minor Changes\n\n- eb152eb: Add Continue IDE support, shell completions, and `/opsx:explore` command\n\n  **New Features**\n\n  - **Continue IDE support** – OpenSpec now generates slash commands for [Continue](https://continue.dev/), expanding editor integration options alongside Cursor, Windsurf, Claude Code, and others\n  - **Shell completions for Bash, Fish, and PowerShell** – Run `openspec completion install` to set up tab completion in your preferred shell\n  - **`/opsx:explore` command** – A new thinking partner mode for exploring ideas and investigating problems before committing to changes\n  - **Codebuddy slash command improvements** – Updated frontmatter format for better compatibility\n\n  **Bug Fixes**\n\n  - Shell completions now correctly offer parent-level flags (like `--help`) when a command has subcommands\n  - Fixed Windows compatibility issues in tests\n\n  **Other**\n\n  - Added optional anonymous usage statistics to help understand how OpenSpec is used. This is **opt-out** by default – set `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` to disable. Only command names and version are collected; no arguments, file paths, or content. Automatically disabled in CI environments.\n\n## 0.18.0\n\n### Minor Changes\n\n- 8dfd824: Add OPSX experimental workflow commands and enhanced artifact system\n\n  **New Commands:**\n\n  - `/opsx:ff` - Fast-forward through artifact creation, generating all needed artifacts in one go\n  - `/opsx:sync` - Sync delta specs from a change to main specs\n  - `/opsx:archive` - Archive completed changes with smart sync check\n\n  **Artifact Workflow Enhancements:**\n\n  - Schema-aware apply instructions with inline guidance and XML output\n  - Agent schema selection for experimental artifact workflow\n  - Per-change schema metadata via `.openspec.yaml` files\n  - Agent Skills for experimental artifact workflow\n  - Instruction loader for template loading and change context\n  - Restructured schemas as directories with templates\n\n  **Improvements:**\n\n  - Enhanced list command with last modified timestamps and sorting\n  - Change creation utilities for better workflow support\n\n  **Fixes:**\n\n  - Normalize paths for cross-platform glob compatibility\n  - Allow REMOVED requirements when creating new spec files\n\n## 0.17.2\n\n### Patch Changes\n\n- 455c65f: Fix `--no-interactive` flag in validate command to properly disable spinner, preventing hangs in pre-commit hooks and CI environments\n\n## 0.17.1\n\n### Patch Changes\n\n- a2757e7: Fix pre-commit hook hang issue in config command by using dynamic import for @inquirer/prompts\n\n  The config command was causing pre-commit hooks to hang indefinitely due to stdin event listeners being registered at module load time. This fix converts the static import to a dynamic import that only loads inquirer when the `config reset` command is actually used interactively.\n\n  Also adds ESLint with a rule to prevent static @inquirer imports, avoiding future regressions.\n\n## 0.17.0\n\n### Minor Changes\n\n- 2e71835: Add `openspec config` command and Oh-my-zsh completions\n\n  **New Features**\n\n  - Add `openspec config` command for managing global configuration settings\n  - Implement global config directory with XDG Base Directory specification support\n  - Add Oh-my-zsh shell completions support for enhanced CLI experience\n\n  **Bug Fixes**\n\n  - Fix hang in pre-commit hooks by using dynamic imports\n  - Respect XDG_CONFIG_HOME environment variable on all platforms\n  - Resolve Windows compatibility issues in zsh-installer tests\n  - Align cli-completion spec with implementation\n  - Remove hardcoded agent field from slash commands\n\n  **Documentation**\n\n  - Alphabetize AI tools list in README and make it collapsible\n\n## 0.16.0\n\n### Minor Changes\n\n- c08fbc1: Add new AI tool integrations and enhancements:\n\n  - **feat(iflow-cli)**: Add iFlow-cli integration with slash command support and documentation\n  - **feat(init)**: Add IDE restart instruction after init to inform users about slash command availability\n    **feat(antigravity)**: Add Antigravity slash command support\n  - **fix**: Generate TOML commands for Qwen Code (fixes #293)\n  - Clarify scaffold proposal documentation and enhance proposal guidelines\n  - Update proposal guidelines to emphasize design-first approach before implementation\n\n## Unreleased\n\n### Minor Changes\n\n- Add Continue slash command support so `openspec init` can generate `.continue/prompts/openspec-*.prompt` files with MARKDOWN frontmatter and `$ARGUMENTS` placeholder, and refresh them on `openspec update`.\n\n- Add Antigravity slash command support so `openspec init` can generate `.agent/workflows/openspec-*.md` files with description-only frontmatter and `openspec update` refreshes existing workflows alongside Windsurf.\n\n## 0.15.0\n\n### Minor Changes\n\n- 4758c5c: Add support for new AI tools with native slash command integration\n\n  - **Gemini CLI**: Add native TOML-based slash command support for Gemini CLI with `.gemini/commands/openspec/` integration\n  - **RooCode**: Add RooCode integration with configurator, slash commands, and templates\n  - **Cline**: Fix Cline to use workflows instead of rules for slash commands (`.clinerules/workflows/` paths)\n  - **Documentation**: Update documentation to reflect new integrations and workflow changes\n\n## 0.14.0\n\n### Minor Changes\n\n- 8386b91: Add support for new AI assistants and configuration improvements\n\n  - feat: add Qwen Code support with slash command integration\n  - feat: add $ARGUMENTS support to apply slash command for dynamic variable passing\n  - feat: add Qoder CLI support to configuration and documentation\n  - feat: add CoStrict AI assistant support\n  - fix: recreate missing openspec template files in extend mode\n  - fix: prevent false 'already configured' detection for tools\n  - fix: use change-id as fallback title instead of \"Untitled Change\"\n  - docs: add guidance for populating project-level context\n  - docs: add Crush to supported AI tools in README\n\n## 0.13.0\n\n### Minor Changes\n\n- 668a125: Add support for multiple AI assistants and improve validation\n\n  This release adds support for several new AI coding assistants:\n\n  - CodeBuddy Code - AI-powered coding assistant\n  - CodeRabbit - AI code review assistant\n  - Cline - Claude-powered CLI assistant\n  - Crush AI - AI assistant platform\n  - Auggie (Augment CLI) - Code augmentation tool\n\n  New features:\n\n  - Archive slash command now supports arguments for more flexible workflows\n\n  Bug fixes:\n\n  - Delta spec validation now handles case-insensitive headers and properly detects empty sections\n  - Archive validation now correctly honors --no-validate flag and ignores metadata\n\n  Documentation improvements:\n\n  - Added VS Code dev container configuration for easier development setup\n  - Updated AGENTS.md with explicit change-id notation\n  - Enhanced slash commands documentation with restart notes\n\n## 0.12.0\n\n### Minor Changes\n\n- 082abb4: Add factory function support for slash commands and non-interactive init options\n\n  This release includes two new features:\n\n  - **Factory function support for slash commands**: Slash commands can now be defined as functions that return command objects, enabling dynamic command configuration\n  - **Non-interactive init options**: Added `--tools`, `--all-tools`, and `--skip-tools` CLI flags to `openspec init` for automated initialization in CI/CD pipelines while maintaining backward compatibility with interactive mode\n\n## 0.11.0\n\n### Minor Changes\n\n- 312e1d6: Add Amazon Q Developer CLI integration. OpenSpec now supports Amazon Q Developer with automatic prompt generation in `.amazonq/prompts/` directory, allowing you to use OpenSpec slash commands with Amazon Q's @-syntax.\n\n## 0.10.0\n\n### Minor Changes\n\n- d7e0ce8: Improve init wizard Enter key behavior to allow proceeding through prompts more naturally\n\n## 0.9.2\n\n### Patch Changes\n\n- 2ae0484: Fix cross-platform path handling issues. This release includes fixes for joinPath behavior and slash command path resolution to ensure OpenSpec works correctly across all platforms.\n\n## 0.9.1\n\n### Patch Changes\n\n- 8210970: Fix OpenSpec not working on Windows when Codex integration is selected. This release includes fixes for cross-platform path handling and normalization to ensure OpenSpec works correctly on Windows systems.\n\n## 0.9.0\n\n### Minor Changes\n\n- efbbf3b: Add support for Codex and GitHub Copilot slash commands with YAML frontmatter and $ARGUMENTS\n\n## Unreleased\n\n### Minor Changes\n\n- Add GitHub Copilot slash command support. OpenSpec now writes prompts to `.github/prompts/openspec-{proposal,apply,archive}.prompt.md` with YAML frontmatter and `$ARGUMENTS` placeholder, and refreshes them on `openspec update`.\n\n## 0.8.1\n\n### Patch Changes\n\n- d070d08: Fix CLI version mismatch and add a release guard that validates the packed tarball prints the same version as package.json via `openspec --version`.\n\n## 0.8.0\n\n### Minor Changes\n\n- c29b06d: Add Windsurf support.\n- Add Codex slash command support. OpenSpec now writes prompts directly to Codex's global directory (`~/.codex/prompts` or `$CODEX_HOME/prompts`) and refreshes them on `openspec update`.\n\n## 0.7.0\n\n### Minor Changes\n\n- Add native Kilo Code workflow integration so `openspec init` and `openspec update` manage `.kilocode/workflows/openspec-*.md` files.\n- Always scaffold the managed root `AGENTS.md` hand-off stub and regroup the AI tool prompts during init/update to keep instructions consistent.\n\n## 0.6.0\n\n### Minor Changes\n\n- Slim the generated root agent instructions down to a managed hand-off stub and update the init/update flows to refresh it safely.\n\n## 0.5.0\n\n### Minor Changes\n\n- feat: implement Phase 1 E2E testing with cross-platform CI matrix\n\n  - Add shared runCLI helper in test/helpers/run-cli.ts for spawn testing\n  - Create test/cli-e2e/basic.test.ts covering help, version, validate flows\n  - Migrate existing CLI exec tests to use runCLI helper\n  - Extend CI matrix to bash (Linux/macOS) and pwsh (Windows)\n  - Split PR and main workflows for optimized feedback\n\n### Patch Changes\n\n- Make apply instructions more specific\n\n  Improve agent templates and slash command templates with more specific and actionable apply instructions.\n\n- docs: improve documentation and cleanup\n\n  - Document non-interactive flag for archive command\n  - Replace discord badge in README\n  - Archive completed changes for better organization\n\n## 0.4.0\n\n### Minor Changes\n\n- Add OpenSpec change proposals for CLI improvements and enhanced user experience\n- Add Opencode slash commands support for AI-driven development workflows\n\n### Patch Changes\n\n- Add documentation improvements including --yes flag for archive command template and Discord badge\n- Fix normalize line endings in markdown parser to handle CRLF files properly\n\n## 0.3.0\n\n### Minor Changes\n\n- Enhance `openspec init` with extend mode, multi-tool selection, and an interactive `AGENTS.md` configurator.\n\n## 0.2.0\n\n### Minor Changes\n\n- ce5cead: - Add an `openspec view` dashboard that rolls up spec counts and change progress at a glance\n  - Generate and update AI slash commands alongside the renamed `openspec/AGENTS.md` instructions file\n  - Remove the deprecated `openspec diff` command and direct users to `openspec show`\n\n## 0.1.0\n\n### Minor Changes\n\n- 24b4866: Initial release\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 OpenSpec Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n"
  },
  {
    "path": "MAINTAINERS.md",
    "content": "# Maintainers\n\nPeople who maintain and guide OpenSpec.\n\n## Core Maintainers\n\n| Name | GitHub | Role |\n|------|--------|------|\n| Tabish Bidiwale | [@TabishB](https://github.com/TabishB) | Lead maintainer |\n\n## Advisors\n\nAdvisors help shape technical direction and provide guidance to the project.\n\n| Name | GitHub | Focus |\n|------|--------|-------|\n| Hari Krishnan | [@harikrishnan83](https://github.com/harikrishnan83) | Technical direction |\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <a href=\"https://github.com/Fission-AI/OpenSpec\">\n    <picture>\n      <source srcset=\"assets/openspec_bg.png\">\n      <img src=\"assets/openspec_bg.png\" alt=\"OpenSpec logo\">\n    </picture>\n  </a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/Fission-AI/OpenSpec/actions/workflows/ci.yml\"><img alt=\"CI\" src=\"https://github.com/Fission-AI/OpenSpec/actions/workflows/ci.yml/badge.svg\" /></a>\n  <a href=\"https://www.npmjs.com/package/@fission-ai/openspec\"><img alt=\"npm version\" src=\"https://img.shields.io/npm/v/@fission-ai/openspec?style=flat-square\" /></a>\n  <a href=\"./LICENSE\"><img alt=\"License: MIT\" src=\"https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square\" /></a>\n  <a href=\"https://discord.gg/YctCnvvshC\"><img alt=\"Discord\" src=\"https://img.shields.io/discord/1411657095639601154?style=flat-square&logo=discord&logoColor=white&label=Discord&suffix=%20online\" /></a>\n</p>\n\n<details>\n<summary><strong>The most loved spec framework.</strong></summary>\n\n[![Stars](https://img.shields.io/github/stars/Fission-AI/OpenSpec?style=flat-square&label=Stars)](https://github.com/Fission-AI/OpenSpec/stargazers)\n[![Downloads](https://img.shields.io/npm/dm/@fission-ai/openspec?style=flat-square&label=Downloads/mo)](https://www.npmjs.com/package/@fission-ai/openspec)\n[![Contributors](https://img.shields.io/github/contributors/Fission-AI/OpenSpec?style=flat-square&label=Contributors)](https://github.com/Fission-AI/OpenSpec/graphs/contributors)\n\n</details>\n<p></p>\nOur philosophy:\n\n```text\n→ fluid not rigid\n→ iterative not waterfall\n→ easy not complex\n→ built for brownfield not just greenfield\n→ scalable from personal projects to enterprises\n```\n\n> [!TIP]\n> **New workflow now available!** We've rebuilt OpenSpec with a new artifact-guided workflow.\n>\n> Run `/opsx:propose \"your idea\"` to get started. → [Learn more here](docs/opsx.md)\n\n<p align=\"center\">\n  Follow <a href=\"https://x.com/0xTab\">@0xTab on X</a> for updates · Join the <a href=\"https://discord.gg/YctCnvvshC\">OpenSpec Discord</a> for help and questions.\n</p>\n\n### Teams\n\nUsing OpenSpec in a team? [Email here](mailto:teams@openspec.dev) for access to our Slack channel.\n\n<!-- TODO: Add GIF demo of /opsx:propose → /opsx:archive workflow -->\n\n## See it in action\n\n```text\nYou: /opsx:propose add-dark-mode\nAI:  Created openspec/changes/add-dark-mode/\n     ✓ proposal.md — why we're doing this, what's changing\n     ✓ specs/       — requirements and scenarios\n     ✓ design.md    — technical approach\n     ✓ tasks.md     — implementation checklist\n     Ready for implementation!\n\nYou: /opsx:apply\nAI:  Implementing tasks...\n     ✓ 1.1 Add theme context provider\n     ✓ 1.2 Create toggle component\n     ✓ 2.1 Add CSS variables\n     ✓ 2.2 Wire up localStorage\n     All tasks complete!\n\nYou: /opsx:archive\nAI:  Archived to openspec/changes/archive/2025-01-23-add-dark-mode/\n     Specs updated. Ready for the next feature.\n```\n\n<details>\n<summary><strong>OpenSpec Dashboard</strong></summary>\n\n<p align=\"center\">\n  <img src=\"assets/openspec_dashboard.png\" alt=\"OpenSpec dashboard preview\" width=\"90%\">\n</p>\n\n</details>\n\n## Quick Start\n\n**Requires Node.js 20.19.0 or higher.**\n\nInstall OpenSpec globally:\n\n```bash\nnpm install -g @fission-ai/openspec@latest\n```\n\nThen navigate to your project directory and initialize:\n\n```bash\ncd your-project\nopenspec init\n```\n\nNow tell your AI: `/opsx:propose <what-you-want-to-build>`\n\nIf 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`.\n\n> [!NOTE]\n> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 20+ tools and growing.\n>\n> Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).\n\n## Docs\n\n→ **[Getting Started](docs/getting-started.md)**: first steps<br>\n→ **[Workflows](docs/workflows.md)**: combos and patterns<br>\n→ **[Commands](docs/commands.md)**: slash commands & skills<br>\n→ **[CLI](docs/cli.md)**: terminal reference<br>\n→ **[Supported Tools](docs/supported-tools.md)**: tool integrations & install paths<br>\n→ **[Concepts](docs/concepts.md)**: how it all fits<br>\n→ **[Multi-Language](docs/multi-language.md)**: multi-language support<br>\n→ **[Customization](docs/customization.md)**: make it yours\n\n\n## Why OpenSpec?\n\nAI coding assistants are powerful but unpredictable when requirements live only in chat history. OpenSpec adds a lightweight spec layer so you agree on what to build before any code is written.\n\n- **Agree before you build** — human and AI align on specs before code gets written\n- **Stay organized** — each change gets its own folder with proposal, specs, design, and tasks\n- **Work fluidly** — update any artifact anytime, no rigid phase gates\n- **Use your tools** — works with 20+ AI assistants via slash commands\n\n### How we compare\n\n**vs. [Spec Kit](https://github.com/github/spec-kit)** (GitHub) — Thorough but heavyweight. Rigid phase gates, lots of Markdown, Python setup. OpenSpec is lighter and lets you iterate freely.\n\n**vs. [Kiro](https://kiro.dev)** (AWS) — Powerful but you're locked into their IDE and limited to Claude models. OpenSpec works with the tools you already use.\n\n**vs. nothing** — AI coding without specs means vague prompts and unpredictable results. OpenSpec brings predictability without the ceremony.\n\n## Updating OpenSpec\n\n**Upgrade the package**\n\n```bash\nnpm install -g @fission-ai/openspec@latest\n```\n\n**Refresh agent instructions**\n\nRun this inside each project to regenerate AI guidance and ensure the latest slash commands are active:\n\n```bash\nopenspec update\n```\n\n## Usage Notes\n\n**Model selection**: OpenSpec works best with high-reasoning models. We recommend Opus 4.5 and GPT 5.2 for both planning and implementation.\n\n**Context hygiene**: OpenSpec benefits from a clean context window. Clear your context before starting implementation and maintain good context hygiene throughout your session.\n\n## Contributing\n\n**Small fixes** — Bug fixes, typo corrections, and minor improvements can be submitted directly as PRs.\n\n**Larger changes** — For new features, significant refactors, or architectural changes, please submit an OpenSpec change proposal first so we can align on intent and goals before implementation begins.\n\nWhen writing proposals, keep the OpenSpec philosophy in mind: we serve a wide variety of users across different coding agents, models, and use cases. Changes should work well for everyone.\n\n**AI-generated code is welcome** — as long as it's been tested and verified. PRs containing AI-generated code should mention the coding agent and model used (e.g., \"Generated with Claude Code using claude-opus-4-5-20251101\").\n\n### Development\n\n- Install dependencies: `pnpm install`\n- Build: `pnpm run build`\n- Test: `pnpm test`\n- Develop CLI locally: `pnpm run dev` or `pnpm run dev:cli`\n- Conventional commits (one-line): `type(scope): subject`\n\n## Other\n\n<details>\n<summary><strong>Telemetry</strong></summary>\n\nOpenSpec collects anonymous usage stats.\n\nWe collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.\n\n**Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`\n\n</details>\n\n<details>\n<summary><strong>Maintainers & Advisors</strong></summary>\n\nSee [MAINTAINERS.md](MAINTAINERS.md) for the list of core maintainers and advisors who help guide the project.\n\n</details>\n\n\n\n## License\n\nMIT\n"
  },
  {
    "path": "README_OLD.md",
    "content": "<p align=\"center\">\n  <a href=\"https://github.com/Fission-AI/OpenSpec\">\n    <picture>\n      <source srcset=\"assets/openspec_pixel_dark.svg\" media=\"(prefers-color-scheme: dark)\">\n      <source srcset=\"assets/openspec_pixel_light.svg\" media=\"(prefers-color-scheme: light)\">\n      <img src=\"assets/openspec_pixel_light.svg\" alt=\"OpenSpec logo\" height=\"64\">\n    </picture>\n  </a>\n  \n</p>\n<p align=\"center\">Spec-driven development for AI coding assistants.</p>\n<p align=\"center\">\n  <a href=\"https://github.com/Fission-AI/OpenSpec/actions/workflows/ci.yml\"><img alt=\"CI\" src=\"https://github.com/Fission-AI/OpenSpec/actions/workflows/ci.yml/badge.svg\" /></a>\n  <a href=\"https://www.npmjs.com/package/@fission-ai/openspec\"><img alt=\"npm version\" src=\"https://img.shields.io/npm/v/@fission-ai/openspec?style=flat-square\" /></a>\n  <a href=\"https://nodejs.org/\"><img alt=\"node version\" src=\"https://img.shields.io/node/v/@fission-ai/openspec?style=flat-square\" /></a>\n  <a href=\"./LICENSE\"><img alt=\"License: MIT\" src=\"https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square\" /></a>\n  <a href=\"https://conventionalcommits.org\"><img alt=\"Conventional Commits\" src=\"https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square\" /></a>\n  <a href=\"https://discord.gg/YctCnvvshC\"><img alt=\"Discord\" src=\"https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white&style=flat-square\" /></a>\n</p>\n\n<p align=\"center\">\n  <img src=\"assets/openspec_dashboard.png\" alt=\"OpenSpec dashboard preview\" width=\"90%\">\n</p>\n\n<p align=\"center\">\n  Follow <a href=\"https://x.com/0xTab\">@0xTab on X</a> for updates · Join the <a href=\"https://discord.gg/YctCnvvshC\">OpenSpec Discord</a> for help and questions.\n</p>\n\n<p align=\"center\">\n  <sub>🧪 <strong>New:</strong> <a href=\"docs/opsx.md\">OPSX Workflow</a> — schema-driven, hackable, fluid. Iterate on workflows without code changes.</sub>\n</p>\n\n# OpenSpec\n\nOpenSpec aligns humans and AI coding assistants with spec-driven development so you agree on what to build before any code is written. **No API keys required.**\n\n## Why OpenSpec?\n\nAI coding assistants are powerful but unpredictable when requirements live in chat history. OpenSpec adds a lightweight specification workflow that locks intent before implementation, giving you deterministic, reviewable outputs.\n\nKey outcomes:\n- Human and AI stakeholders agree on specs before work begins.\n- Structured change folders (proposals, tasks, and spec updates) keep scope explicit and auditable.\n- Shared visibility into what's proposed, active, or archived.\n- Works with the AI tools you already use: custom slash commands where supported, context rules everywhere else.\n\n## How OpenSpec compares (at a glance)\n\n- **Lightweight**: simple workflow, no API keys, minimal setup.\n- **Brownfield-first**: works great beyond 0→1. OpenSpec separates the source of truth from proposals: `openspec/specs/` (current truth) and `openspec/changes/` (proposed updates). This keeps diffs explicit and manageable across features.\n- **Change tracking**: proposals, tasks, and spec deltas live together; archiving merges the approved updates back into specs.\n- **Compared to spec-kit & Kiro**: those shine for brand-new features (0→1). OpenSpec also excels when modifying existing behavior (1→n), especially when updates span multiple specs.\n\nSee the full comparison in [How OpenSpec Compares](#how-openspec-compares).\n\n## How It Works\n\n```\n┌────────────────────┐\n│ Draft Change       │\n│ Proposal           │\n└────────┬───────────┘\n         │ share intent with your AI\n         ▼\n┌────────────────────┐\n│ Review & Align     │\n│ (edit specs/tasks) │◀──── feedback loop ──────┐\n└────────┬───────────┘                          │\n         │ approved plan                        │\n         ▼                                      │\n┌────────────────────┐                          │\n│ Implement Tasks    │──────────────────────────┘\n│ (AI writes code)   │\n└────────┬───────────┘\n         │ ship the change\n         ▼\n┌────────────────────┐\n│ Archive & Update   │\n│ Specs (source)     │\n└────────────────────┘\n\n1. Draft a change proposal that captures the spec updates you want.\n2. Review the proposal with your AI assistant until everyone agrees.\n3. Implement tasks that reference the agreed specs.\n4. Archive the change to merge the approved updates back into the source-of-truth specs.\n```\n\n## Getting Started\n\n### Supported AI Tools\n\n<details>\n<summary><strong>Native Slash Commands</strong> (click to expand)</summary>\n\nThese tools have built-in OpenSpec commands. Select the OpenSpec integration when prompted.\n\n| Tool | Commands |\n|------|----------|\n| **Amazon Q Developer** | `@openspec-proposal`, `@openspec-apply`, `@openspec-archive` (`.amazonq/prompts/`) |\n| **Antigravity** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.agent/workflows/`) |\n| **Auggie (Augment CLI)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.augment/commands/`) |\n| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |\n| **Cline** | Workflows in `.clinerules/workflows/` directory (`.clinerules/workflows/openspec-*.md`) |\n| **CodeBuddy Code (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) — see [docs](https://www.codebuddy.ai/cli) |\n| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |\n| **Continue** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.continue/prompts/`) |\n| **CoStrict** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.cospec/openspec/commands/`) — see [docs](https://costrict.ai)|\n| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |\n| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |\n| **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) |\n| **Gemini CLI** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.gemini/commands/openspec/`) |\n| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) |\n| **iFlow (iflow-cli)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.iflow/commands/`) |\n| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |\n| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |\n| **Qoder** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.qoder/commands/openspec/`) — see [docs](https://qoder.com) |\n| **Qwen Code** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.qwen/commands/`) |\n| **RooCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.roo/commands/`) |\n| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |\n\nKilo Code discovers team workflows automatically. Save the generated files under `.kilocode/workflows/` and trigger them from the command palette with `/openspec-proposal.md`, `/openspec-apply.md`, or `/openspec-archive.md`.\n\n</details>\n\n<details>\n<summary><strong>AGENTS.md Compatible</strong> (click to expand)</summary>\n\nThese tools automatically read workflow instructions from `openspec/AGENTS.md`. Ask them to follow the OpenSpec workflow if they need a reminder. Learn more about the [AGENTS.md convention](https://agents.md/).\n\n| Tools |\n|-------|\n| Amp • Jules • Others |\n\n</details>\n\n### Install & Initialize\n\n#### Prerequisites\n- **Node.js >= 20.19.0** - Check your version with `node --version`\n\n#### Step 1: Install the CLI globally\n\n**Option A: Using npm**\n\n```bash\nnpm install -g @fission-ai/openspec@latest\n```\n\nVerify installation:\n```bash\nopenspec --version\n```\n\n**Option B: Using Nix (NixOS and Nix package manager)**\n\nRun OpenSpec directly without installation:\n```bash\nnix run github:Fission-AI/OpenSpec -- init\n```\n\nOr install to your profile:\n```bash\nnix profile install github:Fission-AI/OpenSpec\n```\n\nOr add to your development environment in `flake.nix`:\n```nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    openspec.url = \"github:Fission-AI/OpenSpec\";\n  };\n\n  outputs = { nixpkgs, openspec, ... }: {\n    devShells.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShell {\n      buildInputs = [ openspec.packages.x86_64-linux.default ];\n    };\n  };\n}\n```\n\nVerify installation:\n```bash\nopenspec --version\n```\n\n#### Step 2: Initialize OpenSpec in your project\n\nNavigate to your project directory:\n```bash\ncd my-project\n```\n\nRun the initialization:\n```bash\nopenspec init\n```\n\n**What happens during initialization:**\n- You'll be prompted to pick any natively supported AI tools (Claude Code, CodeBuddy, Cursor, OpenCode, Qoder,etc.); other assistants always rely on the shared `AGENTS.md` stub\n- OpenSpec automatically configures slash commands for the tools you choose and always writes a managed `AGENTS.md` hand-off at the project root\n- A new `openspec/` directory structure is created in your project\n\n**After setup:**\n- Primary AI tools can trigger `/openspec` workflows without additional configuration\n- Run `openspec list` to verify the setup and view any active changes\n- If your coding assistant doesn't surface the new slash commands right away, restart it. Slash commands are loaded at startup,\n  so a fresh launch ensures they appear\n\n### Optional: Populate Project Context\n\nAfter `openspec init` completes, you'll receive a suggested prompt to help populate your project context:\n\n```text\nPopulate your project context:\n\"Please read openspec/project.md and help me fill it out with details about my project, tech stack, and conventions\"\n```\n\nUse `openspec/project.md` to define project-level conventions, standards, architectural patterns, and other guidelines that should be followed across all changes.\n\n### Create Your First Change\n\nHere's a real example showing the complete OpenSpec workflow. This works with any AI tool. Those with native slash commands will recognize the shortcuts automatically.\n\n#### 1. Draft the Proposal\nStart by asking your AI to create a change proposal:\n\n```text\nYou: Create an OpenSpec change proposal for adding profile search filters by role and team\n     (Shortcut for tools with slash commands: /openspec:proposal Add profile search filters)\n\nAI:  I'll create an OpenSpec change proposal for profile filters.\n     *Scaffolds openspec/changes/add-profile-filters/ with proposal.md, tasks.md, spec deltas.*\n```\n\n#### 2. Verify & Review\nCheck that the change was created correctly and review the proposal:\n\n```bash\n$ openspec list                             # Confirm the change folder exists\n$ openspec validate add-profile-filters     # Validate spec formatting\n$ openspec show add-profile-filters         # Review proposal, tasks, and spec delta\n```\n\n#### 3. Refine the Specs\nIterate on the specifications until they match your needs:\n\n```text\nYou: Can you add acceptance criteria for the role and team filters?\n\nAI:  I'll update the spec delta with scenarios for role and team filters.\n     *Edits openspec/changes/add-profile-filters/specs/profile/spec.md and tasks.md.*\n```\n\n#### 4. Implement the Change\nOnce specs look good, start implementation:\n\n```text\nYou: The specs look good. Let's implement this change.\n     (Shortcut for tools with slash commands: /openspec:apply add-profile-filters)\n\nAI:  I'll work through the tasks in the add-profile-filters change.\n     *Implements tasks from openspec/changes/add-profile-filters/tasks.md*\n     *Marks tasks complete: Task 1.1 ✓, Task 1.2 ✓, Task 2.1 ✓...*\n```\n\n#### 5. Archive the Completed Change\nAfter implementation is complete, archive the change:\n\n```text\nAI:  All tasks are complete. The implementation is ready.\n\nYou: Please archive the change\n     (Shortcut for tools with slash commands: /openspec:archive add-profile-filters)\n\nAI:  I'll archive the add-profile-filters change.\n    *Runs: openspec archive add-profile-filters --yes*\n     ✓ Change archived successfully. Specs updated. Ready for the next feature!\n```\n\nOr run the command yourself in terminal:\n```bash\n$ openspec archive add-profile-filters --yes  # Archive the completed change without prompts\n```\n\n**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex, Qoder, RooCode) can use the shortcuts shown. All other tools work with natural language requests to \"create an OpenSpec proposal\", \"apply the OpenSpec change\", or \"archive the change\".\n\n## Command Reference\n\n```bash\nopenspec list               # View active change folders\nopenspec view               # Interactive dashboard of specs and changes\nopenspec show <change>      # Display change details (proposal, tasks, spec updates)\nopenspec validate <change>  # Check spec formatting and structure\nopenspec archive <change> [--yes|-y]   # Move a completed change into archive/ (non-interactive with --yes)\n```\n\n## Example: How AI Creates OpenSpec Files\n\nWhen you ask your AI assistant to \"add two-factor authentication\", it creates:\n\n```\nopenspec/\n├── specs/\n│   └── auth/\n│       └── spec.md           # Current auth spec (if exists)\n└── changes/\n    └── add-2fa/              # AI creates this entire structure\n        ├── proposal.md       # Why and what changes\n        ├── tasks.md          # Implementation checklist\n        ├── design.md         # Technical decisions (optional)\n        └── specs/\n            └── auth/\n                └── spec.md   # Delta showing additions\n```\n\n### AI-Generated Spec (created in `openspec/specs/auth/spec.md`):\n\n```markdown\n# Auth Specification\n\n## Purpose\nAuthentication and session management.\n\n## Requirements\n### Requirement: User Authentication\nThe system SHALL issue a JWT on successful login.\n\n#### Scenario: Valid credentials\n- WHEN a user submits valid credentials\n- THEN a JWT is returned\n```\n\n### AI-Generated Change Delta (created in `openspec/changes/add-2fa/specs/auth/spec.md`):\n\n```markdown\n# Delta for Auth\n\n## ADDED Requirements\n### Requirement: Two-Factor Authentication\nThe system MUST require a second factor during login.\n\n#### Scenario: OTP required\n- WHEN a user submits valid credentials\n- THEN an OTP challenge is required\n```\n\n### AI-Generated Tasks (created in `openspec/changes/add-2fa/tasks.md`):\n\n```markdown\n## 1. Database Setup\n- [ ] 1.1 Add OTP secret column to users table\n- [ ] 1.2 Create OTP verification logs table\n\n## 2. Backend Implementation  \n- [ ] 2.1 Add OTP generation endpoint\n- [ ] 2.2 Modify login flow to require OTP\n- [ ] 2.3 Add OTP verification endpoint\n\n## 3. Frontend Updates\n- [ ] 3.1 Create OTP input component\n- [ ] 3.2 Update login flow UI\n```\n\n**Important:** You don't create these files manually. Your AI assistant generates them based on your requirements and the existing codebase.\n\n## Understanding OpenSpec Files\n\n### Delta Format\n\nDeltas are \"patches\" that show how specs change:\n\n- **`## ADDED Requirements`** - New capabilities\n- **`## MODIFIED Requirements`** - Changed behavior (include complete updated text)\n- **`## REMOVED Requirements`** - Deprecated features\n\n**Format requirements:**\n- Use `### Requirement: <name>` for headers\n- Every requirement needs at least one `#### Scenario:` block\n- Use SHALL/MUST in requirement text\n\n## How OpenSpec Compares\n\n### vs. spec-kit\nOpenSpec’s two-folder model (`openspec/specs/` for the current truth, `openspec/changes/` for proposed updates) keeps state and diffs separate. This scales when you modify existing features or touch multiple specs. spec-kit is strong for greenfield/0→1 but provides less structure for cross-spec updates and evolving features.\n\n### vs. Kiro.dev\nOpenSpec groups every change for a feature in one folder (`openspec/changes/feature-name/`), making it easy to track related specs, tasks, and designs together. Kiro spreads updates across multiple spec folders, which can make feature tracking harder.\n\n### vs. No Specs\nWithout specs, AI coding assistants generate code from vague prompts, often missing requirements or adding unwanted features. OpenSpec brings predictability by agreeing on the desired behavior before any code is written.\n\n## Team Adoption\n\n1. **Initialize OpenSpec** – Run `openspec init` in your repo.\n2. **Start with new features** – Ask your AI to capture upcoming work as change proposals.\n3. **Grow incrementally** – Each change archives into living specs that document your system.\n4. **Stay flexible** – Different teammates can use Claude Code, CodeBuddy, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.\n\nRun `openspec update` whenever someone switches tools so your agents pick up the latest instructions and slash-command bindings.\n\n## Updating OpenSpec\n\n1. **Upgrade the package**\n   ```bash\n   npm install -g @fission-ai/openspec@latest\n   ```\n2. **Refresh agent instructions**\n   - Run `openspec update` inside each project to regenerate AI guidance and ensure the latest slash commands are active.\n\n## Experimental Features\n\n<details>\n<summary><strong>🧪 OPSX: Fluid, Iterative Workflow</strong> (Claude Code only)</summary>\n\n**Why this exists:**\n- Standard workflow is locked down — you can't tweak instructions or customize\n- When AI output is bad, you can't improve the prompts yourself\n- Same workflow for everyone, no way to match how your team works\n\n**What's different:**\n- **Hackable** — edit templates and schemas yourself, test immediately, no rebuild\n- **Granular** — each artifact has its own instructions, test and tweak individually\n- **Customizable** — define your own workflows, artifacts, and dependencies\n- **Fluid** — no phase gates, update any artifact anytime\n\n```\nYou can always go back:\n\n  proposal ──→ specs ──→ design ──→ tasks ──→ implement\n     ▲           ▲          ▲                    │\n     └───────────┴──────────┴────────────────────┘\n```\n\n| Command | What it does |\n|---------|--------------|\n| `/opsx:new` | Start a new change |\n| `/opsx:continue` | Create the next artifact (based on what's ready) |\n| `/opsx:ff` | Fast-forward (all planning artifacts at once) |\n| `/opsx:apply` | Implement tasks, updating artifacts as needed |\n| `/opsx:archive` | Archive when done |\n\n**Setup:** `openspec experimental`\n\n[Full documentation →](docs/opsx.md)\n\n</details>\n\n<details>\n<summary><strong>Telemetry</strong> – OpenSpec collects anonymous usage stats (opt-out: <code>OPENSPEC_TELEMETRY=0</code>)</summary>\n\nWe collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.\n\n**Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`\n\n</details>\n\n## Contributing\n\n- Install dependencies: `pnpm install`\n- Build: `pnpm run build`\n- Test: `pnpm test`\n- Develop CLI locally: `pnpm run dev` or `pnpm run dev:cli`\n- Conventional commits (one-line): `type(scope): subject`\n\n<details>\n<summary><strong>Maintainers & Advisors</strong></summary>\n\nSee [MAINTAINERS.md](MAINTAINERS.md) for the list of core maintainers and advisors who help guide the project.\n\n</details>\n\n## License\n\nMIT\n"
  },
  {
    "path": "bin/openspec.js",
    "content": "#!/usr/bin/env node\n\nimport '../dist/cli/index.js';"
  },
  {
    "path": "build.js",
    "content": "#!/usr/bin/env node\n\nimport { execFileSync } from 'child_process';\nimport { existsSync, rmSync } from 'fs';\nimport { createRequire } from 'module';\n\nconst require = createRequire(import.meta.url);\n\nconst runTsc = (args = []) => {\n  const tscPath = require.resolve('typescript/bin/tsc');\n  execFileSync(process.execPath, [tscPath, ...args], { stdio: 'inherit' });\n};\n\nconsole.log('🔨 Building OpenSpec...\\n');\n\n// Clean dist directory\nif (existsSync('dist')) {\n  console.log('Cleaning dist directory...');\n  rmSync('dist', { recursive: true, force: true });\n}\n\n// Run TypeScript compiler (use local version explicitly)\nconsole.log('Compiling TypeScript...');\ntry {\n  runTsc(['--version']);\n  runTsc();\n  console.log('\\n✅ Build completed successfully!');\n} catch (error) {\n  console.error('\\n❌ Build failed!');\n  process.exit(1);\n}\n"
  },
  {
    "path": "docs/cli.md",
    "content": "# CLI Reference\n\nThe OpenSpec CLI (`openspec`) provides terminal commands for project setup, validation, status inspection, and management. These commands complement the AI slash commands (like `/opsx:propose`) documented in [Commands](commands.md).\n\n## Summary\n\n| Category | Commands | Purpose |\n|----------|----------|---------|\n| **Setup** | `init`, `update` | Initialize and update OpenSpec in your project |\n| **Browsing** | `list`, `view`, `show` | Explore changes and specs |\n| **Validation** | `validate` | Check changes and specs for issues |\n| **Lifecycle** | `archive` | Finalize completed changes |\n| **Workflow** | `status`, `instructions`, `templates`, `schemas` | Artifact-driven workflow support |\n| **Schemas** | `schema init`, `schema fork`, `schema validate`, `schema which` | Create and manage custom workflows |\n| **Config** | `config` | View and modify settings |\n| **Utility** | `feedback`, `completion` | Feedback and shell integration |\n\n---\n\n## Human vs Agent Commands\n\nMost CLI commands are designed for **human use** in a terminal. Some commands also support **agent/script use** via JSON output.\n\n### Human-Only Commands\n\nThese commands are interactive and designed for terminal use:\n\n| Command | Purpose |\n|---------|---------|\n| `openspec init` | Initialize project (interactive prompts) |\n| `openspec view` | Interactive dashboard |\n| `openspec config edit` | Open config in editor |\n| `openspec feedback` | Submit feedback via GitHub |\n| `openspec completion install` | Install shell completions |\n\n### Agent-Compatible Commands\n\nThese commands support `--json` output for programmatic use by AI agents and scripts:\n\n| Command | Human Use | Agent Use |\n|---------|-----------|-----------|\n| `openspec list` | Browse changes/specs | `--json` for structured data |\n| `openspec show <item>` | Read content | `--json` for parsing |\n| `openspec validate` | Check for issues | `--all --json` for bulk validation |\n| `openspec status` | See artifact progress | `--json` for structured status |\n| `openspec instructions` | Get next steps | `--json` for agent instructions |\n| `openspec templates` | Find template paths | `--json` for path resolution |\n| `openspec schemas` | List available schemas | `--json` for schema discovery |\n\n---\n\n## Global Options\n\nThese options work with all commands:\n\n| Option | Description |\n|--------|-------------|\n| `--version`, `-V` | Show version number |\n| `--no-color` | Disable color output |\n| `--help`, `-h` | Display help for command |\n\n---\n\n## Setup Commands\n\n### `openspec init`\n\nInitialize OpenSpec in your project. Creates the folder structure and configures AI tool integrations.\n\nDefault behavior uses global config defaults: profile `core`, delivery `both`, workflows `propose, explore, apply, archive`.\n\n```\nopenspec init [path] [options]\n```\n\n**Arguments:**\n\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `path` | No | Target directory (default: current directory) |\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--tools <list>` | Configure AI tools non-interactively. Use `all`, `none`, or comma-separated list |\n| `--force` | Auto-cleanup legacy files without prompting |\n| `--profile <profile>` | Override global profile for this init run (`core` or `custom`) |\n\n`--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`).\n\n**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`\n\n**Examples:**\n\n```bash\n# Interactive initialization\nopenspec init\n\n# Initialize in a specific directory\nopenspec init ./my-project\n\n# Non-interactive: configure for Claude and Cursor\nopenspec init --tools claude,cursor\n\n# Configure for all supported tools\nopenspec init --tools all\n\n# Override profile for this run\nopenspec init --profile core\n\n# Skip prompts and auto-cleanup legacy files\nopenspec init --force\n```\n\n**What it creates:**\n\n```\nopenspec/\n├── specs/              # Your specifications (source of truth)\n├── changes/            # Proposed changes\n└── config.yaml         # Project configuration\n\n.claude/skills/         # Claude Code skills (if claude selected)\n.cursor/skills/         # Cursor skills (if cursor selected)\n.cursor/commands/       # Cursor OPSX commands (if delivery includes commands)\n... (other tool configs)\n```\n\n---\n\n### `openspec update`\n\nUpdate OpenSpec instruction files after upgrading the CLI. Re-generates AI tool configuration files using your current global profile, selected workflows, and delivery mode.\n\n```\nopenspec update [path] [options]\n```\n\n**Arguments:**\n\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `path` | No | Target directory (default: current directory) |\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--force` | Force update even when files are up to date |\n\n**Example:**\n\n```bash\n# Update instruction files after npm upgrade\nnpm update @fission-ai/openspec\nopenspec update\n```\n\n---\n\n## Browsing Commands\n\n### `openspec list`\n\nList changes or specs in your project.\n\n```\nopenspec list [options]\n```\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--specs` | List specs instead of changes |\n| `--changes` | List changes (default) |\n| `--sort <order>` | Sort by `recent` (default) or `name` |\n| `--json` | Output as JSON |\n\n**Examples:**\n\n```bash\n# List all active changes\nopenspec list\n\n# List all specs\nopenspec list --specs\n\n# JSON output for scripts\nopenspec list --json\n```\n\n**Output (text):**\n\n```\nActive changes:\n  add-dark-mode     UI theme switching support\n  fix-login-bug     Session timeout handling\n```\n\n---\n\n### `openspec view`\n\nDisplay an interactive dashboard for exploring specs and changes.\n\n```\nopenspec view\n```\n\nOpens a terminal-based interface for navigating your project's specifications and changes.\n\n---\n\n### `openspec show`\n\nDisplay details of a change or spec.\n\n```\nopenspec show [item-name] [options]\n```\n\n**Arguments:**\n\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `item-name` | No | Name of change or spec (prompts if omitted) |\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--type <type>` | Specify type: `change` or `spec` (auto-detected if unambiguous) |\n| `--json` | Output as JSON |\n| `--no-interactive` | Disable prompts |\n\n**Change-specific options:**\n\n| Option | Description |\n|--------|-------------|\n| `--deltas-only` | Show only delta specs (JSON mode) |\n\n**Spec-specific options:**\n\n| Option | Description |\n|--------|-------------|\n| `--requirements` | Show only requirements, exclude scenarios (JSON mode) |\n| `--no-scenarios` | Exclude scenario content (JSON mode) |\n| `-r, --requirement <id>` | Show specific requirement by 1-based index (JSON mode) |\n\n**Examples:**\n\n```bash\n# Interactive selection\nopenspec show\n\n# Show a specific change\nopenspec show add-dark-mode\n\n# Show a specific spec\nopenspec show auth --type spec\n\n# JSON output for parsing\nopenspec show add-dark-mode --json\n```\n\n---\n\n## Validation Commands\n\n### `openspec validate`\n\nValidate changes and specs for structural issues.\n\n```\nopenspec validate [item-name] [options]\n```\n\n**Arguments:**\n\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `item-name` | No | Specific item to validate (prompts if omitted) |\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--all` | Validate all changes and specs |\n| `--changes` | Validate all changes |\n| `--specs` | Validate all specs |\n| `--type <type>` | Specify type when name is ambiguous: `change` or `spec` |\n| `--strict` | Enable strict validation mode |\n| `--json` | Output as JSON |\n| `--concurrency <n>` | Max parallel validations (default: 6, or `OPENSPEC_CONCURRENCY` env) |\n| `--no-interactive` | Disable prompts |\n\n**Examples:**\n\n```bash\n# Interactive validation\nopenspec validate\n\n# Validate a specific change\nopenspec validate add-dark-mode\n\n# Validate all changes\nopenspec validate --changes\n\n# Validate everything with JSON output (for CI/scripts)\nopenspec validate --all --json\n\n# Strict validation with increased parallelism\nopenspec validate --all --strict --concurrency 12\n```\n\n**Output (text):**\n\n```\nValidating add-dark-mode...\n  ✓ proposal.md valid\n  ✓ specs/ui/spec.md valid\n  ⚠ design.md: missing \"Technical Approach\" section\n\n1 warning found\n```\n\n**Output (JSON):**\n\n```json\n{\n  \"version\": \"1.0.0\",\n  \"results\": {\n    \"changes\": [\n      {\n        \"name\": \"add-dark-mode\",\n        \"valid\": true,\n        \"warnings\": [\"design.md: missing 'Technical Approach' section\"]\n      }\n    ]\n  },\n  \"summary\": {\n    \"total\": 1,\n    \"valid\": 1,\n    \"invalid\": 0\n  }\n}\n```\n\n---\n\n## Lifecycle Commands\n\n### `openspec archive`\n\nArchive a completed change and merge delta specs into main specs.\n\n```\nopenspec archive [change-name] [options]\n```\n\n**Arguments:**\n\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `change-name` | No | Change to archive (prompts if omitted) |\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `-y, --yes` | Skip confirmation prompts |\n| `--skip-specs` | Skip spec updates (for infrastructure/tooling/doc-only changes) |\n| `--no-validate` | Skip validation (requires confirmation) |\n\n**Examples:**\n\n```bash\n# Interactive archive\nopenspec archive\n\n# Archive specific change\nopenspec archive add-dark-mode\n\n# Archive without prompts (CI/scripts)\nopenspec archive add-dark-mode --yes\n\n# Archive a tooling change that doesn't affect specs\nopenspec archive update-ci-config --skip-specs\n```\n\n**What it does:**\n\n1. Validates the change (unless `--no-validate`)\n2. Prompts for confirmation (unless `--yes`)\n3. Merges delta specs into `openspec/specs/`\n4. Moves change folder to `openspec/changes/archive/YYYY-MM-DD-<name>/`\n\n---\n\n## Workflow Commands\n\nThese commands support the artifact-driven OPSX workflow. They're useful for both humans checking progress and agents determining next steps.\n\n### `openspec status`\n\nDisplay artifact completion status for a change.\n\n```\nopenspec status [options]\n```\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--change <id>` | Change name (prompts if omitted) |\n| `--schema <name>` | Schema override (auto-detected from change's config) |\n| `--json` | Output as JSON |\n\n**Examples:**\n\n```bash\n# Interactive status check\nopenspec status\n\n# Status for specific change\nopenspec status --change add-dark-mode\n\n# JSON for agent use\nopenspec status --change add-dark-mode --json\n```\n\n**Output (text):**\n\n```\nChange: add-dark-mode\nSchema: spec-driven\nProgress: 2/4 artifacts complete\n\n[x] proposal\n[ ] design\n[x] specs\n[-] tasks (blocked by: design)\n```\n\n**Output (JSON):**\n\n```json\n{\n  \"changeName\": \"add-dark-mode\",\n  \"schemaName\": \"spec-driven\",\n  \"isComplete\": false,\n  \"applyRequires\": [\"tasks\"],\n  \"artifacts\": [\n    {\"id\": \"proposal\", \"outputPath\": \"proposal.md\", \"status\": \"done\"},\n    {\"id\": \"design\", \"outputPath\": \"design.md\", \"status\": \"ready\"},\n    {\"id\": \"specs\", \"outputPath\": \"specs/**/*.md\", \"status\": \"done\"},\n    {\"id\": \"tasks\", \"outputPath\": \"tasks.md\", \"status\": \"blocked\", \"missingDeps\": [\"design\"]}\n  ]\n}\n```\n\n---\n\n### `openspec instructions`\n\nGet enriched instructions for creating an artifact or applying tasks. Used by AI agents to understand what to create next.\n\n```\nopenspec instructions [artifact] [options]\n```\n\n**Arguments:**\n\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `artifact` | No | Artifact ID: `proposal`, `specs`, `design`, `tasks`, or `apply` |\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--change <id>` | Change name (required in non-interactive mode) |\n| `--schema <name>` | Schema override |\n| `--json` | Output as JSON |\n\n**Special case:** Use `apply` as the artifact to get task implementation instructions.\n\n**Examples:**\n\n```bash\n# Get instructions for next artifact\nopenspec instructions --change add-dark-mode\n\n# Get specific artifact instructions\nopenspec instructions design --change add-dark-mode\n\n# Get apply/implementation instructions\nopenspec instructions apply --change add-dark-mode\n\n# JSON for agent consumption\nopenspec instructions design --change add-dark-mode --json\n```\n\n**Output includes:**\n\n- Template content for the artifact\n- Project context from config\n- Content from dependency artifacts\n- Per-artifact rules from config\n\n---\n\n### `openspec templates`\n\nShow resolved template paths for all artifacts in a schema.\n\n```\nopenspec templates [options]\n```\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--schema <name>` | Schema to inspect (default: `spec-driven`) |\n| `--json` | Output as JSON |\n\n**Examples:**\n\n```bash\n# Show template paths for default schema\nopenspec templates\n\n# Show templates for custom schema\nopenspec templates --schema my-workflow\n\n# JSON for programmatic use\nopenspec templates --json\n```\n\n**Output (text):**\n\n```\nSchema: spec-driven\n\nTemplates:\n  proposal  → ~/.openspec/schemas/spec-driven/templates/proposal.md\n  specs     → ~/.openspec/schemas/spec-driven/templates/specs.md\n  design    → ~/.openspec/schemas/spec-driven/templates/design.md\n  tasks     → ~/.openspec/schemas/spec-driven/templates/tasks.md\n```\n\n---\n\n### `openspec schemas`\n\nList available workflow schemas with their descriptions and artifact flows.\n\n```\nopenspec schemas [options]\n```\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--json` | Output as JSON |\n\n**Example:**\n\n```bash\nopenspec schemas\n```\n\n**Output:**\n\n```\nAvailable schemas:\n\n  spec-driven (package)\n    The default spec-driven development workflow\n    Flow: proposal → specs → design → tasks\n\n  my-custom (project)\n    Custom workflow for this project\n    Flow: research → proposal → tasks\n```\n\n---\n\n## Schema Commands\n\nCommands for creating and managing custom workflow schemas.\n\n### `openspec schema init`\n\nCreate a new project-local schema.\n\n```\nopenspec schema init <name> [options]\n```\n\n**Arguments:**\n\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `name` | Yes | Schema name (kebab-case) |\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--description <text>` | Schema description |\n| `--artifacts <list>` | Comma-separated artifact IDs (default: `proposal,specs,design,tasks`) |\n| `--default` | Set as project default schema |\n| `--no-default` | Don't prompt to set as default |\n| `--force` | Overwrite existing schema |\n| `--json` | Output as JSON |\n\n**Examples:**\n\n```bash\n# Interactive schema creation\nopenspec schema init research-first\n\n# Non-interactive with specific artifacts\nopenspec schema init rapid \\\n  --description \"Rapid iteration workflow\" \\\n  --artifacts \"proposal,tasks\" \\\n  --default\n```\n\n**What it creates:**\n\n```\nopenspec/schemas/<name>/\n├── schema.yaml           # Schema definition\n└── templates/\n    ├── proposal.md       # Template for each artifact\n    ├── specs.md\n    ├── design.md\n    └── tasks.md\n```\n\n---\n\n### `openspec schema fork`\n\nCopy an existing schema to your project for customization.\n\n```\nopenspec schema fork <source> [name] [options]\n```\n\n**Arguments:**\n\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `source` | Yes | Schema to copy |\n| `name` | No | New schema name (default: `<source>-custom`) |\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--force` | Overwrite existing destination |\n| `--json` | Output as JSON |\n\n**Example:**\n\n```bash\n# Fork the built-in spec-driven schema\nopenspec schema fork spec-driven my-workflow\n```\n\n---\n\n### `openspec schema validate`\n\nValidate a schema's structure and templates.\n\n```\nopenspec schema validate [name] [options]\n```\n\n**Arguments:**\n\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `name` | No | Schema to validate (validates all if omitted) |\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--verbose` | Show detailed validation steps |\n| `--json` | Output as JSON |\n\n**Example:**\n\n```bash\n# Validate a specific schema\nopenspec schema validate my-workflow\n\n# Validate all schemas\nopenspec schema validate\n```\n\n---\n\n### `openspec schema which`\n\nShow where a schema resolves from (useful for debugging precedence).\n\n```\nopenspec schema which [name] [options]\n```\n\n**Arguments:**\n\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `name` | No | Schema name |\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--all` | List all schemas with their sources |\n| `--json` | Output as JSON |\n\n**Example:**\n\n```bash\n# Check where a schema comes from\nopenspec schema which spec-driven\n```\n\n**Output:**\n\n```\nspec-driven resolves from: package\n  Source: /usr/local/lib/node_modules/@fission-ai/openspec/schemas/spec-driven\n```\n\n**Schema precedence:**\n\n1. Project: `openspec/schemas/<name>/`\n2. User: `~/.local/share/openspec/schemas/<name>/`\n3. Package: Built-in schemas\n\n---\n\n## Configuration Commands\n\n### `openspec config`\n\nView and modify global OpenSpec configuration.\n\n```\nopenspec config <subcommand> [options]\n```\n\n**Subcommands:**\n\n| Subcommand | Description |\n|------------|-------------|\n| `path` | Show config file location |\n| `list` | Show all current settings |\n| `get <key>` | Get a specific value |\n| `set <key> <value>` | Set a value |\n| `unset <key>` | Remove a key |\n| `reset` | Reset to defaults |\n| `edit` | Open in `$EDITOR` |\n| `profile [preset]` | Configure workflow profile interactively or via preset |\n\n**Examples:**\n\n```bash\n# Show config file path\nopenspec config path\n\n# List all settings\nopenspec config list\n\n# Get a specific value\nopenspec config get telemetry.enabled\n\n# Set a value\nopenspec config set telemetry.enabled false\n\n# Set a string value explicitly\nopenspec config set user.name \"My Name\" --string\n\n# Remove a custom setting\nopenspec config unset user.name\n\n# Reset all configuration\nopenspec config reset --all --yes\n\n# Edit config in your editor\nopenspec config edit\n\n# Configure profile with action-based wizard\nopenspec config profile\n\n# Fast preset: switch workflows to core (keeps delivery mode)\nopenspec config profile core\n```\n\n`openspec config profile` starts with a current-state summary, then lets you choose:\n- Change delivery + workflows\n- Change delivery only\n- Change workflows only\n- Keep current settings (exit)\n\nIf you keep current settings, no changes are written and no update prompt is shown.\nIf 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`.\nPressing `Ctrl+C` also cancels the flow cleanly (no stack trace) and exits with code `130`.\nIn 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).\n\n**Interactive examples:**\n\n```bash\n# Delivery-only update\nopenspec config profile\n# choose: Change delivery only\n# choose delivery: Skills only\n\n# Workflows-only update\nopenspec config profile\n# choose: Change workflows only\n# toggle workflows in the checklist, then confirm\n```\n\n---\n\n## Utility Commands\n\n### `openspec feedback`\n\nSubmit feedback about OpenSpec. Creates a GitHub issue.\n\n```\nopenspec feedback <message> [options]\n```\n\n**Arguments:**\n\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `message` | Yes | Feedback message |\n\n**Options:**\n\n| Option | Description |\n|--------|-------------|\n| `--body <text>` | Detailed description |\n\n**Requirements:** GitHub CLI (`gh`) must be installed and authenticated.\n\n**Example:**\n\n```bash\nopenspec feedback \"Add support for custom artifact types\" \\\n  --body \"I'd like to define my own artifact types beyond the built-in ones.\"\n```\n\n---\n\n### `openspec completion`\n\nManage shell completions for the OpenSpec CLI.\n\n```\nopenspec completion <subcommand> [shell]\n```\n\n**Subcommands:**\n\n| Subcommand | Description |\n|------------|-------------|\n| `generate [shell]` | Output completion script to stdout |\n| `install [shell]` | Install completion for your shell |\n| `uninstall [shell]` | Remove installed completions |\n\n**Supported shells:** `bash`, `zsh`, `fish`, `powershell`\n\n**Examples:**\n\n```bash\n# Install completions (auto-detects shell)\nopenspec completion install\n\n# Install for specific shell\nopenspec completion install zsh\n\n# Generate script for manual installation\nopenspec completion generate bash > ~/.bash_completion.d/openspec\n\n# Uninstall\nopenspec completion uninstall\n```\n\n---\n\n## Exit Codes\n\n| Code | Meaning |\n|------|---------|\n| `0` | Success |\n| `1` | Error (validation failure, missing files, etc.) |\n\n---\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `OPENSPEC_CONCURRENCY` | Default concurrency for bulk validation (default: 6) |\n| `EDITOR` or `VISUAL` | Editor for `openspec config edit` |\n| `NO_COLOR` | Disable color output when set |\n\n---\n\n## Related Documentation\n\n- [Commands](commands.md) - AI slash commands (`/opsx:propose`, `/opsx:apply`, etc.)\n- [Workflows](workflows.md) - Common patterns and when to use each command\n- [Customization](customization.md) - Create custom schemas and templates\n- [Getting Started](getting-started.md) - First-time setup guide\n"
  },
  {
    "path": "docs/commands.md",
    "content": "# Commands\n\nThis 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).\n\nFor workflow patterns and when to use each command, see [Workflows](workflows.md). For CLI commands, see [CLI](cli.md).\n\n## Quick Reference\n\n### Default Quick Path (`core` profile)\n\n| Command | Purpose |\n|---------|---------|\n| `/opsx:propose` | Create a change and generate planning artifacts in one step |\n| `/opsx:explore` | Think through ideas before committing to a change |\n| `/opsx:apply` | Implement tasks from the change |\n| `/opsx:archive` | Archive a completed change |\n\n### Expanded Workflow Commands (custom workflow selection)\n\n| Command | Purpose |\n|---------|---------|\n| `/opsx:new` | Start a new change scaffold |\n| `/opsx:continue` | Create the next artifact based on dependencies |\n| `/opsx:ff` | Fast-forward: create all planning artifacts at once |\n| `/opsx:verify` | Validate implementation matches artifacts |\n| `/opsx:sync` | Merge delta specs into main specs |\n| `/opsx:bulk-archive` | Archive multiple changes at once |\n| `/opsx:onboard` | Guided tutorial through the complete workflow |\n\nThe default global profile is `core`. To enable expanded workflow commands, run `openspec config profile`, select workflows, then run `openspec update` in your project.\n\n---\n\n## Command Reference\n\n### `/opsx:propose`\n\nCreate a new change and generate planning artifacts in one step. This is the default start command in the `core` profile.\n\n**Syntax:**\n```text\n/opsx:propose [change-name-or-description]\n```\n\n**Arguments:**\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `change-name-or-description` | No | Kebab-case name or plain-language change description |\n\n**What it does:**\n- Creates `openspec/changes/<change-name>/`\n- Generates artifacts needed before implementation (for `spec-driven`: proposal, specs, design, tasks)\n- Stops when the change is ready for `/opsx:apply`\n\n**Example:**\n```text\nYou: /opsx:propose add-dark-mode\n\nAI:  Created openspec/changes/add-dark-mode/\n     ✓ proposal.md\n     ✓ specs/ui/spec.md\n     ✓ design.md\n     ✓ tasks.md\n     Ready for implementation. Run /opsx:apply.\n```\n\n**Tips:**\n- Use this for the fastest end-to-end path\n- If you want step-by-step artifact control, enable expanded workflows and use `/opsx:new` + `/opsx:continue`\n\n---\n\n### `/opsx:explore`\n\nThink through ideas, investigate problems, and clarify requirements before committing to a change.\n\n**Syntax:**\n```\n/opsx:explore [topic]\n```\n\n**Arguments:**\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `topic` | No | What you want to explore or investigate |\n\n**What it does:**\n- Opens an exploratory conversation with no structure required\n- Investigates the codebase to answer questions\n- Compares options and approaches\n- Creates visual diagrams to clarify thinking\n- Can transition to `/opsx:propose` (default) or `/opsx:new` (expanded workflow) when insights crystallize\n\n**Example:**\n```text\nYou: /opsx:explore\n\nAI:  What would you like to explore?\n\nYou: How should we handle authentication for the mobile app?\n\nAI:  Let me investigate your current auth setup...\n     [Analyzes codebase]\n\n     I see you're using session-based auth for the web app.\n     For mobile, we have three main options:\n\n     1. JWT tokens (stateless, works offline)\n     2. OAuth2 (if you need third-party login)\n     3. Extend current sessions (simpler, but requires connectivity)\n\n     Your API already has CORS configured. Which direction interests you?\n\nYou: Let's go with JWT. Can we start a change for that?\n\nAI:  Ready when you are. Run /opsx:propose add-jwt-auth to begin.\n```\n\n**Tips:**\n- Use when requirements are unclear or you need to investigate\n- No artifacts are created during exploration\n- Good for comparing multiple approaches before deciding\n- Can read files and search the codebase\n\n---\n\n### `/opsx:new`\n\nStart a new change scaffold. Creates the change folder and waits for you to generate artifacts with `/opsx:continue` or `/opsx:ff`.\n\nThis command is part of the expanded workflow set (not included in the default `core` profile).\n\n**Syntax:**\n```\n/opsx:new [change-name] [--schema <schema-name>]\n```\n\n**Arguments:**\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `change-name` | No | Name for the change folder (prompted if not provided) |\n| `--schema` | No | Workflow schema to use (default: from config or `spec-driven`) |\n\n**What it does:**\n- Creates `openspec/changes/<change-name>/` directory\n- Creates `.openspec.yaml` metadata file in the change folder\n- Shows the first artifact template ready for creation\n- Prompts for change name and schema if not provided\n\n**What it creates:**\n```\nopenspec/changes/<change-name>/\n└── .openspec.yaml    # Change metadata (schema, created date)\n```\n\n**Example:**\n```\nYou: /opsx:new add-dark-mode\n\nAI:  Created openspec/changes/add-dark-mode/\n     Schema: spec-driven\n\n     Ready to create: proposal\n     Use /opsx:continue to create it, or /opsx:ff to create all artifacts.\n```\n\n**Tips:**\n- Use descriptive names: `add-feature`, `fix-bug`, `refactor-module`\n- Avoid generic names like `update`, `changes`, `wip`\n- Schema can also be set in project config (`openspec/config.yaml`)\n\n---\n\n### `/opsx:continue`\n\nCreate the next artifact in the dependency chain. Creates one artifact at a time for incremental progress.\n\n**Syntax:**\n```\n/opsx:continue [change-name]\n```\n\n**Arguments:**\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `change-name` | No | Which change to continue (inferred from context if not provided) |\n\n**What it does:**\n- Queries the artifact dependency graph\n- Shows which artifacts are ready vs blocked\n- Creates the first ready artifact\n- Reads dependency files for context\n- Shows what becomes available after creation\n\n**Example:**\n```\nYou: /opsx:continue\n\nAI:  Change: add-dark-mode\n\n     Artifact status:\n     ✓ proposal    (done)\n     ◆ specs       (ready)\n     ◆ design      (ready)\n     ○ tasks       (blocked - needs: specs)\n\n     Creating specs...\n\n     [Reads proposal.md, creates specs based on requirements]\n\n     ✓ Created openspec/changes/add-dark-mode/specs/ui/spec.md\n\n     Now available: tasks\n     Run /opsx:continue to create the next artifact.\n```\n\n**Tips:**\n- Use when you want to review each artifact before proceeding\n- Good for complex changes where you want control\n- Multiple artifacts may become ready simultaneously\n- You can edit created artifacts before continuing\n\n---\n\n### `/opsx:ff`\n\nFast-forward through artifact creation. Creates all planning artifacts at once.\n\n**Syntax:**\n```\n/opsx:ff [change-name]\n```\n\n**Arguments:**\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `change-name` | No | Which change to fast-forward (inferred from context if not provided) |\n\n**What it does:**\n- Creates all artifacts in dependency order\n- Tracks progress via todo list\n- Stops when all `apply-required` artifacts are complete\n- Reads each dependency before creating the next artifact\n\n**Example:**\n```\nYou: /opsx:ff add-dark-mode\n\nAI:  Fast-forwarding add-dark-mode...\n\n     ✓ Creating proposal.md\n     ✓ Creating specs/ui/spec.md\n     ✓ Creating design.md\n     ✓ Creating tasks.md\n\n     All planning artifacts complete!\n     Ready for implementation. Run /opsx:apply to begin.\n```\n\n**Tips:**\n- Use when you have a clear picture of what you're building\n- Faster than `/opsx:continue` for straightforward changes\n- You can still edit artifacts afterward\n- Good for small to medium features\n\n---\n\n### `/opsx:apply`\n\nImplement tasks from the change. Works through the task list, writing code and checking off items.\n\n**Syntax:**\n```\n/opsx:apply [change-name]\n```\n\n**Arguments:**\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `change-name` | No | Which change to implement (inferred from context if not provided) |\n\n**What it does:**\n- Reads `tasks.md` and identifies incomplete tasks\n- Works through tasks one by one\n- Writes code, creates files, runs tests as needed\n- Marks tasks complete with checkboxes `[x]`\n\n**Example:**\n```\nYou: /opsx:apply\n\nAI:  Implementing add-dark-mode...\n\n     Reading tasks.md:\n     - [ ] 1.1 Create ThemeContext\n     - [ ] 1.2 Add CSS custom properties\n     - [ ] 1.3 Implement localStorage persistence\n\n     Working on 1.1: Create ThemeContext...\n     [Creates src/context/ThemeContext.tsx]\n\n     ✓ 1.1 Complete\n\n     Working on 1.2: Add CSS custom properties...\n     [Updates src/styles/globals.css]\n\n     ✓ 1.2 Complete\n     ...\n```\n\n**Tips:**\n- Can resume where you left off if interrupted\n- Use for parallel changes by specifying the change name\n- Completion state is tracked in `tasks.md` checkboxes\n\n---\n\n### `/opsx:verify`\n\nValidate that implementation matches your change artifacts. Checks completeness, correctness, and coherence.\n\n**Syntax:**\n```\n/opsx:verify [change-name]\n```\n\n**Arguments:**\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `change-name` | No | Which change to verify (inferred from context if not provided) |\n\n**What it does:**\n- Checks three dimensions of implementation quality\n- Searches codebase for implementation evidence\n- Reports issues categorized as CRITICAL, WARNING, or SUGGESTION\n- Does not block archive, but surfaces issues\n\n**Verification dimensions:**\n\n| Dimension | What it validates |\n|-----------|-------------------|\n| **Completeness** | All tasks done, all requirements implemented, scenarios covered |\n| **Correctness** | Implementation matches spec intent, edge cases handled |\n| **Coherence** | Design decisions reflected in code, patterns consistent |\n\n**Example:**\n```\nYou: /opsx:verify\n\nAI:  Verifying add-dark-mode...\n\n     COMPLETENESS\n     ✓ All 8 tasks in tasks.md are checked\n     ✓ All requirements in specs have corresponding code\n     ⚠ Scenario \"System preference detection\" has no test coverage\n\n     CORRECTNESS\n     ✓ Implementation matches spec intent\n     ✓ Edge cases from scenarios are handled\n     ✓ Error states match spec definitions\n\n     COHERENCE\n     ✓ Design decisions reflected in code structure\n     ✓ Naming conventions consistent with design.md\n     ⚠ Design mentions \"CSS variables\" but implementation uses Tailwind classes\n\n     SUMMARY\n     ─────────────────────────────\n     Critical issues: 0\n     Warnings: 2\n     Ready to archive: Yes (with warnings)\n\n     Recommendations:\n     1. Add test for system preference detection\n     2. Update design.md to reflect Tailwind usage, or refactor to use CSS variables\n```\n\n**Tips:**\n- Run before archiving to catch mismatches early\n- Warnings don't block archive but indicate potential issues\n- Good for reviewing AI's work before committing\n- Can reveal drift between artifacts and implementation\n\n---\n\n### `/opsx:sync`\n\n**Optional command.** Merge delta specs from a change into main specs. Archive will prompt to sync if needed, so you typically don't need to run this manually.\n\n**Syntax:**\n```\n/opsx:sync [change-name]\n```\n\n**Arguments:**\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `change-name` | No | Which change to sync (inferred from context if not provided) |\n\n**What it does:**\n- Reads delta specs from change folder\n- Parses ADDED/MODIFIED/REMOVED/RENAMED sections\n- Merges changes into main `openspec/specs/` directory\n- Preserves existing content not mentioned in delta\n- Does not archive the change (remains active)\n\n**Example:**\n```text\nYou: /opsx:sync\n\nAI:  Syncing add-dark-mode delta specs...\n\n     Reading: openspec/changes/add-dark-mode/specs/ui/spec.md\n     Target:  openspec/specs/ui/spec.md\n\n     Changes to apply:\n     ✓ ADDED: Theme Selection requirement (2 scenarios)\n     ✓ ADDED: Accessibility requirement (1 scenario)\n\n     Merging...\n     ✓ openspec/specs/ui/spec.md updated\n\n     Change remains active. Run /opsx:archive when ready to complete.\n```\n\n**When to use manually:**\n\n| Scenario | Use sync? |\n|----------|-----------|\n| Long-running change, want specs in main before archiving | Yes |\n| Multiple parallel changes need the updated base specs | Yes |\n| Want to preview/review the merge separately | Yes |\n| Quick change, going straight to archive | No (archive handles it) |\n\n**Tips:**\n- Sync is intelligent, not copy-paste\n- Can add scenarios to existing requirements without duplicating\n- Change stays active after sync (not archived)\n- Most users will never need to call this directly—archive prompts if needed\n\n---\n\n### `/opsx:archive`\n\nArchive a completed change. Finalizes the change and moves it to the archive folder.\n\n**Syntax:**\n```\n/opsx:archive [change-name]\n```\n\n**Arguments:**\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `change-name` | No | Which change to archive (inferred from context if not provided) |\n\n**What it does:**\n- Checks artifact completion status\n- Checks task completion (warns if incomplete)\n- Offers to sync delta specs if not already synced\n- Moves change folder to `openspec/changes/archive/YYYY-MM-DD-<name>/`\n- Preserves all artifacts for audit trail\n\n**Example:**\n```text\nYou: /opsx:archive\n\nAI:  Archiving add-dark-mode...\n\n     Artifact status:\n     ✓ proposal.md exists\n     ✓ specs/ exists\n     ✓ design.md exists\n     ✓ tasks.md exists (8/8 tasks complete)\n\n     Delta specs: Not yet synced\n     → Sync now? (recommended)\n\nYou: Yes\n\nAI:  ✓ Synced specs to openspec/specs/ui/spec.md\n     ✓ Moved to openspec/changes/archive/2025-01-24-add-dark-mode/\n\n     Change archived successfully.\n```\n\n**Tips:**\n- Archive won't block on incomplete tasks, but will warn\n- Delta specs can be synced during archive or beforehand\n- Archived changes are preserved for history\n- Use `/opsx:verify` first to catch issues\n\n---\n\n### `/opsx:bulk-archive`\n\nArchive multiple completed changes at once. Handles spec conflicts between changes.\n\n**Syntax:**\n```\n/opsx:bulk-archive [change-names...]\n```\n\n**Arguments:**\n| Argument | Required | Description |\n|----------|----------|-------------|\n| `change-names` | No | Specific changes to archive (prompts to select if not provided) |\n\n**What it does:**\n- Lists all completed changes\n- Validates each change before archiving\n- Detects spec conflicts across changes\n- Resolves conflicts by checking what's actually implemented\n- Archives in chronological order\n\n**Example:**\n```\nYou: /opsx:bulk-archive\n\nAI:  Found 3 completed changes:\n     - add-dark-mode (8/8 tasks complete)\n     - fix-login-bug (3/3 tasks complete)\n     - update-footer (2/2 tasks complete)\n\n     Checking for spec conflicts...\n     ⚠ add-dark-mode and update-footer both touch specs/ui/\n\n     Inspecting codebase to resolve...\n     Both changes are implemented. Will merge in chronological order:\n     1. add-dark-mode (created Jan 20)\n     2. update-footer (created Jan 22)\n\n     Archive all 3 changes?\n\nYou: Yes\n\nAI:  ✓ Archived add-dark-mode\n     ✓ Archived fix-login-bug\n     ✓ Archived update-footer\n     Specs merged: add-dark-mode → update-footer\n```\n\n**Tips:**\n- Good for parallel work streams\n- Conflict resolution is agentic (checks codebase)\n- Changes are archived in order of creation\n- Prompts before overwriting spec content\n\n---\n\n### `/opsx:onboard`\n\nGuided onboarding through the complete OpenSpec workflow. An interactive tutorial using your actual codebase.\n\n**Syntax:**\n```\n/opsx:onboard\n```\n\n**What it does:**\n- Walks through a complete workflow cycle with narration\n- Scans your codebase for real improvement opportunities\n- Creates an actual change with real artifacts\n- Implements actual work (small, safe changes)\n- Archives the completed change\n- Explains each step as it happens\n\n**Phases:**\n1. Welcome and codebase analysis\n2. Finding an improvement opportunity\n3. Creating a change (`/opsx:new`)\n4. Writing the proposal\n5. Creating specs\n6. Writing the design\n7. Creating tasks\n8. Implementing tasks (`/opsx:apply`)\n9. Verifying implementation\n10. Archiving the change\n11. Summary and next steps\n\n**Example:**\n```\nYou: /opsx:onboard\n\nAI:  Welcome to OpenSpec!\n\n     I'll walk you through the complete workflow using your actual codebase.\n     We'll find something small to improve, create a proper change for it,\n     implement it, and archive it.\n\n     Let me scan your codebase for opportunities...\n\n     [Analyzes codebase]\n\n     I found a few things we could work on:\n     1. Add input validation to the contact form\n     2. Improve error messages in the auth flow\n     3. Add loading states to async buttons\n\n     Which interests you? (or suggest something else)\n```\n\n**Tips:**\n- Best for new users learning the workflow\n- Uses real code, not toy examples\n- Creates a real change you can keep or discard\n- Takes 15-30 minutes to complete\n\n---\n\n## Command Syntax by AI Tool\n\nDifferent AI tools use slightly different command syntax. Use the format that matches your tool:\n\n| Tool | Syntax Example |\n|------|----------------|\n| Claude Code | `/opsx:propose`, `/opsx:apply` |\n| Cursor | `/opsx-propose`, `/opsx-apply` |\n| Windsurf | `/opsx-propose`, `/opsx-apply` |\n| Copilot (IDE) | `/opsx-propose`, `/opsx-apply` |\n| Trae | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) |\n\nThe intent is the same across tools, but how commands are surfaced can differ by integration.\n\n> **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.\n\n---\n\n## Legacy Commands\n\nThese commands use the older \"all-at-once\" workflow. They still work but OPSX commands are recommended.\n\n| Command | What it does |\n|---------|--------------|\n| `/openspec:proposal` | Create all artifacts at once (proposal, specs, design, tasks) |\n| `/openspec:apply` | Implement the change |\n| `/openspec:archive` | Archive the change |\n\n**When to use legacy commands:**\n- Existing projects using the old workflow\n- Simple changes where you don't need incremental artifact creation\n- Preference for the all-or-nothing approach\n\n**Migrating to OPSX:**\nLegacy changes can be continued with OPSX commands. The artifact structure is compatible.\n\n---\n\n## Troubleshooting\n\n### \"Change not found\"\n\nThe command couldn't identify which change to work on.\n\n**Solutions:**\n- Specify the change name explicitly: `/opsx:apply add-dark-mode`\n- Check that the change folder exists: `openspec list`\n- Verify you're in the right project directory\n\n### \"No artifacts ready\"\n\nAll artifacts are either complete or blocked by missing dependencies.\n\n**Solutions:**\n- Run `openspec status --change <name>` to see what's blocking\n- Check if required artifacts exist\n- Create missing dependency artifacts first\n\n### \"Schema not found\"\n\nThe specified schema doesn't exist.\n\n**Solutions:**\n- List available schemas: `openspec schemas`\n- Check spelling of schema name\n- Create the schema if it's custom: `openspec schema init <name>`\n\n### Commands not recognized\n\nThe AI tool doesn't recognize OpenSpec commands.\n\n**Solutions:**\n- Ensure OpenSpec is initialized: `openspec init`\n- Regenerate skills: `openspec update`\n- Check that `.claude/skills/` directory exists (for Claude Code)\n- Restart your AI tool to pick up new skills\n\n### Artifacts not generating properly\n\nThe AI creates incomplete or incorrect artifacts.\n\n**Solutions:**\n- Add project context in `openspec/config.yaml`\n- Add per-artifact rules for specific guidance\n- Provide more detail in your change description\n- Use `/opsx:continue` instead of `/opsx:ff` for more control\n\n---\n\n## Next Steps\n\n- [Workflows](workflows.md) - Common patterns and when to use each command\n- [CLI](cli.md) - Terminal commands for management and validation\n- [Customization](customization.md) - Create custom schemas and workflows\n"
  },
  {
    "path": "docs/concepts.md",
    "content": "# Concepts\n\nThis guide explains the core ideas behind OpenSpec and how they fit together. For practical usage, see [Getting Started](getting-started.md) and [Workflows](workflows.md).\n\n## Philosophy\n\nOpenSpec is built around four principles:\n\n```\nfluid not rigid       — no phase gates, work on what makes sense\niterative not waterfall — learn as you build, refine as you go\neasy not complex      — lightweight setup, minimal ceremony\nbrownfield-first      — works with existing codebases, not just greenfield\n```\n\n### Why These Principles Matter\n\n**Fluid not rigid.** Traditional spec systems lock you into phases: first you plan, then you implement, then you're done. OpenSpec is more flexible — you can create artifacts in any order that makes sense for your work.\n\n**Iterative not waterfall.** Requirements change. Understanding deepens. What seemed like a good approach at the start might not hold up after you see the codebase. OpenSpec embraces this reality.\n\n**Easy not complex.** Some spec frameworks require extensive setup, rigid formats, or heavyweight processes. OpenSpec stays out of your way. Initialize in seconds, start working immediately, customize only if you need to.\n\n**Brownfield-first.** Most software work isn't building from scratch — it's modifying existing systems. OpenSpec's delta-based approach makes it easy to specify changes to existing behavior, not just describe new systems.\n\n## The Big Picture\n\nOpenSpec organizes your work into two main areas:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                        openspec/                                 │\n│                                                                  │\n│   ┌─────────────────────┐      ┌──────────────────────────────┐ │\n│   │       specs/        │      │         changes/              │ │\n│   │                     │      │                               │ │\n│   │  Source of truth    │◄─────│  Proposed modifications       │ │\n│   │  How your system    │ merge│  Each change = one folder     │ │\n│   │  currently works    │      │  Contains artifacts + deltas  │ │\n│   │                     │      │                               │ │\n│   └─────────────────────┘      └──────────────────────────────┘ │\n│                                                                  │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n**Specs** are the source of truth — they describe how your system currently behaves.\n\n**Changes** are proposed modifications — they live in separate folders until you're ready to merge them.\n\nThis separation is key. You can work on multiple changes in parallel without conflicts. You can review a change before it affects the main specs. And when you archive a change, its deltas merge cleanly into the source of truth.\n\n## Specs\n\nSpecs describe your system's behavior using structured requirements and scenarios.\n\n### Structure\n\n```\nopenspec/specs/\n├── auth/\n│   └── spec.md           # Authentication behavior\n├── payments/\n│   └── spec.md           # Payment processing\n├── notifications/\n│   └── spec.md           # Notification system\n└── ui/\n    └── spec.md           # UI behavior and themes\n```\n\nOrganize specs by domain — logical groupings that make sense for your system. Common patterns:\n\n- **By feature area**: `auth/`, `payments/`, `search/`\n- **By component**: `api/`, `frontend/`, `workers/`\n- **By bounded context**: `ordering/`, `fulfillment/`, `inventory/`\n\n### Spec Format\n\nA spec contains requirements, and each requirement has scenarios:\n\n```markdown\n# Auth Specification\n\n## Purpose\nAuthentication and session management for the application.\n\n## Requirements\n\n### Requirement: User Authentication\nThe system SHALL issue a JWT token upon successful login.\n\n#### Scenario: Valid credentials\n- GIVEN a user with valid credentials\n- WHEN the user submits login form\n- THEN a JWT token is returned\n- AND the user is redirected to dashboard\n\n#### Scenario: Invalid credentials\n- GIVEN invalid credentials\n- WHEN the user submits login form\n- THEN an error message is displayed\n- AND no token is issued\n\n### Requirement: Session Expiration\nThe system MUST expire sessions after 30 minutes of inactivity.\n\n#### Scenario: Idle timeout\n- GIVEN an authenticated session\n- WHEN 30 minutes pass without activity\n- THEN the session is invalidated\n- AND the user must re-authenticate\n```\n\n**Key elements:**\n\n| Element | Purpose |\n|---------|---------|\n| `## Purpose` | High-level description of this spec's domain |\n| `### Requirement:` | A specific behavior the system must have |\n| `#### Scenario:` | A concrete example of the requirement in action |\n| SHALL/MUST/SHOULD | RFC 2119 keywords indicating requirement strength |\n\n### Why Structure Specs This Way\n\n**Requirements are the \"what\"** — they state what the system should do without specifying implementation.\n\n**Scenarios are the \"when\"** — they provide concrete examples that can be verified. Good scenarios:\n- Are testable (you could write an automated test for them)\n- Cover both happy path and edge cases\n- Use Given/When/Then or similar structured format\n\n**RFC 2119 keywords** (SHALL, MUST, SHOULD, MAY) communicate intent:\n- **MUST/SHALL** — absolute requirement\n- **SHOULD** — recommended, but exceptions exist\n- **MAY** — optional\n\n### What a Spec Is (and Is Not)\n\nA spec is a **behavior contract**, not an implementation plan.\n\nGood spec content:\n- Observable behavior users or downstream systems rely on\n- Inputs, outputs, and error conditions\n- External constraints (security, privacy, reliability, compatibility)\n- Scenarios that can be tested or explicitly validated\n\nAvoid in specs:\n- Internal class/function names\n- Library or framework choices\n- Step-by-step implementation details\n- Detailed execution plans (those belong in `design.md` or `tasks.md`)\n\nQuick test:\n- If implementation can change without changing externally visible behavior, it likely does not belong in the spec.\n\n### Keep It Lightweight: Progressive Rigor\n\nOpenSpec aims to avoid bureaucracy. Use the lightest level that still makes the change verifiable.\n\n**Lite spec (default):**\n- Short behavior-first requirements\n- Clear scope and non-goals\n- A few concrete acceptance checks\n\n**Full spec (for higher risk):**\n- Cross-team or cross-repo changes\n- API/contract changes, migrations, security/privacy concerns\n- Changes where ambiguity is likely to cause expensive rework\n\nMost changes should stay in Lite mode.\n\n### Human + Agent Collaboration\n\nIn many teams, humans explore and agents draft artifacts. The intended loop is:\n\n1. Human provides intent, context, and constraints.\n2. Agent converts this into behavior-first requirements and scenarios.\n3. Agent keeps implementation detail in `design.md` and `tasks.md`, not `spec.md`.\n4. Validation confirms structure and clarity before implementation.\n\nThis keeps specs readable for humans and consistent for agents.\n\n## Changes\n\nA change is a proposed modification to your system, packaged as a folder with everything needed to understand and implement it.\n\n### Change Structure\n\n```\nopenspec/changes/add-dark-mode/\n├── proposal.md           # Why and what\n├── design.md             # How (technical approach)\n├── tasks.md              # Implementation checklist\n├── .openspec.yaml        # Change metadata (optional)\n└── specs/                # Delta specs\n    └── ui/\n        └── spec.md       # What's changing in ui/spec.md\n```\n\nEach change is self-contained. It has:\n- **Artifacts** — documents that capture intent, design, and tasks\n- **Delta specs** — specifications for what's being added, modified, or removed\n- **Metadata** — optional configuration for this specific change\n\n### Why Changes Are Folders\n\nPackaging a change as a folder has several benefits:\n\n1. **Everything together.** Proposal, design, tasks, and specs live in one place. No hunting through different locations.\n\n2. **Parallel work.** Multiple changes can exist simultaneously without conflicting. Work on `add-dark-mode` while `fix-auth-bug` is also in progress.\n\n3. **Clean history.** When archived, changes move to `changes/archive/` with their full context preserved. You can look back and understand not just what changed, but why.\n\n4. **Review-friendly.** A change folder is easy to review — open it, read the proposal, check the design, see the spec deltas.\n\n## Artifacts\n\nArtifacts are the documents within a change that guide the work.\n\n### The Artifact Flow\n\n```\nproposal ──────► specs ──────► design ──────► tasks ──────► implement\n    │               │             │              │\n   why            what           how          steps\n + scope        changes       approach      to take\n```\n\nArtifacts build on each other. Each artifact provides context for the next.\n\n### Artifact Types\n\n#### Proposal (`proposal.md`)\n\nThe proposal captures **intent**, **scope**, and **approach** at a high level.\n\n```markdown\n# Proposal: Add Dark Mode\n\n## Intent\nUsers have requested a dark mode option to reduce eye strain\nduring nighttime usage and match system preferences.\n\n## Scope\nIn scope:\n- Theme toggle in settings\n- System preference detection\n- Persist preference in localStorage\n\nOut of scope:\n- Custom color themes (future work)\n- Per-page theme overrides\n\n## Approach\nUse CSS custom properties for theming with a React context\nfor state management. Detect system preference on first load,\nallow manual override.\n```\n\n**When to update the proposal:**\n- Scope changes (narrowing or expanding)\n- Intent clarifies (better understanding of the problem)\n- Approach fundamentally shifts\n\n#### Specs (delta specs in `specs/`)\n\nDelta specs describe **what's changing** relative to the current specs. See [Delta Specs](#delta-specs) below.\n\n#### Design (`design.md`)\n\nThe design captures **technical approach** and **architecture decisions**.\n\n````markdown\n# Design: Add Dark Mode\n\n## Technical Approach\nTheme state managed via React Context to avoid prop drilling.\nCSS custom properties enable runtime switching without class toggling.\n\n## Architecture Decisions\n\n### Decision: Context over Redux\nUsing React Context for theme state because:\n- Simple binary state (light/dark)\n- No complex state transitions\n- Avoids adding Redux dependency\n\n### Decision: CSS Custom Properties\nUsing CSS variables instead of CSS-in-JS because:\n- Works with existing stylesheet\n- No runtime overhead\n- Browser-native solution\n\n## Data Flow\n```\nThemeProvider (context)\n       │\n       ▼\nThemeToggle ◄──► localStorage\n       │\n       ▼\nCSS Variables (applied to :root)\n```\n\n## File Changes\n- `src/contexts/ThemeContext.tsx` (new)\n- `src/components/ThemeToggle.tsx` (new)\n- `src/styles/globals.css` (modified)\n````\n\n**When to update the design:**\n- Implementation reveals the approach won't work\n- Better solution discovered\n- Dependencies or constraints change\n\n#### Tasks (`tasks.md`)\n\nTasks are the **implementation checklist** — concrete steps with checkboxes.\n\n```markdown\n# Tasks\n\n## 1. Theme Infrastructure\n- [ ] 1.1 Create ThemeContext with light/dark state\n- [ ] 1.2 Add CSS custom properties for colors\n- [ ] 1.3 Implement localStorage persistence\n- [ ] 1.4 Add system preference detection\n\n## 2. UI Components\n- [ ] 2.1 Create ThemeToggle component\n- [ ] 2.2 Add toggle to settings page\n- [ ] 2.3 Update Header to include quick toggle\n\n## 3. Styling\n- [ ] 3.1 Define dark theme color palette\n- [ ] 3.2 Update components to use CSS variables\n- [ ] 3.3 Test contrast ratios for accessibility\n```\n\n**Task best practices:**\n- Group related tasks under headings\n- Use hierarchical numbering (1.1, 1.2, etc.)\n- Keep tasks small enough to complete in one session\n- Check tasks off as you complete them\n\n## Delta Specs\n\nDelta specs are the key concept that makes OpenSpec work for brownfield development. They describe **what's changing** rather than restating the entire spec.\n\n### The Format\n\n```markdown\n# Delta for Auth\n\n## ADDED Requirements\n\n### Requirement: Two-Factor Authentication\nThe system MUST support TOTP-based two-factor authentication.\n\n#### Scenario: 2FA enrollment\n- GIVEN a user without 2FA enabled\n- WHEN the user enables 2FA in settings\n- THEN a QR code is displayed for authenticator app setup\n- AND the user must verify with a code before activation\n\n#### Scenario: 2FA login\n- GIVEN a user with 2FA enabled\n- WHEN the user submits valid credentials\n- THEN an OTP challenge is presented\n- AND login completes only after valid OTP\n\n## MODIFIED Requirements\n\n### Requirement: Session Expiration\nThe system MUST expire sessions after 15 minutes of inactivity.\n(Previously: 30 minutes)\n\n#### Scenario: Idle timeout\n- GIVEN an authenticated session\n- WHEN 15 minutes pass without activity\n- THEN the session is invalidated\n\n## REMOVED Requirements\n\n### Requirement: Remember Me\n(Deprecated in favor of 2FA. Users should re-authenticate each session.)\n```\n\n### Delta Sections\n\n| Section | Meaning | What Happens on Archive |\n|---------|---------|------------------------|\n| `## ADDED Requirements` | New behavior | Appended to main spec |\n| `## MODIFIED Requirements` | Changed behavior | Replaces existing requirement |\n| `## REMOVED Requirements` | Deprecated behavior | Deleted from main spec |\n\n### Why Deltas Instead of Full Specs\n\n**Clarity.** A delta shows exactly what's changing. Reading a full spec, you'd have to diff it mentally against the current version.\n\n**Conflict avoidance.** Two changes can touch the same spec file without conflicting, as long as they modify different requirements.\n\n**Review efficiency.** Reviewers see the change, not the unchanged context. Focus on what matters.\n\n**Brownfield fit.** Most work modifies existing behavior. Deltas make modifications first-class, not an afterthought.\n\n## Schemas\n\nSchemas define the artifact types and their dependencies for a workflow.\n\n### How Schemas Work\n\n```yaml\n# openspec/schemas/spec-driven/schema.yaml\nname: spec-driven\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    requires: []              # No dependencies, can create first\n\n  - id: specs\n    generates: specs/**/*.md\n    requires: [proposal]      # Needs proposal before creating\n\n  - id: design\n    generates: design.md\n    requires: [proposal]      # Can create in parallel with specs\n\n  - id: tasks\n    generates: tasks.md\n    requires: [specs, design] # Needs both specs and design first\n```\n\n**Artifacts form a dependency graph:**\n\n```\n                    proposal\n                   (root node)\n                       │\n         ┌─────────────┴─────────────┐\n         │                           │\n         ▼                           ▼\n      specs                       design\n   (requires:                  (requires:\n    proposal)                   proposal)\n         │                           │\n         └─────────────┬─────────────┘\n                       │\n                       ▼\n                    tasks\n                (requires:\n                specs, design)\n```\n\n**Dependencies are enablers, not gates.** They show what's possible to create, not what you must create next. You can skip design if you don't need it. You can create specs before or after design — both depend only on proposal.\n\n### Built-in Schemas\n\n**spec-driven** (default)\n\nThe standard workflow for spec-driven development:\n\n```\nproposal → specs → design → tasks → implement\n```\n\nBest for: Most feature work where you want to agree on specs before implementation.\n\n### Custom Schemas\n\nCreate custom schemas for your team's workflow:\n\n```bash\n# Create from scratch\nopenspec schema init research-first\n\n# Or fork an existing one\nopenspec schema fork spec-driven research-first\n```\n\n**Example custom schema:**\n\n```yaml\n# openspec/schemas/research-first/schema.yaml\nname: research-first\nartifacts:\n  - id: research\n    generates: research.md\n    requires: []           # Do research first\n\n  - id: proposal\n    generates: proposal.md\n    requires: [research]   # Proposal informed by research\n\n  - id: tasks\n    generates: tasks.md\n    requires: [proposal]   # Skip specs/design, go straight to tasks\n```\n\nSee [Customization](customization.md) for full details on creating and using custom schemas.\n\n## Archive\n\nArchiving completes a change by merging its delta specs into the main specs and preserving the change for history.\n\n### What Happens When You Archive\n\n```\nBefore archive:\n\nopenspec/\n├── specs/\n│   └── auth/\n│       └── spec.md ◄────────────────┐\n└── changes/                         │\n    └── add-2fa/                     │\n        ├── proposal.md              │\n        ├── design.md                │ merge\n        ├── tasks.md                 │\n        └── specs/                   │\n            └── auth/                │\n                └── spec.md ─────────┘\n\n\nAfter archive:\n\nopenspec/\n├── specs/\n│   └── auth/\n│       └── spec.md        # Now includes 2FA requirements\n└── changes/\n    └── archive/\n        └── 2025-01-24-add-2fa/    # Preserved for history\n            ├── proposal.md\n            ├── design.md\n            ├── tasks.md\n            └── specs/\n                └── auth/\n                    └── spec.md\n```\n\n### The Archive Process\n\n1. **Merge deltas.** Each delta spec section (ADDED/MODIFIED/REMOVED) is applied to the corresponding main spec.\n\n2. **Move to archive.** The change folder moves to `changes/archive/` with a date prefix for chronological ordering.\n\n3. **Preserve context.** All artifacts remain intact in the archive. You can always look back to understand why a change was made.\n\n### Why Archive Matters\n\n**Clean state.** Active changes (`changes/`) shows only work in progress. Completed work moves out of the way.\n\n**Audit trail.** The archive preserves the full context of every change — not just what changed, but the proposal explaining why, the design explaining how, and the tasks showing the work done.\n\n**Spec evolution.** Specs grow organically as changes are archived. Each archive merges its deltas, building up a comprehensive specification over time.\n\n## How It All Fits Together\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                              OPENSPEC FLOW                                   │\n│                                                                              │\n│   ┌────────────────┐                                                         │\n│   │  1. START      │  /opsx:propose (core) or /opsx:new (expanded)          │\n│   │     CHANGE     │                                                         │\n│   └───────┬────────┘                                                         │\n│           │                                                                  │\n│           ▼                                                                  │\n│   ┌────────────────┐                                                         │\n│   │  2. CREATE     │  /opsx:ff or /opsx:continue (expanded workflow)         │\n│   │     ARTIFACTS  │  Creates proposal → specs → design → tasks              │\n│   │                │  (based on schema dependencies)                         │\n│   └───────┬────────┘                                                         │\n│           │                                                                  │\n│           ▼                                                                  │\n│   ┌────────────────┐                                                         │\n│   │  3. IMPLEMENT  │  /opsx:apply                                            │\n│   │     TASKS      │  Work through tasks, checking them off                  │\n│   │                │◄──── Update artifacts as you learn                      │\n│   └───────┬────────┘                                                         │\n│           │                                                                  │\n│           ▼                                                                  │\n│   ┌────────────────┐                                                         │\n│   │  4. VERIFY     │  /opsx:verify (optional)                                │\n│   │     WORK       │  Check implementation matches specs                     │\n│   └───────┬────────┘                                                         │\n│           │                                                                  │\n│           ▼                                                                  │\n│   ┌────────────────┐     ┌──────────────────────────────────────────────┐   │\n│   │  5. ARCHIVE    │────►│  Delta specs merge into main specs           │   │\n│   │     CHANGE     │     │  Change folder moves to archive/             │   │\n│   └────────────────┘     │  Specs are now the updated source of truth   │   │\n│                          └──────────────────────────────────────────────┘   │\n│                                                                              │\n└─────────────────────────────────────────────────────────────────────────────┘\n```\n\n**The virtuous cycle:**\n\n1. Specs describe current behavior\n2. Changes propose modifications (as deltas)\n3. Implementation makes the changes real\n4. Archive merges deltas into specs\n5. Specs now describe the new behavior\n6. Next change builds on updated specs\n\n## Glossary\n\n| Term | Definition |\n|------|------------|\n| **Artifact** | A document within a change (proposal, design, tasks, or delta specs) |\n| **Archive** | The process of completing a change and merging its deltas into main specs |\n| **Change** | A proposed modification to the system, packaged as a folder with artifacts |\n| **Delta spec** | A spec that describes changes (ADDED/MODIFIED/REMOVED) relative to current specs |\n| **Domain** | A logical grouping for specs (e.g., `auth/`, `payments/`) |\n| **Requirement** | A specific behavior the system must have |\n| **Scenario** | A concrete example of a requirement, typically in Given/When/Then format |\n| **Schema** | A definition of artifact types and their dependencies |\n| **Spec** | A specification describing system behavior, containing requirements and scenarios |\n| **Source of truth** | The `openspec/specs/` directory, containing the current agreed-upon behavior |\n\n## Next Steps\n\n- [Getting Started](getting-started.md) - Practical first steps\n- [Workflows](workflows.md) - Common patterns and when to use each\n- [Commands](commands.md) - Full command reference\n- [Customization](customization.md) - Create custom schemas and configure your project\n"
  },
  {
    "path": "docs/customization.md",
    "content": "# Customization\n\nOpenSpec provides three levels of customization:\n\n| Level | What it does | Best for |\n|-------|--------------|----------|\n| **Project Config** | Set defaults, inject context/rules | Most teams |\n| **Custom Schemas** | Define your own workflow artifacts | Teams with unique processes |\n| **Global Overrides** | Share schemas across all projects | Power users |\n\n---\n\n## Project Configuration\n\nThe `openspec/config.yaml` file is the easiest way to customize OpenSpec for your team. It lets you:\n\n- **Set a default schema** - Skip `--schema` on every command\n- **Inject project context** - AI sees your tech stack, conventions, etc.\n- **Add per-artifact rules** - Custom rules for specific artifacts\n\n### Quick Setup\n\n```bash\nopenspec init\n```\n\nThis walks you through creating a config interactively. Or create one manually:\n\n```yaml\n# openspec/config.yaml\nschema: spec-driven\n\ncontext: |\n  Tech stack: TypeScript, React, Node.js, PostgreSQL\n  API style: RESTful, documented in docs/api.md\n  Testing: Jest + React Testing Library\n  We value backwards compatibility for all public APIs\n\nrules:\n  proposal:\n    - Include rollback plan\n    - Identify affected teams\n  specs:\n    - Use Given/When/Then format\n    - Reference existing patterns before inventing new ones\n```\n\n### How It Works\n\n**Default schema:**\n\n```bash\n# Without config\nopenspec new change my-feature --schema spec-driven\n\n# With config - schema is automatic\nopenspec new change my-feature\n```\n\n**Context and rules injection:**\n\nWhen generating any artifact, your context and rules are injected into the AI prompt:\n\n```xml\n<context>\nTech stack: TypeScript, React, Node.js, PostgreSQL\n...\n</context>\n\n<rules>\n- Include rollback plan\n- Identify affected teams\n</rules>\n\n<template>\n[Schema's built-in template]\n</template>\n```\n\n- **Context** appears in ALL artifacts\n- **Rules** ONLY appear for the matching artifact\n\n### Schema Resolution Order\n\nWhen OpenSpec needs a schema, it checks in this order:\n\n1. CLI flag: `--schema <name>`\n2. Change metadata (`.openspec.yaml` in the change folder)\n3. Project config (`openspec/config.yaml`)\n4. Default (`spec-driven`)\n\n---\n\n## Custom Schemas\n\nWhen project config isn't enough, create your own schema with a completely custom workflow. Custom schemas live in your project's `openspec/schemas/` directory and are version-controlled with your code.\n\n```text\nyour-project/\n├── openspec/\n│   ├── config.yaml        # Project config\n│   ├── schemas/           # Custom schemas live here\n│   │   └── my-workflow/\n│   │       ├── schema.yaml\n│   │       └── templates/\n│   └── changes/           # Your changes\n└── src/\n```\n\n### Fork an Existing Schema\n\nThe fastest way to customize is to fork a built-in schema:\n\n```bash\nopenspec schema fork spec-driven my-workflow\n```\n\nThis copies the entire `spec-driven` schema to `openspec/schemas/my-workflow/` where you can edit it freely.\n\n**What you get:**\n\n```text\nopenspec/schemas/my-workflow/\n├── schema.yaml           # Workflow definition\n└── templates/\n    ├── proposal.md       # Template for proposal artifact\n    ├── spec.md           # Template for specs\n    ├── design.md         # Template for design\n    └── tasks.md          # Template for tasks\n```\n\nNow edit `schema.yaml` to change the workflow, or edit templates to change what AI generates.\n\n### Create a Schema from Scratch\n\nFor a completely fresh workflow:\n\n```bash\n# Interactive\nopenspec schema init research-first\n\n# Non-interactive\nopenspec schema init rapid \\\n  --description \"Rapid iteration workflow\" \\\n  --artifacts \"proposal,tasks\" \\\n  --default\n```\n\n### Schema Structure\n\nA schema defines the artifacts in your workflow and how they depend on each other:\n\n```yaml\n# openspec/schemas/my-workflow/schema.yaml\nname: my-workflow\nversion: 1\ndescription: My team's custom workflow\n\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    description: Initial proposal document\n    template: proposal.md\n    instruction: |\n      Create a proposal that explains WHY this change is needed.\n      Focus on the problem, not the solution.\n    requires: []\n\n  - id: design\n    generates: design.md\n    description: Technical design\n    template: design.md\n    instruction: |\n      Create a design document explaining HOW to implement.\n    requires:\n      - proposal    # Can't create design until proposal exists\n\n  - id: tasks\n    generates: tasks.md\n    description: Implementation checklist\n    template: tasks.md\n    requires:\n      - design\n\napply:\n  requires: [tasks]\n  tracks: tasks.md\n```\n\n**Key fields:**\n\n| Field | Purpose |\n|-------|---------|\n| `id` | Unique identifier, used in commands and rules |\n| `generates` | Output filename (supports globs like `specs/**/*.md`) |\n| `template` | Template file in `templates/` directory |\n| `instruction` | AI instructions for creating this artifact |\n| `requires` | Dependencies - which artifacts must exist first |\n\n### Templates\n\nTemplates are markdown files that guide the AI. They're injected into the prompt when creating that artifact.\n\n```markdown\n<!-- templates/proposal.md -->\n## Why\n\n<!-- Explain the motivation for this change. What problem does this solve? -->\n\n## What Changes\n\n<!-- Describe what will change. Be specific about new capabilities or modifications. -->\n\n## Impact\n\n<!-- Affected code, APIs, dependencies, systems -->\n```\n\nTemplates can include:\n- Section headers the AI should fill in\n- HTML comments with guidance for the AI\n- Example formats showing expected structure\n\n### Validate Your Schema\n\nBefore using a custom schema, validate it:\n\n```bash\nopenspec schema validate my-workflow\n```\n\nThis checks:\n- `schema.yaml` syntax is correct\n- All referenced templates exist\n- No circular dependencies\n- Artifact IDs are valid\n\n### Use Your Custom Schema\n\nOnce created, use your schema with:\n\n```bash\n# Specify on command\nopenspec new change feature --schema my-workflow\n\n# Or set as default in config.yaml\nschema: my-workflow\n```\n\n### Debug Schema Resolution\n\nNot sure which schema is being used? Check with:\n\n```bash\n# See where a specific schema resolves from\nopenspec schema which my-workflow\n\n# List all available schemas\nopenspec schema which --all\n```\n\nOutput shows whether it's from your project, user directory, or the package:\n\n```text\nSchema: my-workflow\nSource: project\nPath: /path/to/project/openspec/schemas/my-workflow\n```\n\n---\n\n> **Note:** OpenSpec also supports user-level schemas at `~/.local/share/openspec/schemas/` for sharing across projects, but project-level schemas in `openspec/schemas/` are recommended since they're version-controlled with your code.\n\n---\n\n## Examples\n\n### Rapid Iteration Workflow\n\nA minimal workflow for quick iterations:\n\n```yaml\n# openspec/schemas/rapid/schema.yaml\nname: rapid\nversion: 1\ndescription: Fast iteration with minimal overhead\n\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    description: Quick proposal\n    template: proposal.md\n    instruction: |\n      Create a brief proposal for this change.\n      Focus on what and why, skip detailed specs.\n    requires: []\n\n  - id: tasks\n    generates: tasks.md\n    description: Implementation checklist\n    template: tasks.md\n    requires: [proposal]\n\napply:\n  requires: [tasks]\n  tracks: tasks.md\n```\n\n### Adding a Review Artifact\n\nFork the default and add a review step:\n\n```bash\nopenspec schema fork spec-driven with-review\n```\n\nThen edit `schema.yaml` to add:\n\n```yaml\n  - id: review\n    generates: review.md\n    description: Pre-implementation review checklist\n    template: review.md\n    instruction: |\n      Create a review checklist based on the design.\n      Include security, performance, and testing considerations.\n    requires:\n      - design\n\n  - id: tasks\n    # ... existing tasks config ...\n    requires:\n      - specs\n      - design\n      - review    # Now tasks require review too\n```\n\n---\n\n## See Also\n\n- [CLI Reference: Schema Commands](cli.md#schema-commands) - Full command documentation\n"
  },
  {
    "path": "docs/getting-started.md",
    "content": "# Getting Started\n\nThis guide explains how OpenSpec works after you've installed and initialized it. For installation instructions, see the [main README](../README.md#quick-start).\n\n## How It Works\n\nOpenSpec helps you and your AI coding assistant agree on what to build before any code is written.\n\n**Default quick path (core profile):**\n\n```text\n/opsx:propose ──► /opsx:apply ──► /opsx:archive\n```\n\n**Expanded path (custom workflow selection):**\n\n```text\n/opsx:new ──► /opsx:ff or /opsx:continue ──► /opsx:apply ──► /opsx:verify ──► /opsx:archive\n```\n\nThe 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`.\n\n## What OpenSpec Creates\n\nAfter running `openspec init`, your project has this structure:\n\n```\nopenspec/\n├── specs/              # Source of truth (your system's behavior)\n│   └── <domain>/\n│       └── spec.md\n├── changes/            # Proposed updates (one folder per change)\n│   └── <change-name>/\n│       ├── proposal.md\n│       ├── design.md\n│       ├── tasks.md\n│       └── specs/      # Delta specs (what's changing)\n│           └── <domain>/\n│               └── spec.md\n└── config.yaml         # Project configuration (optional)\n```\n\n**Two key directories:**\n\n- **`specs/`** - The source of truth. These specs describe how your system currently behaves. Organized by domain (e.g., `specs/auth/`, `specs/payments/`).\n\n- **`changes/`** - Proposed modifications. Each change gets its own folder with all related artifacts. When a change is complete, its specs merge into the main `specs/` directory.\n\n## Understanding Artifacts\n\nEach change folder contains artifacts that guide the work:\n\n| Artifact | Purpose |\n|----------|---------|\n| `proposal.md` | The \"why\" and \"what\" - captures intent, scope, and approach |\n| `specs/` | Delta specs showing ADDED/MODIFIED/REMOVED requirements |\n| `design.md` | The \"how\" - technical approach and architecture decisions |\n| `tasks.md` | Implementation checklist with checkboxes |\n\n**Artifacts build on each other:**\n\n```\nproposal ──► specs ──► design ──► tasks ──► implement\n   ▲           ▲          ▲                    │\n   └───────────┴──────────┴────────────────────┘\n            update as you learn\n```\n\nYou can always go back and refine earlier artifacts as you learn more during implementation.\n\n## How Delta Specs Work\n\nDelta specs are the key concept in OpenSpec. They show what's changing relative to your current specs.\n\n### The Format\n\nDelta specs use sections to indicate the type of change:\n\n```markdown\n# Delta for Auth\n\n## ADDED Requirements\n\n### Requirement: Two-Factor Authentication\nThe system MUST require a second factor during login.\n\n#### Scenario: OTP required\n- GIVEN a user with 2FA enabled\n- WHEN the user submits valid credentials\n- THEN an OTP challenge is presented\n\n## MODIFIED Requirements\n\n### Requirement: Session Timeout\nThe system SHALL expire sessions after 30 minutes of inactivity.\n(Previously: 60 minutes)\n\n#### Scenario: Idle timeout\n- GIVEN an authenticated session\n- WHEN 30 minutes pass without activity\n- THEN the session is invalidated\n\n## REMOVED Requirements\n\n### Requirement: Remember Me\n(Deprecated in favor of 2FA)\n```\n\n### What Happens on Archive\n\nWhen you archive a change:\n\n1. **ADDED** requirements are appended to the main spec\n2. **MODIFIED** requirements replace the existing version\n3. **REMOVED** requirements are deleted from the main spec\n\nThe change folder moves to `openspec/changes/archive/` for audit history.\n\n## Example: Your First Change\n\nLet's walk through adding dark mode to an application.\n\n### 1. Start the Change (Default)\n\n```text\nYou: /opsx:propose add-dark-mode\n\nAI:  Created openspec/changes/add-dark-mode/\n     ✓ proposal.md — why we're doing this, what's changing\n     ✓ specs/       — requirements and scenarios\n     ✓ design.md    — technical approach\n     ✓ tasks.md     — implementation checklist\n     Ready for implementation!\n```\n\nIf you've enabled the expanded workflow profile, you can also do this as two steps: `/opsx:new` then `/opsx:ff` (or `/opsx:continue` incrementally).\n\n### 2. What Gets Created\n\n**proposal.md** - Captures the intent:\n\n```markdown\n# Proposal: Add Dark Mode\n\n## Intent\nUsers have requested a dark mode option to reduce eye strain\nduring nighttime usage.\n\n## Scope\n- Add theme toggle in settings\n- Support system preference detection\n- Persist preference in localStorage\n\n## Approach\nUse CSS custom properties for theming with a React context\nfor state management.\n```\n\n**specs/ui/spec.md** - Delta showing new requirements:\n\n```markdown\n# Delta for UI\n\n## ADDED Requirements\n\n### Requirement: Theme Selection\nThe system SHALL allow users to choose between light and dark themes.\n\n#### Scenario: Manual toggle\n- GIVEN a user on any page\n- WHEN the user clicks the theme toggle\n- THEN the theme switches immediately\n- AND the preference persists across sessions\n\n#### Scenario: System preference\n- GIVEN a user with no saved preference\n- WHEN the application loads\n- THEN the system's preferred color scheme is used\n```\n\n**tasks.md** - Implementation checklist:\n\n```markdown\n# Tasks\n\n## 1. Theme Infrastructure\n- [ ] 1.1 Create ThemeContext with light/dark state\n- [ ] 1.2 Add CSS custom properties for colors\n- [ ] 1.3 Implement localStorage persistence\n\n## 2. UI Components\n- [ ] 2.1 Create ThemeToggle component\n- [ ] 2.2 Add toggle to settings page\n- [ ] 2.3 Update Header to include quick toggle\n\n## 3. Styling\n- [ ] 3.1 Define dark theme color palette\n- [ ] 3.2 Update components to use CSS variables\n```\n\n### 3. Implement\n\n```\nYou: /opsx:apply\n\nAI:  Working through tasks...\n     ✓ 1.1 Created ThemeContext with light/dark state\n     ✓ 1.2 Added CSS custom properties to globals.css\n     ✓ 1.3 Implemented localStorage persistence\n     ✓ 2.1 Created ThemeToggle component\n     ...\n     All tasks complete!\n```\n\nDuring implementation, if you discover the design needs adjustment, just update the artifact and continue.\n\n### 4. Archive\n\n```\nYou: /opsx:archive\n\nAI:  Archiving add-dark-mode...\n     ✓ Merged specs into openspec/specs/ui/spec.md\n     ✓ Moved to openspec/changes/archive/2025-01-24-add-dark-mode/\n     Done! Ready for the next feature.\n```\n\nYour delta specs are now part of the main specs, documenting how your system works.\n\n## Verifying and Reviewing\n\nUse the CLI to check on your changes:\n\n```bash\n# List active changes\nopenspec list\n\n# View change details\nopenspec show add-dark-mode\n\n# Validate spec formatting\nopenspec validate add-dark-mode\n\n# Interactive dashboard\nopenspec view\n```\n\n## Next Steps\n\n- [Workflows](workflows.md) - Common patterns and when to use each command\n- [Commands](commands.md) - Full reference for all slash commands\n- [Concepts](concepts.md) - Deeper understanding of specs, changes, and schemas\n- [Customization](customization.md) - Make OpenSpec work your way\n"
  },
  {
    "path": "docs/installation.md",
    "content": "# Installation\n\n## Prerequisites\n\n- **Node.js 20.19.0 or higher** — Check your version: `node --version`\n\n## Package Managers\n\n### npm\n\n```bash\nnpm install -g @fission-ai/openspec@latest\n```\n\n### pnpm\n\n```bash\npnpm add -g @fission-ai/openspec@latest\n```\n\n### yarn\n\n```bash\nyarn global add @fission-ai/openspec@latest\n```\n\n### bun\n\n```bash\nbun add -g @fission-ai/openspec@latest\n```\n\n## Nix\n\nRun OpenSpec directly without installation:\n\n```bash\nnix run github:Fission-AI/OpenSpec -- init\n```\n\nOr install to your profile:\n\n```bash\nnix profile install github:Fission-AI/OpenSpec\n```\n\nOr add to your development environment in `flake.nix`:\n\n```nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    openspec.url = \"github:Fission-AI/OpenSpec\";\n  };\n\n  outputs = { nixpkgs, openspec, ... }: {\n    devShells.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShell {\n      buildInputs = [ openspec.packages.x86_64-linux.default ];\n    };\n  };\n}\n```\n\n## Verify Installation\n\n```bash\nopenspec --version\n```\n\n## Next Steps\n\nAfter installing, initialize OpenSpec in your project:\n\n```bash\ncd your-project\nopenspec init\n```\n\nSee [Getting Started](getting-started.md) for a full walkthrough.\n"
  },
  {
    "path": "docs/migration-guide.md",
    "content": "# Migrating to OPSX\n\nThis guide helps you transition from the legacy OpenSpec workflow to OPSX. The migration is designed to be smooth—your existing work is preserved, and the new system offers more flexibility.\n\n## What's Changing?\n\nOPSX replaces the old phase-locked workflow with a fluid, action-based approach. Here's the key shift:\n\n| Aspect | Legacy | OPSX |\n|--------|--------|------|\n| **Commands** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` | Default: `/opsx:propose`, `/opsx:apply`, `/opsx:archive` (expanded workflow commands optional) |\n| **Workflow** | Create all artifacts at once | Create incrementally or all at once—your choice |\n| **Going back** | Awkward phase gates | Natural—update any artifact anytime |\n| **Customization** | Fixed structure | Schema-driven, fully hackable |\n| **Configuration** | `CLAUDE.md` with markers + `project.md` | Clean config in `openspec/config.yaml` |\n\n**The philosophy change:** Work isn't linear. OPSX stops pretending it is.\n\n---\n\n## Before You Begin\n\n### Your Existing Work Is Safe\n\nThe migration process is designed with preservation in mind:\n\n- **Active changes in `openspec/changes/`** — Completely preserved. You can continue them with OPSX commands.\n- **Archived changes** — Untouched. Your history remains intact.\n- **Main specs in `openspec/specs/`** — Untouched. These are your source of truth.\n- **Your content in CLAUDE.md, AGENTS.md, etc.** — Preserved. Only the OpenSpec marker blocks are removed; everything you wrote stays.\n\n### What Gets Removed\n\nOnly OpenSpec-managed files that are being replaced:\n\n| What | Why |\n|------|-----|\n| Legacy slash command directories/files | Replaced by the new skills system |\n| `openspec/AGENTS.md` | Obsolete workflow trigger |\n| OpenSpec markers in `CLAUDE.md`, `AGENTS.md`, etc. | No longer needed |\n\n**Legacy command locations by tool** (examples—your tool may vary):\n\n- Claude Code: `.claude/commands/openspec/`\n- Cursor: `.cursor/commands/openspec-*.md`\n- Windsurf: `.windsurf/workflows/openspec-*.md`\n- Cline: `.clinerules/workflows/openspec-*.md`\n- Roo: `.roo/commands/openspec-*.md`\n- GitHub Copilot: `.github/prompts/openspec-*.prompt.md` (IDE extensions only; not supported in Copilot CLI)\n- And others (Augment, Continue, Amazon Q, etc.)\n\nThe migration detects whichever tools you have configured and cleans up their legacy files.\n\nThe removal list may seem long, but these are all files that OpenSpec originally created. Your own content is never deleted.\n\n### What Needs Your Attention\n\nOne file requires manual migration:\n\n**`openspec/project.md`** — This file isn't deleted automatically because it may contain project context you've written. You'll need to:\n\n1. Review its contents\n2. Move useful context to `openspec/config.yaml` (see guidance below)\n3. Delete the file when ready\n\n**Why we made this change:**\n\nThe old `project.md` was passive—agents might read it, might not, might forget what they read. We found reliability was inconsistent.\n\nThe new `config.yaml` context is **actively injected into every OpenSpec planning request**. This means your project conventions, tech stack, and rules are always present when the AI is creating artifacts. Higher reliability.\n\n**The tradeoff:**\n\nBecause context is injected into every request, you'll want to be concise. Focus on what really matters:\n- Tech stack and key conventions\n- Non-obvious constraints the AI needs to know\n- Rules that frequently got ignored before\n\nDon't worry about getting it perfect. We're still learning what works best here, and we'll be improving how context injection works as we experiment.\n\n---\n\n## Running the Migration\n\nBoth `openspec init` and `openspec update` detect legacy files and guide you through the same cleanup process. Use whichever fits your situation:\n\n- New installs default to profile `core` (`propose`, `explore`, `apply`, `archive`).\n- Migrated installs preserve your previously installed workflows by writing a `custom` profile when needed.\n\n### Using `openspec init`\n\nRun this if you want to add new tools or reconfigure which tools are set up:\n\n```bash\nopenspec init\n```\n\nThe init command detects legacy files and guides you through cleanup:\n\n```\nUpgrading to the new OpenSpec\n\nOpenSpec now uses agent skills, the emerging standard across coding\nagents. This simplifies your setup while keeping everything working\nas before.\n\nFiles to remove\nNo user content to preserve:\n  • .claude/commands/openspec/\n  • openspec/AGENTS.md\n\nFiles to update\nOpenSpec markers will be removed, your content preserved:\n  • CLAUDE.md\n  • AGENTS.md\n\nNeeds your attention\n  • openspec/project.md\n    We won't delete this file. It may contain useful project context.\n\n    The new openspec/config.yaml has a \"context:\" section for planning\n    context. This is included in every OpenSpec request and works more\n    reliably than the old project.md approach.\n\n    Review project.md, move any useful content to config.yaml's context\n    section, then delete the file when ready.\n\n? Upgrade and clean up legacy files? (Y/n)\n```\n\n**What happens when you say yes:**\n\n1. Legacy slash command directories are removed\n2. OpenSpec markers are stripped from `CLAUDE.md`, `AGENTS.md`, etc. (your content stays)\n3. `openspec/AGENTS.md` is deleted\n4. New skills are installed in `.claude/skills/`\n5. `openspec/config.yaml` is created with a default schema\n\n### Using `openspec update`\n\nRun this if you just want to migrate and refresh your existing tools to the latest version:\n\n```bash\nopenspec update\n```\n\nThe update command also detects and cleans up legacy artifacts, then refreshes generated skills/commands to match your current profile and delivery settings.\n\n### Non-Interactive / CI Environments\n\nFor scripted migrations:\n\n```bash\nopenspec init --force --tools claude\n```\n\nThe `--force` flag skips prompts and auto-accepts cleanup.\n\n---\n\n## Migrating project.md to config.yaml\n\nThe old `openspec/project.md` was a freeform markdown file for project context. The new `openspec/config.yaml` is structured and—critically—**injected into every planning request** so your conventions are always present when the AI works.\n\n### Before (project.md)\n\n```markdown\n# Project Context\n\nThis is a TypeScript monorepo using React and Node.js.\nWe use Jest for testing and follow strict ESLint rules.\nOur API is RESTful and documented in docs/api.md.\n\n## Conventions\n\n- All public APIs must maintain backwards compatibility\n- New features should include tests\n- Use Given/When/Then format for specifications\n```\n\n### After (config.yaml)\n\n```yaml\nschema: spec-driven\n\ncontext: |\n  Tech stack: TypeScript, React, Node.js\n  Testing: Jest with React Testing Library\n  API: RESTful, documented in docs/api.md\n  We maintain backwards compatibility for all public APIs\n\nrules:\n  proposal:\n    - Include rollback plan for risky changes\n  specs:\n    - Use Given/When/Then format for scenarios\n    - Reference existing patterns before inventing new ones\n  design:\n    - Include sequence diagrams for complex flows\n```\n\n### Key Differences\n\n| project.md | config.yaml |\n|------------|-------------|\n| Freeform markdown | Structured YAML |\n| One blob of text | Separate context and per-artifact rules |\n| Unclear when it's used | Context appears in ALL artifacts; rules appear in matching artifacts only |\n| No schema selection | Explicit `schema:` field sets default workflow |\n\n### What to Keep, What to Drop\n\nWhen migrating, be selective. Ask yourself: \"Does the AI need this for *every* planning request?\"\n\n**Good candidates for `context:`**\n- Tech stack (languages, frameworks, databases)\n- Key architectural patterns (monorepo, microservices, etc.)\n- Non-obvious constraints (\"we can't use library X because...\")\n- Critical conventions that often get ignored\n\n**Move to `rules:` instead**\n- Artifact-specific formatting (\"use Given/When/Then in specs\")\n- Review criteria (\"proposals must include rollback plans\")\n- These only appear for the matching artifact, keeping other requests lighter\n\n**Leave out entirely**\n- General best practices the AI already knows\n- Verbose explanations that could be summarized\n- Historical context that doesn't affect current work\n\n### Migration Steps\n\n1. **Create config.yaml** (if not already created by init):\n   ```yaml\n   schema: spec-driven\n   ```\n\n2. **Add your context** (be concise—this goes into every request):\n   ```yaml\n   context: |\n     Your project background goes here.\n     Focus on what the AI genuinely needs to know.\n   ```\n\n3. **Add per-artifact rules** (optional):\n   ```yaml\n   rules:\n     proposal:\n       - Your proposal-specific guidance\n     specs:\n       - Your spec-writing rules\n   ```\n\n4. **Delete project.md** once you've moved everything useful.\n\n**Don't overthink it.** Start with the essentials and iterate. If you notice the AI missing something important, add it. If context feels bloated, trim it. This is a living document.\n\n### Need Help? Use This Prompt\n\nIf you're unsure how to distill your project.md, ask your AI assistant:\n\n```\nI'm migrating from OpenSpec's old project.md to the new config.yaml format.\n\nHere's my current project.md:\n[paste your project.md content]\n\nPlease help me create a config.yaml with:\n1. A concise `context:` section (this gets injected into every planning request, so keep it tight—focus on tech stack, key constraints, and conventions that often get ignored)\n2. `rules:` for specific artifacts if any content is artifact-specific (e.g., \"use Given/When/Then\" belongs in specs rules, not global context)\n\nLeave out anything generic that AI models already know. Be ruthless about brevity.\n```\n\nThe AI will help you identify what's essential vs. what can be trimmed.\n\n---\n\n## The New Commands\n\nCommand availability is profile-dependent:\n\n**Default (`core` profile):**\n\n| Command | Purpose |\n|---------|---------|\n| `/opsx:propose` | Create a change and generate planning artifacts in one step |\n| `/opsx:explore` | Think through ideas with no structure |\n| `/opsx:apply` | Implement tasks from tasks.md |\n| `/opsx:archive` | Finalize and archive the change |\n\n**Expanded workflow (custom selection):**\n\n| Command | Purpose |\n|---------|---------|\n| `/opsx:new` | Start a new change scaffold |\n| `/opsx:continue` | Create the next artifact (one at a time) |\n| `/opsx:ff` | Fast-forward—create planning artifacts at once |\n| `/opsx:verify` | Validate implementation matches specs |\n| `/opsx:sync` | Preview/spec-merge without archiving |\n| `/opsx:bulk-archive` | Archive multiple changes at once |\n| `/opsx:onboard` | Guided end-to-end onboarding workflow |\n\nEnable expanded commands with `openspec config profile`, then run `openspec update`.\n\n### Command Mapping from Legacy\n\n| Legacy | OPSX Equivalent |\n|--------|-----------------|\n| `/openspec:proposal` | `/opsx:propose` (default) or `/opsx:new` then `/opsx:ff` (expanded) |\n| `/openspec:apply` | `/opsx:apply` |\n| `/openspec:archive` | `/opsx:archive` |\n\n### New Capabilities\n\nThese capabilities are part of the expanded workflow command set.\n\n**Granular artifact creation:**\n```\n/opsx:continue\n```\nCreates one artifact at a time based on dependencies. Use this when you want to review each step.\n\n**Exploration mode:**\n```\n/opsx:explore\n```\nThink through ideas with a partner before committing to a change.\n\n---\n\n## Understanding the New Architecture\n\n### From Phase-Locked to Fluid\n\nThe legacy workflow forced linear progression:\n\n```\n┌──────────────┐      ┌──────────────┐      ┌──────────────┐\n│   PLANNING   │ ───► │ IMPLEMENTING │ ───► │   ARCHIVING  │\n│    PHASE     │      │    PHASE     │      │    PHASE     │\n└──────────────┘      └──────────────┘      └──────────────┘\n\nIf you're in implementation and realize the design is wrong?\nToo bad. Phase gates don't let you go back easily.\n```\n\nOPSX uses actions, not phases:\n\n```\n         ┌───────────────────────────────────────────────┐\n         │           ACTIONS (not phases)                │\n         │                                               │\n         │     new ◄──► continue ◄──► apply ◄──► archive │\n         │      │          │           │             │   │\n         │      └──────────┴───────────┴─────────────┘   │\n         │                    any order                  │\n         └───────────────────────────────────────────────┘\n```\n\n### Dependency Graph\n\nArtifacts form a directed graph. Dependencies are enablers, not gates:\n\n```\n                        proposal\n                       (root node)\n                            │\n              ┌─────────────┴─────────────┐\n              │                           │\n              ▼                           ▼\n           specs                       design\n        (requires:                  (requires:\n         proposal)                   proposal)\n              │                           │\n              └─────────────┬─────────────┘\n                            │\n                            ▼\n                         tasks\n                     (requires:\n                     specs, design)\n```\n\nWhen you run `/opsx:continue`, it checks what's ready and offers the next artifact. You can also create multiple ready artifacts in any order.\n\n### Skills vs Commands\n\nThe legacy system used tool-specific command files:\n\n```\n.claude/commands/openspec/\n├── proposal.md\n├── apply.md\n└── archive.md\n```\n\nOPSX uses the emerging **skills** standard:\n\n```\n.claude/skills/\n├── openspec-explore/SKILL.md\n├── openspec-new-change/SKILL.md\n├── openspec-continue-change/SKILL.md\n├── openspec-apply-change/SKILL.md\n└── ...\n```\n\nSkills are recognized across multiple AI coding tools and provide richer metadata.\n\n---\n\n## Continuing Existing Changes\n\nYour in-progress changes work seamlessly with OPSX commands.\n\n**Have an active change from the legacy workflow?**\n\n```\n/opsx:apply add-my-feature\n```\n\nOPSX reads the existing artifacts and continues from where you left off.\n\n**Want to add more artifacts to an existing change?**\n\n```\n/opsx:continue add-my-feature\n```\n\nShows what's ready to create based on what already exists.\n\n**Need to see status?**\n\n```bash\nopenspec status --change add-my-feature\n```\n\n---\n\n## The New Config System\n\n### config.yaml Structure\n\n```yaml\n# Required: Default schema for new changes\nschema: spec-driven\n\n# Optional: Project context (max 50KB)\n# Injected into ALL artifact instructions\ncontext: |\n  Your project background, tech stack,\n  conventions, and constraints.\n\n# Optional: Per-artifact rules\n# Only injected into matching artifacts\nrules:\n  proposal:\n    - Include rollback plan\n  specs:\n    - Use Given/When/Then format\n  design:\n    - Document fallback strategies\n  tasks:\n    - Break into 2-hour maximum chunks\n```\n\n### Schema Resolution\n\nWhen determining which schema to use, OPSX checks in order:\n\n1. **CLI flag**: `--schema <name>` (highest priority)\n2. **Change metadata**: `.openspec.yaml` in the change directory\n3. **Project config**: `openspec/config.yaml`\n4. **Default**: `spec-driven`\n\n### Available Schemas\n\n| Schema | Artifacts | Best For |\n|--------|-----------|----------|\n| `spec-driven` | proposal → specs → design → tasks | Most projects |\n\nList all available schemas:\n\n```bash\nopenspec schemas\n```\n\n### Custom Schemas\n\nCreate your own workflow:\n\n```bash\nopenspec schema init my-workflow\n```\n\nOr fork an existing one:\n\n```bash\nopenspec schema fork spec-driven my-workflow\n```\n\nSee [Customization](customization.md) for details.\n\n---\n\n## Troubleshooting\n\n### \"Legacy files detected in non-interactive mode\"\n\nYou're running in a CI or non-interactive environment. Use:\n\n```bash\nopenspec init --force\n```\n\n### Commands not appearing after migration\n\nRestart your IDE. Skills are detected at startup.\n\n### \"Unknown artifact ID in rules\"\n\nCheck that your `rules:` keys match your schema's artifact IDs:\n\n- **spec-driven**: `proposal`, `specs`, `design`, `tasks`\n\nRun this to see valid artifact IDs:\n\n```bash\nopenspec schemas --json\n```\n\n### Config not being applied\n\n1. Ensure the file is at `openspec/config.yaml` (not `.yml`)\n2. Validate YAML syntax\n3. Config changes take effect immediately—no restart needed\n\n### project.md not migrated\n\nThe system intentionally preserves `project.md` because it may contain your custom content. Review it manually, move useful parts to `config.yaml`, then delete it.\n\n### Want to see what would be cleaned up?\n\nRun init and decline the cleanup prompt—you'll see the full detection summary without any changes being made.\n\n---\n\n## Quick Reference\n\n### Files After Migration\n\n```\nproject/\n├── openspec/\n│   ├── specs/                    # Unchanged\n│   ├── changes/                  # Unchanged\n│   │   └── archive/              # Unchanged\n│   └── config.yaml               # NEW: Project configuration\n├── .claude/\n│   └── skills/                   # NEW: OPSX skills\n│       ├── openspec-propose/     # default core profile\n│       ├── openspec-explore/\n│       ├── openspec-apply-change/\n│       └── ...                   # expanded profile adds new/continue/ff/etc.\n├── CLAUDE.md                     # OpenSpec markers removed, your content preserved\n└── AGENTS.md                     # OpenSpec markers removed, your content preserved\n```\n\n### What's Gone\n\n- `.claude/commands/openspec/` — replaced by `.claude/skills/`\n- `openspec/AGENTS.md` — obsolete\n- `openspec/project.md` — migrate to `config.yaml`, then delete\n- OpenSpec marker blocks in `CLAUDE.md`, `AGENTS.md`, etc.\n\n### Command Cheatsheet\n\n```text\n/opsx:propose      Start quickly (default core profile)\n/opsx:apply        Implement tasks\n/opsx:archive      Finish and archive\n\n# Expanded workflow (if enabled):\n/opsx:new          Scaffold a change\n/opsx:continue     Create next artifact\n/opsx:ff           Create planning artifacts\n```\n\n---\n\n## Getting Help\n\n- **Discord**: [discord.gg/YctCnvvshC](https://discord.gg/YctCnvvshC)\n- **GitHub Issues**: [github.com/Fission-AI/OpenSpec/issues](https://github.com/Fission-AI/OpenSpec/issues)\n- **Documentation**: [docs/opsx.md](opsx.md) for the full OPSX reference\n"
  },
  {
    "path": "docs/multi-language.md",
    "content": "# Multi-Language Guide\n\nConfigure OpenSpec to generate artifacts in languages other than English.\n\n## Quick Setup\n\nAdd a language instruction to your `openspec/config.yaml`:\n\n```yaml\nschema: spec-driven\n\ncontext: |\n  Language: Portuguese (pt-BR)\n  All artifacts must be written in Brazilian Portuguese.\n\n  # Your other project context below...\n  Tech stack: TypeScript, React, Node.js\n```\n\nThat's it. All generated artifacts will now be in Portuguese.\n\n## Language Examples\n\n### Portuguese (Brazil)\n\n```yaml\ncontext: |\n  Language: Portuguese (pt-BR)\n  All artifacts must be written in Brazilian Portuguese.\n```\n\n### Spanish\n\n```yaml\ncontext: |\n  Idioma: Español\n  Todos los artefactos deben escribirse en español.\n```\n\n### Chinese (Simplified)\n\n```yaml\ncontext: |\n  语言：中文（简体）\n  所有产出物必须用简体中文撰写。\n```\n\n### Japanese\n\n```yaml\ncontext: |\n  言語：日本語\n  すべての成果物は日本語で作成してください。\n```\n\n### French\n\n```yaml\ncontext: |\n  Langue : Français\n  Tous les artefacts doivent être rédigés en français.\n```\n\n### German\n\n```yaml\ncontext: |\n  Sprache: Deutsch\n  Alle Artefakte müssen auf Deutsch verfasst werden.\n```\n\n## Tips\n\n### Handle Technical Terms\n\nDecide how to handle technical terminology:\n\n```yaml\ncontext: |\n  Language: Japanese\n  Write in Japanese, but:\n  - Keep technical terms like \"API\", \"REST\", \"GraphQL\" in English\n  - Code examples and file paths remain in English\n```\n\n### Combine with Other Context\n\nLanguage settings work alongside your other project context:\n\n```yaml\nschema: spec-driven\n\ncontext: |\n  Language: Portuguese (pt-BR)\n  All artifacts must be written in Brazilian Portuguese.\n\n  Tech stack: TypeScript, React 18, Node.js 20\n  Database: PostgreSQL with Prisma ORM\n```\n\n## Verification\n\nTo verify your language config is working:\n\n```bash\n# Check the instructions - should show your language context\nopenspec instructions proposal --change my-change\n\n# Output will include your language context\n```\n\n## Related Documentation\n\n- [Customization Guide](./customization.md) - Project configuration options\n- [Workflows Guide](./workflows.md) - Full workflow documentation\n"
  },
  {
    "path": "docs/opsx.md",
    "content": "# OPSX Workflow\n\n> Feedback welcome on [Discord](https://discord.gg/YctCnvvshC).\n\n## What Is It?\n\nOPSX is now the standard workflow for OpenSpec.\n\nIt's a **fluid, iterative workflow** for OpenSpec changes. No more rigid phases — just actions you can take anytime.\n\n## Why This Exists\n\nThe legacy OpenSpec workflow works, but it's **locked down**:\n\n- **Instructions are hardcoded** — buried in TypeScript, you can't change them\n- **All-or-nothing** — one big command creates everything, can't test individual pieces\n- **Fixed structure** — same workflow for everyone, no customization\n- **Black box** — when AI output is bad, you can't tweak the prompts\n\n**OPSX opens it up.** Now anyone can:\n\n1. **Experiment with instructions** — edit a template, see if the AI does better\n2. **Test granularly** — validate each artifact's instructions independently\n3. **Customize workflows** — define your own artifacts and dependencies\n4. **Iterate quickly** — change a template, test immediately, no rebuild\n\n```\nLegacy workflow:                      OPSX:\n┌────────────────────────┐           ┌────────────────────────┐\n│  Hardcoded in package  │           │  schema.yaml           │◄── You edit this\n│  (can't change)        │           │  templates/*.md        │◄── Or this\n│        ↓               │           │        ↓               │\n│  Wait for new release  │           │  Instant effect        │\n│        ↓               │           │        ↓               │\n│  Hope it's better      │           │  Test it yourself      │\n└────────────────────────┘           └────────────────────────┘\n```\n\n**This is for everyone:**\n- **Teams** — create workflows that match how you actually work\n- **Power users** — tweak prompts to get better AI outputs for your codebase\n- **OpenSpec contributors** — experiment with new approaches without releases\n\nWe're all still learning what works best. OPSX lets us learn together.\n\n## The User Experience\n\n**The problem with linear workflows:**\nYou're \"in planning phase\", then \"in implementation phase\", then \"done\". But real work doesn't work that way. You implement something, realize your design was wrong, need to update specs, continue implementing. Linear phases fight against how work actually happens.\n\n**OPSX approach:**\n- **Actions, not phases** — create, implement, update, archive — do any of them anytime\n- **Dependencies are enablers** — they show what's possible, not what's required next\n\n```\n  proposal ──→ specs ──→ design ──→ tasks ──→ implement\n```\n\n## Setup\n\n```bash\n# Make sure you have openspec installed — skills are automatically generated\nopenspec init\n```\n\nThis creates skills in `.claude/skills/` (or equivalent) that AI coding assistants auto-detect.\n\nBy 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`.\n\nDuring setup, you'll be prompted to create a **project config** (`openspec/config.yaml`). This is optional but recommended.\n\n## Project Configuration\n\nProject config lets you set defaults and inject project-specific context into all artifacts.\n\n### Creating Config\n\nConfig is created during `openspec init`, or manually:\n\n```yaml\n# openspec/config.yaml\nschema: spec-driven\n\ncontext: |\n  Tech stack: TypeScript, React, Node.js\n  API conventions: RESTful, JSON responses\n  Testing: Vitest for unit tests, Playwright for e2e\n  Style: ESLint with Prettier, strict TypeScript\n\nrules:\n  proposal:\n    - Include rollback plan\n    - Identify affected teams\n  specs:\n    - Use Given/When/Then format for scenarios\n  design:\n    - Include sequence diagrams for complex flows\n```\n\n### Config Fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `schema` | string | Default schema for new changes (e.g., `spec-driven`) |\n| `context` | string | Project context injected into all artifact instructions |\n| `rules` | object | Per-artifact rules, keyed by artifact ID |\n\n### How It Works\n\n**Schema precedence** (highest to lowest):\n1. CLI flag (`--schema <name>`)\n2. Change metadata (`.openspec.yaml` in change directory)\n3. Project config (`openspec/config.yaml`)\n4. Default (`spec-driven`)\n\n**Context injection:**\n- Context is prepended to every artifact's instructions\n- Wrapped in `<context>...</context>` tags\n- Helps AI understand your project's conventions\n\n**Rules injection:**\n- Rules are only injected for matching artifacts\n- Wrapped in `<rules>...</rules>` tags\n- Appear after context, before the template\n\n### Artifact IDs by Schema\n\n**spec-driven** (default):\n- `proposal` — Change proposal\n- `specs` — Specifications\n- `design` — Technical design\n- `tasks` — Implementation tasks\n\n### Config Validation\n\n- Unknown artifact IDs in `rules` generate warnings\n- Schema names are validated against available schemas\n- Context has a 50KB size limit\n- Invalid YAML is reported with line numbers\n\n### Troubleshooting\n\n**\"Unknown artifact ID in rules: X\"**\n- Check artifact IDs match your schema (see list above)\n- Run `openspec schemas --json` to see artifact IDs for each schema\n\n**Config not being applied:**\n- Ensure file is at `openspec/config.yaml` (not `.yml`)\n- Check YAML syntax with a validator\n- Config changes take effect immediately (no restart needed)\n\n**Context too large:**\n- Context is limited to 50KB\n- Summarize or link to external docs instead\n\n## Commands\n\n| Command | What it does |\n|---------|--------------|\n| `/opsx:propose` | Create a change and generate planning artifacts in one step (default quick path) |\n| `/opsx:explore` | Think through ideas, investigate problems, clarify requirements |\n| `/opsx:new` | Start a new change scaffold (expanded workflow) |\n| `/opsx:continue` | Create the next artifact (expanded workflow) |\n| `/opsx:ff` | Fast-forward planning artifacts (expanded workflow) |\n| `/opsx:apply` | Implement tasks, updating artifacts as needed |\n| `/opsx:verify` | Validate implementation against artifacts (expanded workflow) |\n| `/opsx:sync` | Sync delta specs to main (expanded workflow, optional) |\n| `/opsx:archive` | Archive when done |\n| `/opsx:bulk-archive` | Archive multiple completed changes (expanded workflow) |\n| `/opsx:onboard` | Guided walkthrough of an end-to-end change (expanded workflow) |\n\n## Usage\n\n### Explore an idea\n```\n/opsx:explore\n```\nThink through ideas, investigate problems, compare options. No structure required - just a thinking partner. When insights crystallize, transition to `/opsx:propose` (default) or `/opsx:new`/`/opsx:ff` (expanded).\n\n### Start a new change\n```\n/opsx:propose\n```\nCreates the change and generates planning artifacts needed before implementation.\n\nIf you've enabled expanded workflows, you can instead use:\n\n```text\n/opsx:new        # scaffold only\n/opsx:continue   # create one artifact at a time\n/opsx:ff         # create all planning artifacts at once\n```\n\n### Create artifacts\n```\n/opsx:continue\n```\nShows what's ready to create based on dependencies, then creates one artifact. Use repeatedly to build up your change incrementally.\n\n```\n/opsx:ff add-dark-mode\n```\nCreates all planning artifacts at once. Use when you have a clear picture of what you're building.\n\n### Implement (the fluid part)\n```\n/opsx:apply\n```\nWorks through tasks, checking them off as you go. If you're juggling multiple changes, you can run `/opsx:apply <name>`; otherwise it should infer from the conversation and prompt you to choose if it can't tell.\n\n### Finish up\n```\n/opsx:archive   # Move to archive when done (prompts to sync specs if needed)\n```\n\n## When to Update vs. Start Fresh\n\nYou can always edit your proposal or specs before implementation. But when does refining become \"this is different work\"?\n\n### What a Proposal Captures\n\nA proposal defines three things:\n1. **Intent** — What problem are you solving?\n2. **Scope** — What's in/out of bounds?\n3. **Approach** — How will you solve it?\n\nThe question is: which changed, and by how much?\n\n### Update the Existing Change When:\n\n**Same intent, refined execution**\n- You discover edge cases you didn't consider\n- The approach needs tweaking but the goal is unchanged\n- Implementation reveals the design was slightly off\n\n**Scope narrows**\n- You realize full scope is too big, want to ship MVP first\n- \"Add dark mode\" → \"Add dark mode toggle (system preference in v2)\"\n\n**Learning-driven corrections**\n- Codebase isn't structured how you thought\n- A dependency doesn't work as expected\n- \"Use CSS variables\" → \"Use Tailwind's dark: prefix instead\"\n\n### Start a New Change When:\n\n**Intent fundamentally changed**\n- The problem itself is different now\n- \"Add dark mode\" → \"Add comprehensive theme system with custom colors, fonts, spacing\"\n\n**Scope exploded**\n- Change grew so much it's essentially different work\n- Original proposal would be unrecognizable after updates\n- \"Fix login bug\" → \"Rewrite auth system\"\n\n**Original is completable**\n- The original change can be marked \"done\"\n- New work stands alone, not a refinement\n- Complete \"Add dark mode MVP\" → Archive → New change \"Enhance dark mode\"\n\n### The Heuristics\n\n```\n                        ┌─────────────────────────────────────┐\n                        │     Is this the same work?          │\n                        └──────────────┬──────────────────────┘\n                                       │\n                    ┌──────────────────┼──────────────────┐\n                    │                  │                  │\n                    ▼                  ▼                  ▼\n             Same intent?      >50% overlap?      Can original\n             Same problem?     Same scope?        be \"done\" without\n                    │                  │          these changes?\n                    │                  │                  │\n          ┌────────┴────────┐  ┌──────┴──────┐   ┌───────┴───────┐\n          │                 │  │             │   │               │\n         YES               NO YES           NO  NO              YES\n          │                 │  │             │   │               │\n          ▼                 ▼  ▼             ▼   ▼               ▼\n       UPDATE            NEW  UPDATE       NEW  UPDATE          NEW\n```\n\n| Test | Update | New Change |\n|------|--------|------------|\n| **Identity** | \"Same thing, refined\" | \"Different work\" |\n| **Scope overlap** | >50% overlaps | <50% overlaps |\n| **Completion** | Can't be \"done\" without changes | Can finish original, new work stands alone |\n| **Story** | Update chain tells coherent story | Patches would confuse more than clarify |\n\n### The Principle\n\n> **Update preserves context. New change provides clarity.**\n>\n> Choose update when the history of your thinking is valuable.\n> Choose new when starting fresh would be clearer than patching.\n\nThink of it like git branches:\n- Keep committing while working on the same feature\n- Start a new branch when it's genuinely new work\n- Sometimes merge a partial feature and start fresh for phase 2\n\n## What's Different?\n\n| | Legacy (`/openspec:proposal`) | OPSX (`/opsx:*`) |\n|---|---|---|\n| **Structure** | One big proposal document | Discrete artifacts with dependencies |\n| **Workflow** | Linear phases: plan → implement → archive | Fluid actions — do anything anytime |\n| **Iteration** | Awkward to go back | Update artifacts as you learn |\n| **Customization** | Fixed structure | Schema-driven (define your own artifacts) |\n\n**The key insight:** work isn't linear. OPSX stops pretending it is.\n\n## Architecture Deep Dive\n\nThis section explains how OPSX works under the hood and how it compares to the legacy workflow.\nExamples in this section use the expanded command set (`new`, `continue`, etc.); default `core` users can map the same flow to `propose → apply → archive`.\n\n### Philosophy: Phases vs Actions\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                         LEGACY WORKFLOW                                      │\n│                    (Phase-Locked, All-or-Nothing)                           │\n├─────────────────────────────────────────────────────────────────────────────┤\n│                                                                             │\n│   ┌──────────────┐      ┌──────────────┐      ┌──────────────┐             │\n│   │   PLANNING   │ ───► │ IMPLEMENTING │ ───► │   ARCHIVING  │             │\n│   │    PHASE     │      │    PHASE     │      │    PHASE     │             │\n│   └──────────────┘      └──────────────┘      └──────────────┘             │\n│         │                     │                     │                       │\n│         ▼                     ▼                     ▼                       │\n│   /openspec:proposal   /openspec:apply      /openspec:archive              │\n│                                                                             │\n│   • Creates ALL artifacts at once                                          │\n│   • Can't go back to update specs during implementation                    │\n│   • Phase gates enforce linear progression                                  │\n│                                                                             │\n└─────────────────────────────────────────────────────────────────────────────┘\n\n\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                            OPSX WORKFLOW                                     │\n│                      (Fluid Actions, Iterative)                             │\n├─────────────────────────────────────────────────────────────────────────────┤\n│                                                                             │\n│              ┌────────────────────────────────────────────┐                 │\n│              │           ACTIONS (not phases)             │                 │\n│              │                                            │                 │\n│              │   new ◄──► continue ◄──► apply ◄──► archive │                 │\n│              │    │          │           │           │    │                 │\n│              │    └──────────┴───────────┴───────────┘    │                 │\n│              │              any order                     │                 │\n│              └────────────────────────────────────────────┘                 │\n│                                                                             │\n│   • Create artifacts one at a time OR fast-forward                         │\n│   • Update specs/design/tasks during implementation                        │\n│   • Dependencies enable progress, phases don't exist                       │\n│                                                                             │\n└─────────────────────────────────────────────────────────────────────────────┘\n```\n\n### Component Architecture\n\n**Legacy workflow** uses hardcoded templates in TypeScript:\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                      LEGACY WORKFLOW COMPONENTS                              │\n├─────────────────────────────────────────────────────────────────────────────┤\n│                                                                             │\n│   Hardcoded Templates (TypeScript strings)                                  │\n│                    │                                                        │\n│                    ▼                                                        │\n│   Tool-specific configurators/adapters                                      │\n│                    │                                                        │\n│                    ▼                                                        │\n│   Generated Command Files (.claude/commands/openspec/*.md)                  │\n│                                                                             │\n│   • Fixed structure, no artifact awareness                                  │\n│   • Change requires code modification + rebuild                             │\n│                                                                             │\n└─────────────────────────────────────────────────────────────────────────────┘\n```\n\n**OPSX** uses external schemas and a dependency graph engine:\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                         OPSX COMPONENTS                                      │\n├─────────────────────────────────────────────────────────────────────────────┤\n│                                                                             │\n│   Schema Definitions (YAML)                                                 │\n│   ┌─────────────────────────────────────────────────────────────────────┐   │\n│   │  name: spec-driven                                                  │   │\n│   │  artifacts:                                                         │   │\n│   │    - id: proposal                                                   │   │\n│   │      generates: proposal.md                                         │   │\n│   │      requires: []              ◄── Dependencies                     │   │\n│   │    - id: specs                                                      │   │\n│   │      generates: specs/**/*.md  ◄── Glob patterns                    │   │\n│   │      requires: [proposal]      ◄── Enables after proposal           │   │\n│   └─────────────────────────────────────────────────────────────────────┘   │\n│                    │                                                        │\n│                    ▼                                                        │\n│   Artifact Graph Engine                                                     │\n│   ┌─────────────────────────────────────────────────────────────────────┐   │\n│   │  • Topological sort (dependency ordering)                           │   │\n│   │  • State detection (filesystem existence)                           │   │\n│   │  • Rich instruction generation (templates + context)                │   │\n│   └─────────────────────────────────────────────────────────────────────┘   │\n│                    │                                                        │\n│                    ▼                                                        │\n│   Skill Files (.claude/skills/openspec-*/SKILL.md)                          │\n│                                                                             │\n│   • Cross-editor compatible (Claude Code, Cursor, Windsurf)                 │\n│   • Skills query CLI for structured data                                    │\n│   • Fully customizable via schema files                                     │\n│                                                                             │\n└─────────────────────────────────────────────────────────────────────────────┘\n```\n\n### Dependency Graph Model\n\nArtifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, not gates:\n\n```\n                              proposal\n                             (root node)\n                                  │\n                    ┌─────────────┴─────────────┐\n                    │                           │\n                    ▼                           ▼\n                 specs                       design\n              (requires:                  (requires:\n               proposal)                   proposal)\n                    │                           │\n                    └─────────────┬─────────────┘\n                                  │\n                                  ▼\n                               tasks\n                           (requires:\n                           specs, design)\n                                  │\n                                  ▼\n                          ┌──────────────┐\n                          │ APPLY PHASE  │\n                          │ (requires:   │\n                          │  tasks)      │\n                          └──────────────┘\n```\n\n**State transitions:**\n\n```\n   BLOCKED ────────────────► READY ────────────────► DONE\n      │                        │                       │\n   Missing                  All deps               File exists\n   dependencies             are DONE               on filesystem\n```\n\n### Information Flow\n\n**Legacy workflow** — agent receives static instructions:\n\n```\n  User: \"/openspec:proposal\"\n           │\n           ▼\n  ┌─────────────────────────────────────────┐\n  │  Static instructions:                   │\n  │  • Create proposal.md                   │\n  │  • Create tasks.md                      │\n  │  • Create design.md                     │\n  │  • Create specs/<capability>/spec.md    │\n  │                                         │\n  │  No awareness of what exists or         │\n  │  dependencies between artifacts         │\n  └─────────────────────────────────────────┘\n           │\n           ▼\n  Agent creates ALL artifacts in one go\n```\n\n**OPSX** — agent queries for rich context:\n\n```\n  User: \"/opsx:continue\"\n           │\n           ▼\n  ┌──────────────────────────────────────────────────────────────────────────┐\n  │  Step 1: Query current state                                             │\n  │  ┌────────────────────────────────────────────────────────────────────┐  │\n  │  │  $ openspec status --change \"add-auth\" --json                      │  │\n  │  │                                                                    │  │\n  │  │  {                                                                 │  │\n  │  │    \"artifacts\": [                                                  │  │\n  │  │      {\"id\": \"proposal\", \"status\": \"done\"},                         │  │\n  │  │      {\"id\": \"specs\", \"status\": \"ready\"},      ◄── First ready      │  │\n  │  │      {\"id\": \"design\", \"status\": \"ready\"},                          │  │\n  │  │      {\"id\": \"tasks\", \"status\": \"blocked\", \"missingDeps\": [\"specs\"]}│  │\n  │  │    ]                                                               │  │\n  │  │  }                                                                 │  │\n  │  └────────────────────────────────────────────────────────────────────┘  │\n  │                                                                          │\n  │  Step 2: Get rich instructions for ready artifact                        │\n  │  ┌────────────────────────────────────────────────────────────────────┐  │\n  │  │  $ openspec instructions specs --change \"add-auth\" --json          │  │\n  │  │                                                                    │  │\n  │  │  {                                                                 │  │\n  │  │    \"template\": \"# Specification\\n\\n## ADDED Requirements...\",      │  │\n  │  │    \"dependencies\": [{\"id\": \"proposal\", \"path\": \"...\", \"done\": true}│  │\n  │  │    \"unlocks\": [\"tasks\"]                                            │  │\n  │  │  }                                                                 │  │\n  │  └────────────────────────────────────────────────────────────────────┘  │\n  │                                                                          │\n  │  Step 3: Read dependencies → Create ONE artifact → Show what's unlocked  │\n  └──────────────────────────────────────────────────────────────────────────┘\n```\n\n### Iteration Model\n\n**Legacy workflow** — awkward to iterate:\n\n```\n  ┌─────────┐     ┌─────────┐     ┌─────────┐\n  │/proposal│ ──► │ /apply  │ ──► │/archive │\n  └─────────┘     └─────────┘     └─────────┘\n       │               │\n       │               ├── \"Wait, the design is wrong\"\n       │               │\n       │               ├── Options:\n       │               │   • Edit files manually (breaks context)\n       │               │   • Abandon and start over\n       │               │   • Push through and fix later\n       │               │\n       │               └── No official \"go back\" mechanism\n       │\n       └── Creates ALL artifacts at once\n```\n\n**OPSX** — natural iteration:\n\n```\n  /opsx:new ───► /opsx:continue ───► /opsx:apply ───► /opsx:archive\n      │                │                  │\n      │                │                  ├── \"The design is wrong\"\n      │                │                  │\n      │                │                  ▼\n      │                │            Just edit design.md\n      │                │            and continue!\n      │                │                  │\n      │                │                  ▼\n      │                │         /opsx:apply picks up\n      │                │         where you left off\n      │                │\n      │                └── Creates ONE artifact, shows what's unlocked\n      │\n      └── Scaffolds change, waits for direction\n```\n\n### Custom Schemas\n\nCreate custom workflows using the schema management commands:\n\n```bash\n# Create a new schema from scratch (interactive)\nopenspec schema init my-workflow\n\n# Or fork an existing schema as a starting point\nopenspec schema fork spec-driven my-workflow\n\n# Validate your schema structure\nopenspec schema validate my-workflow\n\n# See where a schema resolves from (useful for debugging)\nopenspec schema which my-workflow\n```\n\nSchemas are stored in `openspec/schemas/` (project-local, version controlled) or `~/.local/share/openspec/schemas/` (user global).\n\n**Schema structure:**\n```\nopenspec/schemas/research-first/\n├── schema.yaml\n└── templates/\n    ├── research.md\n    ├── proposal.md\n    └── tasks.md\n```\n\n**Example schema.yaml:**\n```yaml\nname: research-first\nartifacts:\n  - id: research        # Added before proposal\n    generates: research.md\n    requires: []\n\n  - id: proposal\n    generates: proposal.md\n    requires: [research]  # Now depends on research\n\n  - id: tasks\n    generates: tasks.md\n    requires: [proposal]\n```\n\n**Dependency Graph:**\n```\n   research ──► proposal ──► tasks\n```\n\n### Summary\n\n| Aspect | Legacy | OPSX |\n|--------|----------|------|\n| **Templates** | Hardcoded TypeScript | External YAML + Markdown |\n| **Dependencies** | None (all at once) | DAG with topological sort |\n| **State** | Phase-based mental model | Filesystem existence |\n| **Customization** | Edit source, rebuild | Create schema.yaml |\n| **Iteration** | Phase-locked | Fluid, edit anything |\n| **Editor Support** | Tool-specific configurator/adapters | Single skills directory |\n\n## Schemas\n\nSchemas define what artifacts exist and their dependencies. Currently available:\n\n- **spec-driven** (default): proposal → specs → design → tasks\n\n```bash\n# List available schemas\nopenspec schemas\n\n# See all schemas with their resolution sources\nopenspec schema which --all\n\n# Create a new schema interactively\nopenspec schema init my-workflow\n\n# Fork an existing schema for customization\nopenspec schema fork spec-driven my-workflow\n\n# Validate schema structure before use\nopenspec schema validate my-workflow\n```\n\n## Tips\n\n- Use `/opsx:explore` to think through an idea before committing to a change\n- `/opsx:ff` when you know what you want, `/opsx:continue` when exploring\n- During `/opsx:apply`, if something's wrong — fix the artifact, then continue\n- Tasks track progress via checkboxes in `tasks.md`\n- Check status anytime: `openspec status --change \"name\"`\n\n## Feedback\n\nThis is rough. That's intentional — we're learning what works.\n\nFound a bug? Have ideas? Join us on [Discord](https://discord.gg/YctCnvvshC) or open an issue on [GitHub](https://github.com/Fission-AI/openspec/issues).\n"
  },
  {
    "path": "docs/supported-tools.md",
    "content": "# Supported Tools\n\nOpenSpec works with many AI coding assistants. When you run `openspec init`, OpenSpec configures selected tools using your active profile/workflow selection and delivery mode.\n\n## How It Works\n\nFor each selected tool, OpenSpec can install:\n\n1. **Skills** (if delivery includes skills): `.../skills/openspec-*/SKILL.md`\n2. **Commands** (if delivery includes commands): tool-specific `opsx-*` command files\n\nBy default, OpenSpec uses the `core` profile, which includes:\n- `propose`\n- `explore`\n- `apply`\n- `archive`\n\nYou can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `bulk-archive`, `onboard`) via `openspec config profile`, then run `openspec update`.\n\n## Tool Directory Reference\n\n| Tool (ID) | Skills path pattern | Command path pattern |\n|-----------|---------------------|----------------------|\n| Amazon Q Developer (`amazon-q`) | `.amazonq/skills/openspec-*/SKILL.md` | `.amazonq/prompts/opsx-<id>.md` |\n| Antigravity (`antigravity`) | `.agent/skills/openspec-*/SKILL.md` | `.agent/workflows/opsx-<id>.md` |\n| Auggie (`auggie`) | `.augment/skills/openspec-*/SKILL.md` | `.augment/commands/opsx-<id>.md` |\n| Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/<id>.md` |\n| Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-<id>.md` |\n| CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/<id>.md` |\n| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | `$CODEX_HOME/prompts/opsx-<id>.md`\\* |\n| Continue (`continue`) | `.continue/skills/openspec-*/SKILL.md` | `.continue/prompts/opsx-<id>.prompt` |\n| CoStrict (`costrict`) | `.cospec/skills/openspec-*/SKILL.md` | `.cospec/openspec/commands/opsx-<id>.md` |\n| Crush (`crush`) | `.crush/skills/openspec-*/SKILL.md` | `.crush/commands/opsx/<id>.md` |\n| Cursor (`cursor`) | `.cursor/skills/openspec-*/SKILL.md` | `.cursor/commands/opsx-<id>.md` |\n| Factory Droid (`factory`) | `.factory/skills/openspec-*/SKILL.md` | `.factory/commands/opsx-<id>.md` |\n| Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/<id>.toml` |\n| GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-<id>.prompt.md`\\*\\* |\n| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |\n| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-<id>.md` |\n| Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-<id>.prompt.md` |\n| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |\n| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-<id>.md` |\n| Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/<id>.md` |\n| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.toml` |\n| RooCode (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-<id>.md` |\n| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |\n| Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-<id>.md` |\n\n\\* Codex commands are installed in the global Codex home (`$CODEX_HOME/prompts/` if set, otherwise `~/.codex/prompts/`), not your project directory.\n\n\\*\\* 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.\n\n## Non-Interactive Setup\n\nFor CI/CD or scripted setup, use `--tools` (and optionally `--profile`):\n\n```bash\n# Configure specific tools\nopenspec init --tools claude,cursor\n\n# Configure all supported tools\nopenspec init --tools all\n\n# Skip tool configuration\nopenspec init --tools none\n\n# Override profile for this init run\nopenspec init --profile core\n```\n\n**Available 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`\n\n## Workflow-Dependent Installation\n\nOpenSpec installs workflow artifacts based on selected workflows:\n\n- **Core profile (default):** `propose`, `explore`, `apply`, `archive`\n- **Custom selection:** any subset of all workflow IDs:\n  `propose`, `explore`, `new`, `continue`, `apply`, `ff`, `sync`, `archive`, `bulk-archive`, `verify`, `onboard`\n\nIn other words, skill/command counts are profile-dependent and delivery-dependent, not fixed.\n\n## Generated Skill Names\n\nWhen selected by profile/workflow config, OpenSpec generates these skills:\n\n- `openspec-propose`\n- `openspec-explore`\n- `openspec-new-change`\n- `openspec-continue-change`\n- `openspec-apply-change`\n- `openspec-ff-change`\n- `openspec-sync-specs`\n- `openspec-archive-change`\n- `openspec-bulk-archive-change`\n- `openspec-verify-change`\n- `openspec-onboard`\n\nSee [Commands](commands.md) for command behavior and [CLI](cli.md) for `init`/`update` options.\n\n## Related\n\n- [CLI Reference](cli.md) — Terminal commands\n- [Commands](commands.md) — Slash commands and skills\n- [Getting Started](getting-started.md) — First-time setup\n"
  },
  {
    "path": "docs/workflows.md",
    "content": "# Workflows\n\nThis guide covers common workflow patterns for OpenSpec and when to use each one. For basic setup, see [Getting Started](getting-started.md). For command reference, see [Commands](commands.md).\n\n## Philosophy: Actions, Not Phases\n\nTraditional workflows force you through phases: planning, then implementation, then done. But real work doesn't fit neatly into boxes.\n\nOPSX takes a different approach:\n\n```text\nTraditional (phase-locked):\n\n  PLANNING ────────► IMPLEMENTING ────────► DONE\n      │                    │\n      │   \"Can't go back\"  │\n      └────────────────────┘\n\nOPSX (fluid actions):\n\n  proposal ──► specs ──► design ──► tasks ──► implement\n```\n\n**Key principles:**\n\n- **Actions, not phases** - Commands are things you can do, not stages you're stuck in\n- **Dependencies are enablers** - They show what's possible, not what's required next\n\n> **Customization:** OPSX workflows are driven by schemas that define artifact sequences. See [Customization](customization.md) for details on creating custom schemas.\n\n## Two Modes\n\n### Default Quick Path (`core` profile)\n\nNew installs default to `core`, which provides:\n- `/opsx:propose`\n- `/opsx:explore`\n- `/opsx:apply`\n- `/opsx:archive`\n\nTypical flow:\n\n```text\n/opsx:propose ──► /opsx:apply ──► /opsx:archive\n```\n\n### Expanded/Full Workflow (custom selection)\n\nIf 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:\n\n```bash\nopenspec config profile\nopenspec update\n```\n\n## Workflow Patterns (Expanded Mode)\n\n### Quick Feature\n\nWhen you know what you want to build and just need to execute:\n\n```text\n/opsx:new ──► /opsx:ff ──► /opsx:apply ──► /opsx:verify ──► /opsx:archive\n```\n\n**Example conversation:**\n\n```text\nYou: /opsx:new add-logout-button\n\nAI:  Created openspec/changes/add-logout-button/\n     Ready to create: proposal\n\nYou: /opsx:ff\n\nAI:  Creating all planning artifacts...\n     ✓ proposal.md\n     ✓ specs/\n     ✓ design.md\n     ✓ tasks.md\n     Ready for implementation!\n\nYou: /opsx:apply\n\nAI:  Working through tasks...\n     ✓ Added logout button to header\n     ✓ Implemented session clearing\n     ✓ Added confirmation dialog\n     All tasks complete!\n\nYou: /opsx:archive\n\nAI:  ✓ Merged specs\n     ✓ Archived change\n```\n\n**Best for:** Small to medium features, bug fixes, straightforward changes.\n\n### Exploratory\n\nWhen requirements are unclear or you need to investigate first:\n\n```text\n/opsx:explore ──► /opsx:new ──► /opsx:continue ──► ... ──► /opsx:apply\n```\n\n**Example conversation:**\n\n```text\nYou: /opsx:explore\n\nAI:  What would you like to explore?\n\nYou: I want to improve page load performance but I'm not sure\n     where the bottlenecks are.\n\nAI:  Let me help investigate...\n     [Analyzes bundle sizes, identifies slow queries, reviews\n     component render patterns]\n\n     I found three main bottlenecks:\n     1. Large unoptimized images\n     2. Synchronous data fetching in ProductList\n     3. Re-renders from context changes\n\n     Which would you like to address first?\n\nYou: Let's tackle the data fetching issue.\n\nYou: /opsx:new optimize-product-list-fetching\n\nAI:  Created change. Ready to create: proposal\n\nYou: /opsx:continue\n\nAI:  Creating proposal based on our exploration...\n     [Writes proposal incorporating investigation insights]\n\n     Ready to create: specs, design\n     Which would you like next?\n\nYou: /opsx:continue\n\nAI:  Creating specs...\n```\n\n**Best for:** Performance optimization, debugging, architectural decisions, unclear requirements.\n\n### Parallel Changes\n\nWork on multiple changes at once:\n\n```text\nChange A: /opsx:new ──► /opsx:ff ──► /opsx:apply (in progress)\n                                         │\n                                    context switch\n                                         │\nChange B: /opsx:new ──► /opsx:ff ──────► /opsx:apply\n```\n\n**Example conversation:**\n\n```text\nYou: I'm working on add-dark-mode but got pulled into a bug fix\n\nYou: /opsx:new fix-login-redirect\n\nAI:  Created openspec/changes/fix-login-redirect/\n\nYou: /opsx:ff\n\nAI:  ✓ Created planning artifacts\n\nYou: /opsx:apply\n\nAI:  Working on fix-login-redirect...\n     [Implements the fix]\n     All tasks complete!\n\nYou: /opsx:archive\n\nAI:  ✓ Archived fix-login-redirect\n\nYou: Let's get back to dark mode\n\nYou: /opsx:apply add-dark-mode\n\nAI:  Resuming add-dark-mode...\n     Picking up at task 2.3: Update Header...\n```\n\n**Best for:** Parallel work streams, urgent interrupts, team collaboration.\n\nWhen you have multiple completed changes, use `/opsx:bulk-archive`:\n\n```text\nYou: /opsx:bulk-archive\n\nAI:  Found 3 completed changes:\n     - add-dark-mode (all tasks done)\n     - fix-login-redirect (all tasks done)\n     - update-footer (all tasks done)\n\n     Checking for spec conflicts...\n     ⚠ add-dark-mode and update-footer both touch specs/ui/\n\n     Inspecting codebase to resolve...\n     Both changes are implemented. Will apply in chronological order.\n\n     Archive all 3 changes?\n\nYou: Yes\n\nAI:  ✓ Archived add-dark-mode\n     ✓ Archived fix-login-redirect\n     ✓ Archived update-footer\n     Specs merged in order: add-dark-mode → update-footer\n```\n\nBulk archive detects when multiple changes touch the same specs and resolves conflicts by checking what's actually implemented.\n\n### Completing a Change\n\nThe recommended completion flow:\n\n```text\n/opsx:apply ──► /opsx:verify ──► /opsx:archive\n                    │                 │\n              validates          prompts to sync\n              implementation     if needed\n```\n\n#### Verify: Check Your Work\n\n`/opsx:verify` validates implementation against your artifacts across three dimensions:\n\n```text\nYou: /opsx:verify\n\nAI:  Verifying add-auth...\n\n     COMPLETENESS\n     ✓ All 12 tasks in tasks.md are checked\n     ✓ All requirements in specs have corresponding code\n     ⚠ Scenario \"Session timeout after inactivity\" not tested\n\n     CORRECTNESS\n     ✓ Implementation matches spec intent\n     ✓ Edge cases from scenarios are handled\n     ✓ Error states match spec definitions\n\n     COHERENCE\n     ✓ Design decisions reflected in code structure\n     ✓ Naming conventions consistent with design.md\n     ⚠ Design mentions \"event-driven\" but implementation uses polling\n\n     SUMMARY\n     ─────────────────────────────\n     Critical issues: 0\n     Warnings: 2\n     Ready to archive: Yes (with warnings)\n\n     Recommendations:\n     1. Add test for session timeout scenario\n     2. Consider refactoring to event-driven as designed, or update design.md\n```\n\n**What verify checks:**\n\n| Dimension | What it validates |\n|-----------|------------------|\n| Completeness | All tasks done, all requirements implemented, scenarios covered |\n| Correctness | Implementation matches spec intent, edge cases handled |\n| Coherence | Design decisions reflected in code, patterns consistent |\n\nVerify won't block archive, but it surfaces issues you might want to address first.\n\n#### Archive: Finalize the Change\n\n`/opsx:archive` completes the change and moves it to the archive:\n\n```text\nYou: /opsx:archive\n\nAI:  Archiving add-auth...\n\n     Artifact status:\n     ✓ proposal.md exists\n     ✓ specs/ exists\n     ✓ design.md exists\n     ✓ tasks.md exists (12/12 tasks complete)\n\n     Delta specs: Not yet synced to main\n     → Would you like to sync now?\n\nYou: Yes\n\nAI:  ✓ Synced specs to openspec/specs/auth/spec.md\n     ✓ Moved to openspec/changes/archive/2025-01-24-add-auth/\n\n     Change archived successfully.\n```\n\nArchive will prompt if specs aren't synced. It won't block on incomplete tasks, but it will warn you.\n\n## When to Use What\n\n### `/opsx:ff` vs `/opsx:continue`\n\n| Situation | Use |\n|-----------|-----|\n| Clear requirements, ready to build | `/opsx:ff` |\n| Exploring, want to review each step | `/opsx:continue` |\n| Want to iterate on proposal before specs | `/opsx:continue` |\n| Time pressure, need to move fast | `/opsx:ff` |\n| Complex change, want control | `/opsx:continue` |\n\n**Rule of thumb:** If you can describe the full scope upfront, use `/opsx:ff`. If you're figuring it out as you go, use `/opsx:continue`.\n\n### When to Update vs Start Fresh\n\nA common question: when is updating an existing change okay, and when should you start a new one?\n\n**Update the existing change when:**\n\n- Same intent, refined execution\n- Scope narrows (MVP first, rest later)\n- Learning-driven corrections (codebase isn't what you expected)\n- Design tweaks based on implementation discoveries\n\n**Start a new change when:**\n\n- Intent fundamentally changed\n- Scope exploded to different work entirely\n- Original change can be marked \"done\" standalone\n- Patches would confuse more than clarify\n\n```text\n                     ┌─────────────────────────────────────┐\n                     │     Is this the same work?          │\n                     └──────────────┬──────────────────────┘\n                                    │\n                 ┌──────────────────┼──────────────────┐\n                 │                  │                  │\n                 ▼                  ▼                  ▼\n          Same intent?      >50% overlap?      Can original\n          Same problem?     Same scope?        be \"done\" without\n                 │                  │          these changes?\n                 │                  │                  │\n       ┌────────┴────────┐  ┌──────┴──────┐   ┌───────┴───────┐\n       │                 │  │             │   │               │\n      YES               NO YES           NO  NO              YES\n       │                 │  │             │   │               │\n       ▼                 ▼  ▼             ▼   ▼               ▼\n    UPDATE            NEW  UPDATE       NEW  UPDATE          NEW\n```\n\n**Example: \"Add dark mode\"**\n\n- \"Need to also support custom themes\" → New change (scope exploded)\n- \"System preference detection is harder than expected\" → Update (same intent)\n- \"Let's ship toggle first, add preferences later\" → Update then archive, then new change\n\n## Best Practices\n\n### Keep Changes Focused\n\nOne logical unit of work per change. If you're doing \"add feature X and also refactor Y\", consider two separate changes.\n\n**Why it matters:**\n- Easier to review and understand\n- Cleaner archive history\n- Can ship independently\n- Simpler rollback if needed\n\n### Use `/opsx:explore` for Unclear Requirements\n\nBefore committing to a change, explore the problem space:\n\n```text\nYou: /opsx:explore\n\nAI:  What would you like to explore?\n\nYou: How should we handle rate limiting for our API?\n\nAI:  Let me investigate your current setup and options...\n     [Researches codebase, considers patterns]\n\n     I see three approaches we could take:\n     1. Middleware-based (simple, per-endpoint)\n     2. Redis-backed (scalable, shared state)\n     3. Token bucket per user (fair, complex)\n\n     Your current stack suggests #1 or #2. What's your scale?\n```\n\nExploration clarifies thinking before you create artifacts.\n\n### Verify Before Archiving\n\nUse `/opsx:verify` to check implementation matches artifacts:\n\n```text\nYou: /opsx:verify\n\nAI:  Verifying add-dark-mode...\n\n     ✓ All tasks in tasks.md are checked\n     ✓ Requirements in specs have corresponding code\n     ✓ Design decisions are reflected in implementation\n\n     Ready to archive!\n```\n\nCatches mismatches before you close out the change.\n\n### Name Changes Clearly\n\nGood names make `openspec list` useful:\n\n```text\nGood:                          Avoid:\nadd-dark-mode                  feature-1\nfix-login-redirect             update\noptimize-product-query         changes\nimplement-2fa                  wip\n```\n\n## Command Quick Reference\n\nFor full command details and options, see [Commands](commands.md).\n\n| Command | Purpose | When to Use |\n|---------|---------|-------------|\n| `/opsx:propose` | Create change + planning artifacts | Fast default path (`core` profile) |\n| `/opsx:explore` | Think through ideas | Unclear requirements, investigation |\n| `/opsx:new` | Start a change scaffold | Expanded mode, explicit artifact control |\n| `/opsx:continue` | Create next artifact | Expanded mode, step-by-step artifact creation |\n| `/opsx:ff` | Create all planning artifacts | Expanded mode, clear scope |\n| `/opsx:apply` | Implement tasks | Ready to write code |\n| `/opsx:verify` | Validate implementation | Expanded mode, before archiving |\n| `/opsx:sync` | Merge delta specs | Expanded mode, optional |\n| `/opsx:archive` | Complete the change | All work finished |\n| `/opsx:bulk-archive` | Archive multiple changes | Expanded mode, parallel work |\n\n## Next Steps\n\n- [Commands](commands.md) - Full command reference with options\n- [Concepts](concepts.md) - Deep dive into specs, artifacts, and schemas\n- [Customization](customization.md) - Create custom workflows\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import tseslint from 'typescript-eslint';\n\nexport default tseslint.config(\n  {\n    files: ['src/**/*.ts'],\n    extends: [...tseslint.configs.recommended],\n    rules: {\n      // Prevent static imports of @inquirer modules to avoid pre-commit hook hangs.\n      // These modules have side effects that can keep the Node.js event loop alive\n      // when stdin is piped. Use dynamic import() instead.\n      // See: https://github.com/Fission-AI/OpenSpec/issues/367\n      'no-restricted-imports': [\n        'error',\n        {\n          patterns: [\n            {\n              group: ['@inquirer/*'],\n              message:\n                'Use dynamic import() for @inquirer modules to prevent pre-commit hook hangs. See #367.',\n            },\n          ],\n        },\n      ],\n      // Disable rules that need broader cleanup - focus on critical issues only\n      '@typescript-eslint/no-explicit-any': 'off',\n      '@typescript-eslint/no-unused-vars': 'off',\n      'no-empty': 'off',\n      'prefer-const': 'off',\n    },\n  },\n  {\n    // init.ts is dynamically imported from cli/index.ts, so static @inquirer\n    // imports there are safe - they won't be loaded at CLI startup\n    files: ['src/core/init.ts'],\n    rules: {\n      'no-restricted-imports': 'off',\n    },\n  },\n  {\n    ignores: ['dist/**', 'node_modules/**', '*.js', '*.mjs'],\n  }\n);\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"OpenSpec - AI-native system for spec-driven development\";\n\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n  };\n\n  outputs =\n    { self, nixpkgs }:\n    let\n      supportedSystems = [\n        \"x86_64-linux\"\n        \"aarch64-linux\"\n        \"x86_64-darwin\"\n        \"aarch64-darwin\"\n      ];\n\n      forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);\n    in\n    {\n      packages = forAllSystems (\n        system:\n        let\n          pkgs = nixpkgs.legacyPackages.${system};\n          inherit (pkgs) lib;\n        in\n        {\n          default = pkgs.stdenv.mkDerivation (finalAttrs: {\n            pname = \"openspec\";\n            version = (builtins.fromJSON (builtins.readFile ./package.json)).version;\n\n            src = lib.fileset.toSource {\n              root = ./.;\n              fileset = lib.fileset.unions [\n                ./src\n                ./bin\n                ./schemas\n                ./scripts\n                ./test\n                ./package.json\n                ./pnpm-lock.yaml\n                ./tsconfig.json\n                ./build.js\n                ./vitest.config.ts\n                ./vitest.setup.ts\n                ./eslint.config.js\n              ];\n            };\n\n            pnpmDeps = pkgs.fetchPnpmDeps {\n              inherit (finalAttrs) pname version src;\n              pnpm = pkgs.pnpm_9;\n              fetcherVersion = 3;\n              hash = \"sha256-9s2kdvd7svK4hofnD66HkDc86WTQeayfF5y7L2dmjNg=\";\n            };\n\n            nativeBuildInputs = with pkgs; [\n              nodejs_20\n              npmHooks.npmInstallHook\n              pnpmConfigHook\n              pnpm_9\n            ];\n\n            buildPhase = ''\n              runHook preBuild\n\n              pnpm run build\n\n              runHook postBuild\n            '';\n\n            dontNpmPrune = true;\n\n            meta = with pkgs.lib; {\n              description = \"AI-native system for spec-driven development\";\n              homepage = \"https://github.com/Fission-AI/OpenSpec\";\n              license = licenses.mit;\n              maintainers = [ ];\n              mainProgram = \"openspec\";\n            };\n          });\n        }\n      );\n\n      apps = forAllSystems (system: {\n        default = {\n          type = \"app\";\n          program = \"${self.packages.${system}.default}/bin/openspec\";\n        };\n      });\n\n      devShells = forAllSystems (\n        system:\n        let\n          pkgs = nixpkgs.legacyPackages.${system};\n        in\n        {\n          default = pkgs.mkShell {\n            buildInputs = with pkgs; [\n              nodejs_20\n              pnpm_9\n            ];\n\n            shellHook = ''\n              echo \"OpenSpec development environment\"\n              echo \"Node version: $(node --version)\"\n              echo \"pnpm version: $(pnpm --version)\"\n              echo \"Run 'pnpm install' to install dependencies\"\n            '';\n          };\n        }\n      );\n    };\n}\n"
  },
  {
    "path": "openspec/changes/IMPLEMENTATION_ORDER.md",
    "content": "# Implementation Order and Dependencies\n\n## Required Implementation Sequence\n\nThe following changes must be implemented in this specific order due to dependencies:\n\n### Phase 1: Foundation\n**1. add-zod-validation** (No dependencies)\n- Creates all core schemas (RequirementSchema, ScenarioSchema, SpecSchema, ChangeSchema, DeltaSchema)\n- Implements markdown parser utilities\n- Implements validation infrastructure and rules\n- Establishes validation patterns used by all commands\n- Must be completed first\n\n### Phase 2: Change Commands\n**2. add-change-commands** (Depends on: add-zod-validation)\n- Imports ChangeSchema and DeltaSchema from zod validation\n- Reuses markdown parsing utilities\n- Implements change command with built-in validation\n- Uses validation infrastructure for change validate subcommand\n- Cannot start until schemas and validation exist\n\n### Phase 3: Spec Commands\n**3. add-spec-commands** (Depends on: add-zod-validation, add-change-commands)\n- Imports RequirementSchema, ScenarioSchema, SpecSchema from zod validation\n- Reuses markdown parsing utilities\n- Implements spec command with built-in validation\n- Uses validation infrastructure for spec validate subcommand\n- Builds on patterns established by change commands\n\n## Dependency Graph\n```\nadd-zod-validation\n    ↓\nadd-change-commands\n    ↓\nadd-spec-commands\n```\n\n## Key Dependencies\n\n### Shared Code Dependencies\n1. **Schemas**: All schemas created in add-zod-validation, used by both command implementations\n2. **Validation**: Infrastructure created in add-zod-validation, integrated into both commands\n3. **Parsers**: Markdown parsing utilities created in add-zod-validation, used by both commands\n\n### File Dependencies\n- `src/core/schemas/*.schema.ts` (created by add-zod-validation) → imported by both commands\n- `src/core/validation/validator.ts` (created by add-zod-validation) → used by both commands\n- `src/core/parsers/markdown-parser.ts` (created by add-zod-validation) → used by both commands\n\n## Implementation Notes\n\n### For Developers\n1. Complete each phase fully before moving to the next\n2. Run tests after each phase to ensure stability\n3. The legacy `list` command remains functional throughout\n\n### For CI/CD\n1. Each change can be validated independently\n2. Integration tests should run after each phase\n3. Full system tests required after Phase 3\n\n### Parallel Work Opportunities\nWithin each phase, the following can be done in parallel:\n- **Phase 1**: Schema design, validation rules, and parser implementation\n- **Phase 2**: Change command features and legacy compatibility work\n- **Phase 3**: Spec command features and final integration"
  },
  {
    "path": "openspec/changes/add-artifact-regeneration-support/proposal.md",
    "content": "# Add Artifact Regeneration Support\n\n## Problem\n\nCurrently, there is **no way to regenerate artifacts** in the OPSX workflow:\n\n- `/opsx:apply` just reads whatever's on disk\n- `/opsx:continue` only creates the NEXT artifact - won't touch existing ones\n\nIf you edit `design.md` after `tasks.md` exists, your only options are:\n1. Delete tasks.md manually, then run `/opsx:continue`\n2. Edit tasks.md manually\n\nThe documentation claims you can \"update artifacts mid-flight and continue\" but there's no mechanism that actually supports this.\n\n## Proposed Solution\n\nTwo parts:\n\n### Part 1: Staleness Detection\nAdd artifact staleness detection to `/opsx:apply`:\n\n1. **Track modification times**: When generating an artifact, record the mtime of its dependencies\n2. **Detect staleness**: When `/opsx:apply` runs, check if upstream artifacts (design.md, specs) have been modified since tasks.md was generated\n3. **Prompt user**: If stale, ask: \"Design was modified after tasks were generated. Would you like to regenerate tasks with `/opsx:continue`?\"\n\n## User Experience\n\n### Vision: Seamless Mid-Flight Correction\n\nThis is the workflow we want to enable (currently documented but not supported):\n\n```\nYou: /opsx:apply\n\nAI:  Working through tasks...\n     ✓ Task 1.1: Created caching layer\n     ✓ Task 1.2: Added cache invalidation\n\n     Working on 1.3: Implement TTL...\n     I noticed the design assumes Redis, but your project uses\n     in-memory caching. Should I update the design?\n\nYou: Yes, update it to use the existing cache module.\n\nAI:  Updated design.md to use CacheManager from src/cache/\n     Updated tasks.md with revised implementation steps\n     Continuing implementation...\n     ✓ Task 1.3: Implemented TTL using CacheManager\n     ...\n```\n\n**No restart needed.** Just update the artifact and continue.\n\n### Staleness Warning UX\n\nWhen user manually edits an upstream artifact:\n\n```\n$ /opsx:apply\n\n⚠️  Detected changes to upstream artifacts:\n    - design.md modified 5 minutes ago (after tasks.md was generated)\n\nOptions:\n1. Regenerate tasks (recommended)\n2. Continue anyway with current tasks\n3. Cancel\n\n>\n```\n\n### Part 2: Regeneration Capability\n\nAdd a way to regenerate specific artifacts:\n\n```bash\n# Option A: Flag on continue\n/opsx:continue --regenerate tasks\n\n# Option B: Separate command\n/opsx:regenerate tasks\n\n# Option C: Interactive prompt when staleness detected\n/opsx:apply\n# \"Design changed. Regenerate tasks? [y/N]\"\n```\n\n## Technical Approach\n\n### Option A: Metadata File\nStore `.openspec-meta.json` in change directory:\n```json\n{\n  \"tasks.md\": {\n    \"generated_at\": \"2025-01-24T10:00:00Z\",\n    \"dependencies\": {\n      \"design.md\": \"2025-01-24T09:55:00Z\",\n      \"specs/feature/spec.md\": \"2025-01-24T09:50:00Z\"\n    }\n  }\n}\n```\n\n### Option B: Frontmatter\nAdd YAML frontmatter to generated artifacts:\n```markdown\n---\ngenerated_at: 2025-01-24T10:00:00Z\ndepends_on:\n  - design.md@2025-01-24T09:55:00Z\n---\n# Tasks\n...\n```\n\n### Option C: Git-based\nUse git to detect if upstream files changed since downstream was last modified. No extra metadata needed but requires git.\n\n## Non-Goals\n\n- Automatic regeneration (user should always choose)\n- Blocking apply entirely (just warn)\n- Tracking code file changes (only artifact dependencies)\n\n## Dependencies\n\n- Should be implemented after `fix-midflight-update-docs` so docs are accurate first\n- Could be combined with that change if desired\n\n## Success Criteria\n\n- User is warned when applying with stale artifacts\n- Clear path to regenerate if needed\n- No false positives (only warn when genuinely stale)\n- Documentation claims become actually true\n"
  },
  {
    "path": "openspec/changes/add-change-stacking-awareness/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-02-21\n"
  },
  {
    "path": "openspec/changes/add-change-stacking-awareness/proposal.md",
    "content": "## Why\n\nParallel changes often touch the same capabilities and `cli-init`/`cli-update` behavior, but today there is no machine-readable way to express sequencing, dependencies, or expected merge order.\n\nThis creates three recurring problems:\n\n- teams cannot tell which change should land first\n- large changes are hard to split into safe mergeable slices\n- parallel work can accidentally reintroduce assumptions already removed by another change\n\nWe need lightweight planning metadata and CLI guidance so contributors can safely stack plans on top of each other.\n\n## What Changes\n\n### 1. Add lightweight stack metadata for changes\n\nExtend change metadata to support sequencing and decomposition context, for example:\n\n- `dependsOn`: changes that must land first\n- `provides`: capability markers exposed by this change\n- `requires`: capability markers needed by this change\n- `touches`: capability/spec areas likely affected (advisory only; warning signal, not a hard dependency)\n- `parent`: optional parent change for split work\n\nMetadata is optional and backward compatible for existing changes.\n\nOrdering semantics:\n\n- `dependsOn` is the source of truth for execution/archive ordering\n- `provides`/`requires` are capability contracts for validation and planning visibility\n- `provides`/`requires` do not create implicit dependency edges; authors must still declare required ordering via `dependsOn`\n\n### 2. Add stack-aware validation\n\nEnhance change validation to detect planning issues early:\n\n- missing dependencies\n- dependency cycles\n- archive ordering violations (for example, attempting to archive a change before all `dependsOn` predecessors are archived)\n- unmatched capability markers (for example, `requires` marker with no provider in active history emits non-blocking warning)\n- overlap warnings when active changes touch the same capability\n\nValidation should fail only for deterministic blockers (for example cycles or missing required dependencies), and keep overlap checks as actionable warnings.\n\n### 3. Add sequencing visibility commands\n\nAdd lightweight CLI support to inspect and execute plan order:\n\n- `openspec change graph` to show dependency DAG/order\n- `openspec change graph` validates for cycles first; when cycles are present it fails with the same deterministic cycle error as stack-aware validation\n- `openspec change next` to suggest unblocked changes ready to implement/archive\n\n### 4. Add split scaffolding for large changes\n\nAdd helper workflow to decompose large proposals into stackable slices:\n\n- `openspec change split <change-id>` scaffolds child changes with `parent` + `dependsOn`\n- generates minimal proposal/tasks stubs for each child slice\n- converts the source change into a parent planning container (no duplicate child implementation tasks)\n- re-running split for an already-split source change returns a deterministic actionable error unless `--overwrite` (alias `--force`) is passed\n- `--overwrite` / `--force` fully regenerates managed child scaffold stubs and metadata links for the split, replacing prior scaffold content\n\n### 5. Document stack-first workflow\n\nUpdate docs to describe:\n\n- how to model dependencies and parent/child slices\n- when to split a large change\n- how to use graph/next validation signals during parallel development\n- migration guidance for `openspec/changes/IMPLEMENTATION_ORDER.md`:\n  - machine-readable change metadata becomes the normative dependency source\n  - `IMPLEMENTATION_ORDER.md` remains optional narrative context during transition\n\n## Capabilities\n\n### New Capabilities\n\n- `change-stacking-workflow`: Dependency-aware sequencing and split scaffolding for change planning\n\n### Modified Capabilities\n\n- `cli-change`: Adds graph/next/split planning commands and stack-aware validation messaging\n- `change-creation`: Supports parent/dependency metadata when creating or splitting changes\n- `openspec-conventions`: Defines optional stack metadata conventions for change proposals\n\n## Impact\n\n- `src/core/project-config.ts` and related parsing/validation utilities for change metadata loading\n- `src/core/config-schema.ts` (or dedicated change schema) for stack metadata validation\n- `src/commands/change.ts` and/or `src/core/list.ts` for graph/next/split command behavior\n- `src/core/validation/*` for dependency cycle and overlap checks\n- `docs/cli.md`, `docs/concepts.md`, and contributor guidance for stack-aware workflows\n- tests for metadata parsing, graph ordering, next-item suggestions, and split scaffolding\n"
  },
  {
    "path": "openspec/changes/add-change-stacking-awareness/specs/change-creation/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Stack Metadata Scaffolding\nChange creation workflows SHALL support optional dependency metadata for new or split changes.\n\n#### Scenario: Create change with stack metadata\n- **WHEN** a change is created with stack metadata inputs\n- **THEN** creation SHALL persist metadata fields in change configuration\n- **AND** persisted metadata SHALL be validated against change metadata schema rules\n\n#### Scenario: Split-generated child metadata\n- **WHEN** child changes are generated from a split workflow\n- **THEN** each child SHALL include a `parent` link to the source change\n- **AND** SHALL include dependency metadata needed for deterministic sequencing\n\n"
  },
  {
    "path": "openspec/changes/add-change-stacking-awareness/specs/change-stacking-workflow/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Stack Metadata Model\nThe system SHALL support optional metadata on active changes to express sequencing and decomposition relationships.\n\n#### Scenario: Optional stack metadata is present\n- **WHEN** a change includes stack metadata fields\n- **THEN** the system SHALL parse and expose `dependsOn`, `provides`, `requires`, `touches`, and `parent`\n- **AND** validation SHALL enforce normalized field shapes and value types (`dependsOn`/`provides`/`requires`/`touches` as string arrays, `parent` as string when present)\n\n#### Scenario: Backward compatibility without stack metadata\n- **WHEN** a change does not include stack metadata\n- **THEN** existing behavior SHALL continue without migration steps\n- **AND** validation SHALL not fail solely because stack metadata is absent\n\n### Requirement: Change Dependency Graph\nThe system SHALL provide dependency-aware ordering for active changes.\n\n#### Scenario: Build dependency order\n- **WHEN** users request stack planning output\n- **THEN** the system SHALL compute a dependency graph across active changes\n- **AND** SHALL return a deterministic topological order for unblocked changes\n\n#### Scenario: Tie-breaking within the same dependency depth\n- **WHEN** multiple unblocked changes share the same topological dependency depth\n- **THEN** ordering SHALL break ties lexicographically by change ID\n- **AND** repeated runs over the same input SHALL return the same order\n\n#### Scenario: Dependency cycle detection\n- **WHEN** active changes contain a dependency cycle\n- **THEN** validation SHALL fail with cycle details before archive or sequencing actions proceed\n- **AND** output SHALL include actionable guidance to break the cycle\n\n### Requirement: Capability marker and overlap semantics\nThe system SHALL treat capability markers as validation contracts and `touches` as advisory overlap signals.\n\n#### Scenario: Required capability provided by an active change\n- **WHEN** change B declares `requires` marker `X`\n- **AND** active change A declares `provides` marker `X`\n- **THEN** validation SHALL require B to declare an explicit ordering edge in `dependsOn` to at least one active provider of `X`\n- **AND** validation SHALL fail if no explicit dependency is declared\n\n#### Scenario: Requires marker without active provider\n- **WHEN** a change declares a `requires` marker\n- **AND** no active change declares the corresponding `provides` marker\n- **THEN** validation SHALL NOT infer an implicit dependency edge\n- **AND** ordering SHALL continue to be determined solely by explicit `dependsOn` relationships\n\n#### Scenario: Requires marker satisfied by archived history\n- **WHEN** a change declares a `requires` marker\n- **AND** no active change provides that marker\n- **AND** at least one archived change in history provides that marker\n- **THEN** validation SHALL NOT warn solely about missing provider\n- **AND** SHALL continue to use explicit `dependsOn` for active ordering\n\n#### Scenario: Requires marker missing in full history\n- **WHEN** a change declares a `requires` marker\n- **AND** no active or archived change in history provides that marker\n- **THEN** validation SHALL emit a non-blocking warning naming the change and missing marker\n- **AND** SHALL NOT infer an implicit dependency edge\n\n#### Scenario: Overlap warning for shared touches\n- **WHEN** multiple active changes declare overlapping `touches` values\n- **THEN** validation SHALL emit a warning listing the overlapping changes and touched areas\n- **AND** validation SHALL NOT fail solely on overlap\n"
  },
  {
    "path": "openspec/changes/add-change-stacking-awareness/specs/cli-change/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Stack Planning Commands\nThe change CLI SHALL provide commands for dependency-aware sequencing of active changes.\n\n#### Scenario: Show dependency graph\n- **WHEN** a user runs `openspec change graph`\n- **THEN** the CLI SHALL display dependency relationships for active changes\n- **AND** SHALL include a deterministic recommended order for execution\n\n#### Scenario: Show next unblocked changes\n- **WHEN** a user runs `openspec change next`\n- **THEN** the CLI SHALL list changes that are not blocked by unresolved dependencies\n- **AND** SHALL use deterministic tie-breaking when multiple options are available\n\n### Requirement: Split Large Change Scaffolding\nThe change CLI SHALL support scaffolding child slices from an existing large change.\n\n#### Scenario: Split command scaffolds child changes\n- **WHEN** a user runs `openspec change split <change-id>`\n- **THEN** the CLI SHALL create child change directories with proposal/tasks stubs\n- **AND** generated metadata SHALL include `parent` and dependency links back to the source change\n\n#### Scenario: Re-running split on an already-split change\n- **WHEN** a user runs `openspec change split <change-id>` for a parent whose generated child directories already exist\n- **THEN** the CLI SHALL fail with a deterministic, actionable error\n- **AND** SHALL NOT mutate existing child change content unless an explicit overwrite mode is requested\n"
  },
  {
    "path": "openspec/changes/add-change-stacking-awareness/specs/openspec-conventions/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Stack-Aware Change Planning Conventions\nOpenSpec conventions SHALL define optional metadata fields for sequencing and decomposition across concurrent changes.\n\n#### Scenario: Declaring change dependencies\n- **WHEN** authors need to sequence related changes\n- **THEN** conventions SHALL define how to declare dependencies and provided/required capability markers\n- **AND** validation guidance SHALL distinguish hard blockers from soft overlap warnings\n\n#### Scenario: Dependency source of truth during migration\n- **WHEN** both stack metadata and `openspec/changes/IMPLEMENTATION_ORDER.md` are present\n- **THEN** conventions SHALL treat per-change stack metadata as the normative dependency source\n- **AND** `IMPLEMENTATION_ORDER.md` SHALL be treated as optional narrative guidance\n\n#### Scenario: Explicit ordering remains required for capability markers\n- **WHEN** authors use `provides` and `requires` markers to describe capability contracts\n- **THEN** conventions SHALL require explicit `dependsOn` edges for ordering relationships\n- **AND** conventions SHALL prohibit treating `requires` as an implicit dependency edge\n\n#### Scenario: Declaring advisory overlap via touches\n- **WHEN** a change may affect capability/spec areas shared by concurrent changes without requiring ordering\n- **THEN** conventions SHALL allow authors to declare `touches` with advisory area identifiers (for example capability IDs, spec area names, or paths)\n- **AND** tooling SHALL treat `touches` as informational only (no implicit dependency edge, non-blocking validation signal)\n\n#### Scenario: Declaring parent-child split structure\n- **WHEN** a large change is decomposed into smaller slices\n- **THEN** conventions SHALL define parent-child metadata and expected ordering semantics\n- **AND** docs SHALL describe when to split versus keep a single change\n"
  },
  {
    "path": "openspec/changes/add-change-stacking-awareness/tasks.md",
    "content": "## 1. Metadata Model\n\n- [ ] 1.1 Add optional stack metadata fields (`dependsOn`, `provides`, `requires`, `touches`, `parent`) to change metadata schema\n- [ ] 1.2 Keep metadata backward compatible for existing changes without new fields\n- [ ] 1.3 Add tests for valid/invalid metadata and schema evolution behavior\n\n## 2. Stack-Aware Validation\n\n- [ ] 2.1 Detect dependency cycles and fail validation with deterministic errors\n- [ ] 2.2 Detect missing `dependsOn` targets (referenced change ID does not exist) and detect changes transitively blocked by unresolved/cyclic dependency paths\n- [ ] 2.3 Add overlap warnings for active changes that touch the same capability/spec areas\n- [ ] 2.4 Emit advisory warnings for unmatched `requires` markers when no provider exists in active history\n- [ ] 2.5 Add tests for cycle, missing dependency, overlap warning, and unmatched `requires` cases\n\n## 3. Sequencing Commands\n\n- [ ] 3.1 Add `openspec change graph` to display dependency order for active changes\n- [ ] 3.2 Add `openspec change next` to suggest unblocked changes in recommended order\n- [ ] 3.3 Add tests for topological ordering and deterministic tie-breaking (lexicographic by change ID at equal depth)\n\n## 4. Split Scaffolding\n\n- [ ] 4.1 Add `openspec change split <change-id>` to scaffold child slices\n- [ ] 4.2 Ensure generated children include parent/dependency metadata and stub proposal/tasks files\n- [ ] 4.3 Convert the source change into a parent planning container as part of split (no duplicate child implementation tasks)\n- [ ] 4.4 Add tests for split output structure, source-change parent conversion, and deterministic re-split error behavior when overwrite mode is not requested\n- [ ] 4.5 Implement and test explicit overwrite mode for `openspec change split` (`--overwrite` / `--force`) for controlled re-splitting\n\n## 5. Documentation\n\n- [ ] 5.1 Document stack metadata and sequencing workflow in `docs/concepts.md`\n- [ ] 5.2 Document new change commands and usage examples in `docs/cli.md`\n- [ ] 5.3 Add guidance for breaking large changes into independently mergeable slices\n- [ ] 5.4 Document migration guidance for `openspec/changes/IMPLEMENTATION_ORDER.md` as optional narrative, not dependency source of truth\n\n## 6. Verification\n\n- [ ] 6.1 Run targeted tests for change parsing, validation, and CLI commands\n- [ ] 6.2 Run full test suite (`pnpm test`) and resolve regressions\n"
  },
  {
    "path": "openspec/changes/add-global-install-scope/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-02-21\n"
  },
  {
    "path": "openspec/changes/add-global-install-scope/design.md",
    "content": "## Context\n\nOpenSpec today assumes project-local installation for most generated artifacts, with Codex command prompts as the main global exception. This mixed model works, but it is implicit and not user-configurable.\n\nThe requested change is to support user-selectable install scope (`global` or `project`) for tool skills/commands, defaulting to `global` for new configurations while preserving legacy project-local behavior until explicit migration.\n\n## Goals / Non-Goals\n\n**Goals:**\n\n- Provide a single scope preference that users can set globally and override per run\n- Default new users to `global` scope\n- Make install path resolution deterministic and explicit across tools/surfaces\n- Preserve current behavior for users with older config files that do not yet define `installScope`\n- Avoid silent partial installs; surface effective scope decisions in output\n\n**Non-Goals:**\n\n- Implementing project-local config file support for global settings\n- Defining global install paths for tools where upstream location conventions are unknown\n- Changing workflow/profile semantics (`core`, `custom`, `delivery`) in this change\n\n## Decisions\n\n### 1. Scope model in global config\n\nAdd install scope preference to global config:\n\n```ts\ntype InstallScope = 'global' | 'project';\n\ninterface GlobalConfig {\n  // existing fields...\n  installScope?: InstallScope;\n}\n```\n\nDefaults:\n\n- New configs SHOULD write `installScope: global` explicitly.\n- Existing configs without this field continue to load safely through schema evolution and SHALL resolve effective default as `project` until users explicitly set `installScope`.\n\n### 2. Explicit tool scope support metadata\n\nExtend `AI_TOOLS` metadata with optional scope support declarations per surface:\n\n```ts\ninterface ToolInstallScopeSupport {\n  skills?: InstallScope[];\n  commands?: InstallScope[];\n}\n```\n\nResolution rules:\n\n1. If scope support metadata is absent for a tool surface, treat it as project-only support for conservative backward compatibility.\n2. Try preferred scope.\n3. If unsupported, use alternate scope when supported.\n4. If neither is supported, fail with actionable error.\n\nThis enables default-global behavior while remaining safe for tools that only support project-local paths.\n\n### 3. Scope-aware install target resolver\n\nIntroduce shared resolver utilities to compute effective target paths for:\n\n- skills root directory\n- command output files\n\nResolver input:\n\n- tool id\n- requested scope\n- project root\n- environment context (`CODEX_HOME`, etc.)\n\nResolver output:\n\n- effective scope per surface\n- concrete target paths\n- optional fallback reasons for user-facing output\n\nPlatform behavior:\n\n- Resolver outputs are OS-aware and normalized for the current platform.\n- Windows global targets MUST use Windows path conventions (for example `%USERPROFILE%\\.codex\\prompts` fallback for Codex when `CODEX_HOME` is unset), not POSIX defaults.\n\n### 4. Context-aware command adapter paths\n\nUpdate command generation contract so adapters receive install context for path resolution. This avoids hardcoded absolute/relative assumptions and centralizes scope decisions.\n\nExample direction:\n\n```ts\ngetFilePath(commandId: string, context: InstallContext): string\n```\n\n### 5. CLI behavior and UX\n\n`init`:\n\n- Uses configured install scope by default; if absent in a legacy config, uses migration-safe effective default (`project`).\n- Supports explicit override flag (`--scope global|project`).\n- In interactive mode, displays chosen scope and any per-tool fallback decisions before writing files.\n\n`update`:\n\n- Applies current scope preference (or override); if absent in a legacy config, uses migration-safe effective default (`project`).\n- Performs drift detection using effective scoped paths and last-applied scope state.\n- Reports effective scope decisions in summary output.\n\n`config`:\n\n- `openspec config profile` interactive flow includes install scope selection.\n- `openspec config list` shows `installScope` with source annotation (`explicit`, `new-default`, or `legacy-default`).\n\n### 6. Cleanup safety during scope changes\n\nWhen scope changes:\n\n- Writes occur in the new effective targets.\n- Cleanup/removal is limited to OpenSpec-managed files for the relevant tool/workflow IDs.\n- Output explicitly states which scope locations were updated and which were cleaned.\n\n### 7. Scope drift state tracking\n\nTrack last successful effective scope per tool/surface in project-managed state.\n\nRules:\n\n1. Drift is detected when current resolved scope differs from last successful scope for a configured tool/surface.\n2. Scope support MUST be validated for all configured tools/surfaces before any write starts.\n3. Update writes to newly resolved targets first, verifies completeness, then removes managed files at previous targets.\n4. If new-target writes are partial or verification fails, command SHALL abort old-target cleanup and report actionable failure with incomplete/new and preserved/old paths.\n5. Cleanup failures do not rollback new writes; command returns actionable failure with leftover paths to resolve.\n\n### 8. Coordination with command-surface capability changes\n\nIf `add-tool-command-surface-capabilities` lands, planning logic must evaluate scope resolution and delivery/capability behavior together (scope × delivery × command surface).\n\n## Risks / Trade-offs\n\n**Risk: Cross-project shared global state**\nGlobal installs are shared across projects. Updating global artifacts from one project affects all projects using that tool scope.\n→ Mitigation: make scope explicit in output; keep profile/delivery global and deterministic.\n\n**Risk: Tool-specific unknown global conventions**\nNot all tools document a stable global install location.\n→ Mitigation: use explicit scope support metadata; fallback or fail instead of guessing.\n\n**Risk: Adapter API churn**\nChanging adapter path contracts touches many files/tests.\n→ Mitigation: migrate in one pass with adapter contract tests and existing end-to-end generation tests.\n\n## Rollout Plan\n\n1. Add config schema + defaults for install scope.\n2. Add tool scope capability metadata and resolver utilities.\n3. Upgrade command adapter contract and generator path plumbing.\n4. Integrate scope-aware behavior into init/update.\n5. Add documentation and test coverage.\n"
  },
  {
    "path": "openspec/changes/add-global-install-scope/proposal.md",
    "content": "## Why\n\nOpenSpec installation paths are currently inconsistent:\n\n- Most skills and commands are written to project-local directories.\n- Codex commands are already global (`$CODEX_HOME/prompts` or `~/.codex/prompts`).\n- Users cannot choose a consistent install scope strategy across tools.\n\nThis creates friction for users who prefer user-level setup and expect tool artifacts to be managed globally by default.\n\n## What Changes\n\n### 1. Add install scope preference with legacy-safe defaults\n\nIntroduce a global install scope setting with two modes:\n\n- `global` (default for newly created configs)\n- `project`\n\nThe setting is stored in global config and can be overridden per command run.\nFor schema-evolved legacy configs where `installScope` is absent, effective default remains `project` until users opt in to global scope.\n\n### 2. Add scope-aware path resolution for skills and commands\n\nRefactor path resolution so both `init` and `update` compute install targets from:\n\n- selected scope preference (`global` or `project`)\n- tool capability metadata (which scopes each tool/surface supports)\n- runtime context (project root, home directories, env overrides)\n\n### 3. Add per-tool capability metadata for scope support\n\nExtend tool metadata to explicitly declare scope support per surface:\n\n- skills scope support\n- commands scope support\n\nWhen preferred scope is unsupported for a tool/surface, the system uses deterministic fallback rules and reports the effective scope in output.\n\n### 4. Make command generation context-aware\n\nExtend command adapter path resolution so adapters receive install context (scope + environment context), instead of only command ID. This removes special-case handling and allows consistent scope behavior across tools.\n\n### 5. Update init/update UX and behavior\n\n- `openspec init`:\n  - accepts scope override flag\n  - uses configured scope or migration-aware default (new configs default global; legacy configs preserve project until migration)\n  - applies scope-aware generation and cleanup planning\n- `openspec update`:\n  - applies current scope preference\n  - syncs artifacts in effective scope per tool/surface\n  - tracks last successful effective scope per tool/surface for deterministic scope-drift detection\n  - reports effective scope decisions clearly\n\n### 6. Extend config UX and docs\n\n- Add install scope control in `openspec config profile` interactive flow.\n- Extend `openspec config list` output with install scope source (`explicit`, `new-default`, `legacy-default`).\n- Add explicit migration guidance and prompt path so legacy users can opt into `global` scope.\n- Update supported tools and CLI docs to explain scope behavior and fallback rules.\n\n### 7. Coordinate with command-surface capability delivery rules\n\n`cli-init` and `cli-update` planning SHALL compose:\n\n- install scope (`global | project`)\n- delivery mode (`both | skills | commands`)\n- command surface capability (`adapter | skills-invocable | none`)\n\nThis proposal remains focused on scope resolution, but implementation and test coverage should include mixed-tool cases to avoid regressions when combined with `add-tool-command-surface-capabilities`.\n\n## Capabilities\n\n### New Capabilities\n\n- `installation-scope`: Scope preference model and effective scope resolution for tool artifact installation.\n\n### Modified Capabilities\n\n- `global-config`: Persist install scope preference with schema evolution defaults.\n- `cli-config`: Configure and inspect install scope preferences.\n- `ai-tool-paths`: Add tool-level scope support metadata and path strategy.\n- `command-generation`: Scope-aware adapter path resolution via install context.\n- `cli-init`: Scope-aware initialization planning and output.\n- `cli-update`: Scope-aware update sync, drift detection, and output.\n- `migration`: Scope-aware migration scanning with install-scope-aware workflow lookup.\n\n## Impact\n\n- `src/core/global-config.ts` - new install scope fields and defaults\n- `src/core/config-schema.ts` - validation support for install scope config keys\n- `src/commands/config.ts` - interactive profile/config UX additions for install scope\n- `src/core/config.ts` - tool scope capability metadata\n- `src/core/available-tools.ts` and `src/core/shared/tool-detection.ts` - scope-aware configured detection\n- `src/core/command-generation/types.ts` and adapter implementations - context-aware file path resolution\n- `src/core/init.ts` - scope-aware generation/removal planning\n- `src/core/update.ts` - scope-aware sync/removal/drift planning\n- `src/core/migration.ts` - scope-aware workflow scanning support\n- `docs/supported-tools.md` and `docs/cli.md` - install scope behavior documentation\n- `test/core/init.test.ts`, `test/core/update.test.ts`, adapter tests, config tests - scope coverage\n"
  },
  {
    "path": "openspec/changes/add-global-install-scope/specs/ai-tool-paths/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: AIToolOption skillsDir field\nThe `AIToolOption` interface SHALL include scope support metadata in addition to path metadata.\n\n#### Scenario: Scope support metadata present\n- **WHEN** a tool entry is defined in `AI_TOOLS`\n- **THEN** it MAY declare supported install scopes for skills and commands\n- **AND** this metadata SHALL be used for effective scope resolution\n\n#### Scenario: Scope support metadata absent\n- **WHEN** a tool entry in `AI_TOOLS` omits scope support metadata for a surface\n- **THEN** resolver behavior SHALL default that surface to project-only support\n- **AND** effective scope resolution SHALL apply normal preferred/fallback rules against that default\n\n### Requirement: Path configuration for supported tools\nPath metadata SHALL support both project and global install targets via resolver logic.\n\n#### Scenario: Project scope path\n- **WHEN** effective scope is `project` for skills\n- **THEN** `skillsDir` SHALL be treated as a tool-specific container path under project root\n- **AND** managed skill artifacts SHALL be written under `<projectRoot>/<skillsDir>/skills/`\n- **AND** tool definitions SHALL set `skillsDir` accordingly (for example `.openspec` -> `.openspec/skills/`)\n\n#### Scenario: Global scope path\n- **WHEN** effective scope is `global` for a supported tool/surface\n- **THEN** paths SHALL resolve to tool-specific global directories\n- **AND** environment overrides (for example `CODEX_HOME`) SHALL be respected where applicable\n\n#### Scenario: Windows global path resolution for Codex commands\n- **WHEN** effective scope is `global`\n- **AND** tool is Codex\n- **AND** platform is Windows\n- **THEN** command targets SHALL resolve to `%CODEX_HOME%\\prompts` when `CODEX_HOME` is set\n- **AND** SHALL otherwise resolve to `%USERPROFILE%\\.codex\\prompts`\n"
  },
  {
    "path": "openspec/changes/add-global-install-scope/specs/cli-config/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Install scope configuration via profile flow\nThe config profile workflow SHALL allow users to configure install scope preference.\n\n#### Scenario: Interactive profile includes install scope\n- **WHEN** user runs `openspec config profile`\n- **THEN** the interactive flow SHALL include install scope selection with values `global` and `project`\n- **AND** the currently configured value SHALL be pre-selected\n\n#### Scenario: Save install scope\n- **WHEN** user confirms config profile changes\n- **THEN** selected install scope SHALL be saved to global config\n\n### Requirement: Install scope visibility in config output\nThe config command SHALL display install scope preference in human-readable output.\n\n#### Scenario: Config list shows install scope\n- **WHEN** user runs `openspec config list`\n- **THEN** output SHALL include current install scope value\n- **AND** indicate whether value is default or explicit\n"
  },
  {
    "path": "openspec/changes/add-global-install-scope/specs/cli-init/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Init install scope selection\nThe init command SHALL support install scope selection for generated artifacts.\n\n#### Scenario: Scope defaults to global\n- **WHEN** user runs `openspec init` without explicit scope override\n- **THEN** init SHALL use global config install scope\n- **AND** if unset, SHALL resolve migration-aware default (`global` for newly created configs, `project` for legacy schema-evolved configs)\n\n#### Scenario: Scope override via flag\n- **WHEN** user runs `openspec init --scope project`\n- **THEN** init SHALL use `project` as preferred scope for that run\n- **AND** SHALL NOT mutate persisted global config unless user explicitly changes config\n\n### Requirement: Init uses effective scope resolution\nThe init command SHALL resolve effective scope per tool surface before generating files.\n\n#### Scenario: Effective scope with fallback\n- **WHEN** selected tool/surface does not support preferred scope\n- **AND** supports alternate scope\n- **THEN** init SHALL generate files at alternate effective scope\n- **AND** SHALL display fallback note in summary\n\n#### Scenario: Unsupported scope selection\n- **WHEN** selected tool/surface supports neither preferred nor alternate scope\n- **THEN** init SHALL fail before writing files\n- **AND** SHALL provide clear error guidance\n"
  },
  {
    "path": "openspec/changes/add-global-install-scope/specs/cli-update/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Update install scope selection\nThe update command SHALL support install scope selection for sync operations.\n\n#### Scenario: Scope defaults to global config value\n- **WHEN** user runs `openspec update` without explicit scope override\n- **THEN** update SHALL use configured install scope\n- **AND** if unset, SHALL resolve migration-aware default (`global` for newly created configs, `project` for legacy schema-evolved configs)\n\n#### Scenario: Scope override via flag\n- **WHEN** user runs `openspec update --scope project`\n- **THEN** update SHALL use `project` as preferred scope for that run\n\n### Requirement: Scope-aware sync and drift detection\nThe update command SHALL evaluate configured state and drift using effective scoped paths.\n\n#### Scenario: Scoped drift detection\n- **WHEN** update evaluates whether tools are up-to-date\n- **THEN** it SHALL inspect files at effective scoped targets for each tool/surface\n- **AND** SHALL compare current resolved scope against last successful effective scope for each tool/surface\n- **AND** SHALL treat a difference as sync-required drift\n\n#### Scenario: Scope fallback during update\n- **WHEN** preferred scope is unsupported for a configured tool/surface\n- **AND** alternate scope is supported\n- **THEN** update SHALL apply fallback scope resolution\n- **AND** SHALL report fallback in output\n\n#### Scenario: Unsupported scope during update\n- **WHEN** configured tool/surface supports neither preferred nor alternate scope\n- **THEN** scope support SHALL be validated for all configured tools/surfaces before any write\n- **AND** update SHALL fail without performing file writes when incompatibilities are detected\n- **AND** SHALL report incompatible tools with remediation steps\n"
  },
  {
    "path": "openspec/changes/add-global-install-scope/specs/command-generation/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: ToolCommandAdapter interface\nThe system SHALL provide install-context-aware command path resolution.\n\n#### Scenario: Adapter interface structure\n- **WHEN** implementing a tool adapter\n- **THEN** command file path resolution SHALL receive install context (including effective scope and environment context)\n- **AND** SHALL return the effective command output path for that context\n\n#### Scenario: Codex global path remains supported\n- **WHEN** resolving Codex command paths in global scope\n- **THEN** the adapter SHALL target `$CODEX_HOME/prompts` when `CODEX_HOME` is set\n- **AND** SHALL otherwise target `~/.codex/prompts`\n\n### Requirement: Command generator function\nThe command generator SHALL pass install context into adapter path resolution for all generated commands.\n\n#### Scenario: Scoped command generation\n- **WHEN** generating commands for a tool with a resolved effective scope\n- **THEN** generated command paths SHALL match that effective scope\n- **AND** the formatted command body/frontmatter behavior SHALL remain tool-specific and unchanged\n"
  },
  {
    "path": "openspec/changes/add-global-install-scope/specs/global-config/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Install scope field in global config\nThe global config schema SHALL include install scope preference.\n\n#### Scenario: Config shape supports install scope\n- **WHEN** reading or writing global config\n- **THEN** config SHALL support `installScope` with allowed values `global` and `project`\n\n#### Scenario: Schema evolution default\n- **WHEN** loading legacy config without `installScope`\n- **THEN** the system SHALL preserve schema compatibility without mutating the file\n- **AND** effective install scope SHALL resolve to `project` until user explicitly sets `installScope`\n- **AND** preserve all other existing fields\n\n#### Scenario: New config default\n- **WHEN** creating a new global config\n- **THEN** the system SHALL persist `installScope: global` by default\n- **AND** users MAY switch to `project` explicitly\n\n#### Scenario: Invalid install scope value\n- **WHEN** config validation receives an invalid install scope value\n- **THEN** the value SHALL be rejected\n- **AND** the system SHALL preserve the existing valid configuration\n"
  },
  {
    "path": "openspec/changes/add-global-install-scope/specs/installation-scope/spec.md",
    "content": "## Purpose\n\nDefine the install scope model for OpenSpec-generated skills and commands, including scope preference, effective scope resolution, and fallback/error semantics.\n\n## ADDED Requirements\n\n### Requirement: Install scope preference model\nThe system SHALL support a user-level install scope preference with values `global` and `project`.\n\n#### Scenario: Default install scope\n- **WHEN** install scope is not explicitly configured\n- **THEN** the system SHALL resolve a migration-aware default:\n- **AND** use `global` for newly created configs\n- **AND** use `project` for legacy schema-evolved configs until explicit migration\n\n#### Scenario: Explicit install scope\n- **WHEN** user configures install scope to `project`\n- **THEN** generation and update flows SHALL use `project` as the preferred scope\n\n### Requirement: Effective scope resolution by tool surface\nThe system SHALL compute effective scope per tool surface (skills, commands) based on preferred scope and tool capability support.\n\n#### Scenario: Preferred scope is supported\n- **WHEN** preferred scope is supported for a tool surface\n- **THEN** the system SHALL use that scope as the effective scope\n\n#### Scenario: Preferred scope is unsupported but alternate is supported\n- **WHEN** preferred scope is not supported for a tool surface\n- **AND** the alternate scope is supported\n- **THEN** the system SHALL use the alternate scope as effective scope\n- **AND** SHALL record a fallback note for user-facing output\n\n#### Scenario: No supported scope\n- **WHEN** neither `global` nor `project` is supported for a tool surface\n- **THEN** the command SHALL fail before writing files\n- **AND** SHALL display actionable remediation\n\n### Requirement: Effective scope reporting\nThe system SHALL report effective scope decisions in command output when they differ from the preferred scope.\n\n#### Scenario: Fallback reporting\n- **WHEN** fallback resolution occurs for any selected/configured tool surface\n- **THEN** init/update summaries SHALL include effective scope notes per affected tool\n\n### Requirement: Cross-platform path behavior\nInstall scope resolution SHALL produce platform-correct target paths.\n\n#### Scenario: Global scope path on Windows\n- **WHEN** effective scope is `global`\n- **AND** the command runs on Windows\n- **THEN** resolved target paths SHALL use Windows path conventions and separators\n- **AND** SHALL NOT reuse POSIX-style home-relative defaults directly\n\n### Requirement: Cleanup safety for scope transitions\nScope transitions SHALL update new targets first and clean old managed targets safely.\n\n#### Scenario: Automatic cleanup for managed files on scope change\n- **WHEN** update or init applies a scope transition for a configured tool/surface\n- **THEN** the system SHALL write new artifacts in the new effective scope before cleanup\n- **AND** SHALL automatically remove only OpenSpec-managed files in the previous effective scope\n\n#### Scenario: Cleanup scope boundaries\n- **WHEN** cleanup runs after a scope transition\n- **THEN** the system SHALL leave non-managed files untouched\n- **AND** SHALL limit removal scope to the affected tool/workflow-managed paths\n\n#### Scenario: Cleanup failure after successful writes\n- **WHEN** new artifacts were written successfully in the new scope\n- **AND** cleanup of old managed targets fails\n- **THEN** the command SHALL report failure with leftover cleanup paths\n- **AND** SHALL NOT rollback successfully written new-scope artifacts\n"
  },
  {
    "path": "openspec/changes/add-global-install-scope/tasks.md",
    "content": "## 1. Global Config + Validation\n\n- [ ] 1.1 Add `installScope` (`global` | `project`) to `GlobalConfig` with explicit `global` default for newly created configs\n- [ ] 1.2 Update config schema validation and known-key checks to include install scope\n- [ ] 1.3 Add schema-evolution tests ensuring missing `installScope` in legacy configs resolves to effective `project` until explicit migration\n- [ ] 1.4 Extend `openspec config list` output to show install scope and source (`explicit`, `new-default`, `legacy-default`)\n\n## 2. Tool Capability Metadata + Resolvers\n\n- [ ] 2.1 Extend `AI_TOOLS` metadata to declare scope support per surface (skills/commands)\n- [ ] 2.2 Add shared install-target resolver for skills and commands using requested scope + tool support\n- [ ] 2.3 Implement deterministic fallback/error behavior when preferred scope is unsupported, including default behavior when scope support metadata is absent\n- [ ] 2.4 Add unit tests for scope resolution (preferred, fallback, and hard-fail paths)\n\n## 3. Command Generation Contract\n\n- [ ] 3.1 Update `ToolCommandAdapter` path contract to accept install context\n- [ ] 3.2 Update `generateCommand`/`generateCommands` to pass context through adapters\n- [ ] 3.3 Migrate all command adapters to the new path contract\n- [ ] 3.4 Update adapter tests for scoped path behavior (including Codex global path semantics)\n\n## 4. Init Command Scope Support\n\n- [ ] 4.1 Add scope override flag to `openspec init` (`--scope global|project`)\n- [ ] 4.2 Resolve effective scope per tool/surface before writing artifacts\n- [ ] 4.3 Apply scope-aware generation/removal planning for skills and commands\n- [ ] 4.4 Surface effective scope decisions and fallback notes in init summary output\n- [ ] 4.5 Add init tests for global default, project override, and fallback/error scenarios\n\n## 5. Update Command Scope Support\n\n- [ ] 5.1 Add scope override flag to `openspec update` (`--scope global|project`)\n- [ ] 5.2 Make configured-tool detection and drift checks scope-aware\n- [ ] 5.3 Persist and read last successful effective scope per tool/surface for deterministic scope-drift detection\n- [ ] 5.4 Apply scope-aware sync/removal with consistent fallback/error behavior\n- [ ] 5.5 Ensure scope changes update managed files in new targets and clean old managed targets safely\n- [ ] 5.6 Add update tests for global/project/fallback/error and repeat-run idempotency\n\n## 6. Config UX\n\n- [ ] 6.1 Extend `openspec config profile` interactive flow to select install scope\n- [ ] 6.2 Preserve install scope when using preset shortcuts unless explicitly changed\n- [ ] 6.3 Ensure non-interactive config behavior remains deterministic with clear errors\n- [ ] 6.4 Add/adjust config command tests for install scope flows\n- [ ] 6.5 Add migration UX for legacy users to opt into `global` scope explicitly\n\n## 7. Documentation\n\n- [ ] 7.1 Update `docs/supported-tools.md` with scope behavior and effective-scope fallback notes\n- [ ] 7.2 Update `docs/cli.md` examples for init/update scope options\n- [ ] 7.3 Document cross-project implications of global installs\n- [ ] 7.4 Add existing-user migration guide covering legacy-default behavior and explicit opt-in to `installScope: global`\n\n## 8. Verification\n\n- [ ] 8.1 Run targeted tests for config, adapters, init, and update\n- [ ] 8.2 Run full test suite (`pnpm test`) and resolve regressions\n- [ ] 8.3 Manual smoke test: init/update with `installScope=global`\n- [ ] 8.4 Manual smoke test: init/update with `--scope project`\n- [ ] 8.5 Verify path resolution behavior on Windows CI (or cross-platform unit tests with mocked Windows paths)\n- [ ] 8.6 Verify combined behavior matrix for mixed tools across scope × delivery × command-surface capability\n"
  },
  {
    "path": "openspec/changes/add-qa-smoke-harness/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-02-20\n"
  },
  {
    "path": "openspec/changes/add-qa-smoke-harness/proposal.md",
    "content": "## Why\n\nWe need a faster, more reliable way to manually validate CLI behavior changes like profile/delivery sync, migration behavior, and tool-detection UX.\n\nToday, manual review is mostly ad hoc: each developer sets up state differently, runs a different command order, and checks outputs informally. This makes regressions easy to miss and slows iteration on CLI UX work.\n\nAn 80/20 solution is to add a lightweight smoke harness for deterministic non-interactive flows, plus a short manual checklist for interactive prompt behavior.\n\n## What Changes\n\n- Add a lightweight QA smoke harness for OpenSpec CLI behavior with isolated per-run sandbox state\n- Use `Makefile` targets as the primary entrypoint:\n  - `make qa` (default local QA entrypoint)\n  - `make qa-smoke` (deterministic non-interactive suite)\n  - `make qa-interactive` (prints/opens manual interactive checklist)\n- Implement smoke logic in a script (invoked by Make targets), not in Make itself\n- Ensure each scenario runs in an isolated sandbox with temporary `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, and `CODEX_HOME`\n- Capture scenario artifacts for inspection (command output, exit code, and before/after filesystem state)\n- Add a focused scenario set for high-risk behavior:\n  - init core output generation\n  - non-interactive detected-tool behavior\n  - migration when profile is unset\n  - delivery cleanup (`both -> skills`, `both -> commands`)\n  - commands-only update detection\n  - new tool directory detection messaging\n  - invalid profile override validation\n- Add a short interactive checklist for keypress/prompt UX verification (Space toggle, Enter confirm, detected pre-selection)\n- Wire CI to run the smoke suite on Linux as a fast regression gate\n\n## Capabilities\n\n### New Capabilities\n\n- `qa-smoke-harness`: Deterministic, sandboxed CLI smoke validation with a single developer entrypoint\n\n### Modified Capabilities\n\n- `developer-qa-workflow`: Standardized local/CI QA flow for CLI behavior and migration-sensitive scenarios\n\n## Impact\n\n- `Makefile` - Add `qa`, `qa-smoke`, and `qa-interactive` targets\n- `scripts/qa-smoke.sh` (or equivalent) - Implement sandbox setup, scenario execution, and assertions\n- `docs/` - Add/update contributor-facing QA instructions and interactive checklist usage\n- CI workflow - Add smoke target execution as a lightweight regression gate\n"
  },
  {
    "path": "openspec/changes/add-qa-smoke-harness/specs/developer-qa-workflow/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Makefile QA Entry Point\n\nThe repository SHALL provide Makefile targets as the primary developer entrypoint for CLI QA flows.\n\n#### Scenario: Default QA target runs smoke suite\n\n- **WHEN** a developer runs `make qa`\n- **THEN** the command SHALL execute the non-interactive smoke suite\n- **AND** exit with status code 0 only when all smoke scenarios pass\n\n#### Scenario: Smoke suite target is directly invokable\n\n- **WHEN** a developer runs `make qa-smoke`\n- **THEN** the command SHALL execute the same smoke suite used by `make qa`\n- **AND** return a non-zero exit code on assertion failure\n\n#### Scenario: Interactive checklist target exists\n\n- **WHEN** a developer runs `make qa-interactive`\n- **THEN** the command SHALL provide the manual interactive verification checklist\n- **AND** SHALL NOT run interactive prompt automation by default\n\n### Requirement: Sandboxed Smoke Scenario Runner\n\nThe smoke suite SHALL run CLI scenarios in isolated sandboxes so tests are repeatable and do not depend on machine-global state.\n\n#### Scenario: Scenario execution is environment-isolated\n\n- **WHEN** a smoke scenario runs\n- **THEN** it SHALL use temporary values for `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, and `CODEX_HOME`\n- **AND** global config from the host machine SHALL NOT affect scenario outcomes\n\n#### Scenario: Scenario artifacts are captured for review\n\n- **WHEN** a smoke scenario completes\n- **THEN** the runner SHALL capture command output and exit status\n- **AND** SHALL capture enough filesystem state to inspect before/after behavior\n\n#### Scenario: High-risk workflow coverage exists\n\n- **WHEN** the smoke suite executes\n- **THEN** it SHALL include scenarios covering profile/delivery behavior and migration-sensitive flows\n- **AND** include at least:\n  - non-interactive tool detection\n  - migration when profile is unset\n  - delivery cleanup (`both -> skills`, `both -> commands`)\n  - commands-only update detection\n"
  },
  {
    "path": "openspec/changes/add-tool-command-surface-capabilities/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-02-19\n"
  },
  {
    "path": "openspec/changes/add-tool-command-surface-capabilities/proposal.md",
    "content": "## Why\n\nOpenSpec currently assumes command delivery maps directly to command adapters. That assumption does not hold for all tools.\n\nTrae 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.\n\nToday, this creates a behavior gap:\n\n- `delivery=commands` can remove skills\n- tools without adapters skip command generation\n- result: selected tools like Trae can end up with no invocable workflow artifacts\n\nThis 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.\n\n## What Changes\n\n### 1. Add explicit command-surface capability metadata\n\nAdd an optional field in tool metadata to describe how a tool exposes commands:\n\n- `adapter`: command files are generated through a command adapter\n- `skills-invocable`: skills are directly invocable as commands\n- `none`: no OpenSpec command surface\n\nField 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`.\nCapability values use kebab-case string tokens for consistency with serialized metadata conventions.\n\nInitial explicit override:\n\n- Trae -> `skills-invocable`\n\n### 2. Make delivery behavior capability-aware\n\nUpdate `init` and `update` to compute effective artifact actions per tool from:\n\n- global delivery (`both | skills | commands`)\n- tool command surface capability\n\nBehavior matrix:\n\n- `both`:\n  - generate skills for all tools with `skillsDir` (including `skills-invocable`)\n  - generate command files only for `adapter` tools\n  - `none`: no artifact action; MAY emit compatibility warning\n- `skills`:\n  - generate skills for all tools with `skillsDir` (including `skills-invocable`)\n  - remove adapter-generated command files\n  - `none`: no artifact action; MAY emit compatibility warning\n- `commands`:\n  - `adapter`: generate commands, remove skills\n  - `skills-invocable`: generate (or keep if up-to-date) skills as command surface; do not remove them\n  - `none`: fail fast with clear error\n\n### 3. Add preflight validation and clearer output\n\nBefore writing/removing artifacts, validate selected/configured tools against delivery mode:\n\n- interactive flow: show clear compatibility note before confirmation\n- non-interactive flow: fail with deterministic error listing incompatible tools and supported alternatives\n\nUpdate summaries to show effective delivery outcomes per tool (for example, when commands mode still installs skills for skills-invocable tools).\n\n### 4. Update docs and tests\n\n- document capability model and Trae behavior under delivery modes\n- ensure CLI docs and supported-tools docs reflect effective behavior\n- add test coverage for:\n  - `init --tools trae` with `delivery=commands`\n  - `update` with Trae configured under `delivery=commands`\n  - mixed selections (`claude + trae`) across all delivery modes\n  - explicit error path for tools with no command surface under `delivery=commands`\n\n### 5. Coordinate with install-scope behavior\n\nWhen combined with `add-global-install-scope`, init/update planning must compose:\n\n- install scope (`global | project`)\n- delivery mode (`both | skills | commands`)\n- command surface capability (`adapter | skills-invocable | none`)\n\nImplementation tests should cover mixed-tool matrices to ensure deterministic behavior when both changes are active.\n\n## Capabilities\n\n### New Capabilities\n\n- `tool-command-surface`: Capability model that classifies tools as `adapter`, `skills-invocable`, or `none` to drive delivery behavior\n\n### Modified Capabilities\n\n- `cli-init`: Delivery handling becomes tool-capability-aware with preflight compatibility validation\n- `cli-update`: Delivery sync becomes tool-capability-aware with consistent compatibility validation and messaging\n- `supported-tools-docs`: Documents command-surface semantics for non-adapter tools\n\n## Impact\n\n- `src/core/config.ts` - add optional command-surface metadata and Trae override\n- `src/core/command-generation/registry.ts` (or shared helper) - capability inference from adapter presence\n- `src/core/init.ts` - capability-aware generation/removal planning + compatibility validation + summary messaging\n- `src/core/update.ts` - capability-aware sync/removal planning + compatibility validation + summary messaging\n- `src/core/shared/tool-detection.ts` - include capability-aware detection so `skills-invocable` tools remain detectable under `delivery=commands`, and `none` tools are excluded from command-surface artifact detection\n- `docs/supported-tools.md` and `docs/cli.md` - document delivery behavior and compatibility notes\n- `test/core/init.test.ts` and `test/core/update.test.ts` - add coverage for skills-invocable behavior and mixed-tool delivery scenarios\n\n## Sequencing Notes\n\n- This change is intended to stack safely with `simplify-skill-installation` by introducing additive, capability-specific requirements for init/update.\n- If `simplify-skill-installation` merges first, this change should be rebased and keep the capability-aware rule as the source of truth for `delivery=commands` behavior on `skills-invocable` tools.\n- If this change merges first, the `simplify-skill-installation` branch should be rebased to avoid re-introducing a global \"commands-only means no skills for all tools\" assumption.\n- If `add-global-install-scope` merges first, this change should be rebased to compose capability-aware behavior on top of scope-resolved path decisions from that change.\n- If this change merges first, `add-global-install-scope` should be rebased to preserve Section 5 composition rules (`install scope` + `delivery mode` + `command surface capability`) without overriding capability-aware command-surface outcomes.\n"
  },
  {
    "path": "openspec/changes/add-tool-command-surface-capabilities/specs/cli-init/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Command surface capability resolution\nThe init command SHALL resolve each selected tool's command surface using explicit metadata first, then deterministic inference.\n\n#### Scenario: Explicit command surface override\n- **WHEN** a tool declares an explicit command-surface capability\n- **THEN** init SHALL use that explicit capability\n- **AND** SHALL NOT override it based on adapter presence\n\n#### Scenario: Inferred command surface from adapter presence\n- **WHEN** a tool does not declare an explicit command-surface capability\n- **AND** a command adapter is registered for the tool\n- **THEN** init SHALL infer `adapter` as the command surface\n\n#### Scenario: Inferred command surface for skills-only tool\n- **WHEN** a tool does not declare an explicit command-surface capability\n- **AND** no command adapter is registered for the tool\n- **AND** the tool has a configured `skillsDir`\n- **THEN** init SHALL infer `skills-invocable` as the command surface\n\n#### Scenario: Inferred command surface without adapter or skills\n- **WHEN** a tool does not declare an explicit command-surface capability\n- **AND** no command adapter is registered for the tool\n- **AND** the tool has no `skillsDir`\n- **THEN** init SHALL infer `none` as the command surface\n\n### Requirement: Delivery compatibility by tool command surface\nThe init command SHALL apply delivery settings using each tool's command surface capability, not adapter presence alone.\n\n#### Scenario: Both delivery for adapter-backed tool\n- **WHEN** user runs `openspec init` with a selected tool that has a command adapter\n- **AND** delivery is set to `both`\n- **THEN** the system SHALL generate command files for active workflows using that adapter\n- **AND** SHALL generate or refresh managed skills when the tool has `skillsDir`\n\n#### Scenario: Both delivery for skills-invocable tool\n- **WHEN** user runs `openspec init` with a selected tool whose command surface is `skills-invocable`\n- **AND** delivery is set to `both`\n- **THEN** the system SHALL generate or refresh managed skill directories when the tool has `skillsDir`\n- **AND** SHALL NOT require adapter-generated command files for that tool\n\n#### Scenario: Both delivery for none command surface\n- **WHEN** user runs `openspec init` with a selected tool whose command surface is `none`\n- **AND** delivery is set to `both`\n- **THEN** the system SHALL perform no command-surface artifact action for that tool\n- **AND** MAY emit a compatibility note indicating no command surface is available\n\n#### Scenario: Skills delivery for adapter-backed tool\n- **WHEN** user runs `openspec init` with a selected tool that has a command adapter\n- **AND** delivery is set to `skills`\n- **THEN** the system SHALL generate or refresh managed skill directories when the tool has `skillsDir`\n- **AND** SHALL remove managed adapter-generated command files for that tool\n\n#### Scenario: Skills delivery for skills-invocable tool\n- **WHEN** user runs `openspec init` with a selected tool whose command surface is `skills-invocable`\n- **AND** delivery is set to `skills`\n- **THEN** the system SHALL generate or refresh managed skill directories when the tool has `skillsDir`\n- **AND** SHALL NOT require adapter-generated command files for that tool\n\n#### Scenario: Skills delivery for none command surface\n- **WHEN** user runs `openspec init` with a selected tool whose command surface is `none`\n- **AND** delivery is set to `skills`\n- **THEN** the system SHALL perform no command-surface artifact action for that tool\n- **AND** MAY emit a compatibility note indicating no command surface is available\n\n#### Scenario: Commands delivery for adapter-backed tool\n- **WHEN** user runs `openspec init` with a selected tool that has a command adapter\n- **AND** delivery is set to `commands`\n- **THEN** the system SHALL generate command files for active workflows using that adapter\n- **AND** the system SHALL remove managed skill directories for that tool\n\n#### Scenario: Commands delivery for skills-invocable tool\n- **WHEN** user runs `openspec init` with a selected tool whose command surface is `skills-invocable`\n- **AND** delivery is set to `commands`\n- **THEN** the system SHALL generate or refresh managed skill directories for active workflows\n- **AND** the system SHALL NOT remove those managed skill directories as part of commands-only cleanup\n- **AND** the system SHALL NOT require a command adapter for that tool\n\n#### Scenario: Commands delivery for mixed tool selection\n- **WHEN** user runs `openspec init` with multiple tools\n- **AND** selected tools include both adapter-backed and skills-invocable command surfaces\n- **AND** delivery is set to `commands`\n- **THEN** the system SHALL apply commands-only behavior per tool capability\n- **AND** the resulting install SHALL include command files for adapter-backed tools and skills for skills-invocable tools\n\n#### Scenario: Commands delivery for unsupported command surface\n- **WHEN** user runs `openspec init` with a selected tool that has no command surface capability\n- **AND** delivery is set to `commands`\n- **THEN** the system SHALL fail before generating or deleting artifacts\n- **AND** the error SHALL list incompatible tool IDs and explain supported alternatives (`both` or `skills`)\n\n#### Scenario: Interactive handling for unsupported command surface\n- **WHEN** user runs `openspec init` interactively\n- **AND** delivery is set to `commands`\n- **AND** selected tools include one or more tools with command surface `none`\n- **THEN** the CLI SHALL show a compatibility error and return to the interactive selection flow for correction\n- **AND** SHALL not perform artifact writes until a valid selection is confirmed\n\n### Requirement: Init compatibility signaling\nThe init command SHALL clearly signal command-surface compatibility outcomes in both interactive and non-interactive flows.\n\n#### Scenario: Interactive compatibility note\n- **WHEN** init runs interactively\n- **AND** delivery is `commands`\n- **AND** selected tools include skills-invocable command surfaces\n- **THEN** the system SHALL display a compatibility note before the confirmation prompt indicating those tools will use skills as their command surface\n\n#### Scenario: Non-interactive compatibility summary for skills-invocable tools\n- **WHEN** init runs non-interactively (including `--tools` usage)\n- **AND** delivery is `commands`\n- **AND** selected tools include one or more `skills-invocable` command surfaces\n- **THEN** the command SHALL proceed with exit code 0\n- **AND** the command SHALL write deterministic compatibility summary lines to stdout indicating those tools will use managed skills as their command surface\n\n#### Scenario: Non-interactive compatibility failure\n- **WHEN** init runs non-interactively (including `--tools` usage)\n- **AND** delivery is `commands`\n- **AND** selected tools include any tool with no command surface capability\n- **THEN** the command SHALL exit with code 1\n- **AND** the command SHALL write deterministic, actionable guidance for resolving the selection to stderr\n"
  },
  {
    "path": "openspec/changes/add-tool-command-surface-capabilities/specs/cli-update/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Delivery sync by command surface capability\nThe update command SHALL synchronize artifacts using each configured tool's command surface capability.\n\n#### Scenario: Commands delivery for adapter-backed configured tool\n- **WHEN** user runs `openspec update`\n- **AND** delivery is set to `commands`\n- **AND** a configured tool has an adapter-backed command surface\n- **THEN** the system SHALL generate or refresh command files for active workflows\n- **AND** the system SHALL remove managed skill directories for that tool\n\n#### Scenario: Commands delivery for skills-invocable configured tool\n- **WHEN** user runs `openspec update`\n- **AND** delivery is set to `commands`\n- **AND** a configured tool has `skills-invocable` command surface capability\n- **THEN** the system SHALL generate or refresh managed skill directories for active workflows\n- **AND** the system SHALL NOT remove those managed skill directories as part of commands-only cleanup\n- **AND** the system SHALL NOT attempt to require adapter-generated command files for that tool\n\n#### Scenario: Commands delivery with unsupported command surface\n- **WHEN** user runs `openspec update`\n- **AND** delivery is set to `commands`\n- **AND** a configured tool has no command surface capability\n- **THEN** the system SHALL fail with exit code 1 before applying partial updates\n- **AND** the output SHALL identify incompatible tools and recommended remediation\n\n### Requirement: Configured-tool detection for skills-invocable command surfaces\nThe update command SHALL treat tools with skills-invocable command surfaces as configured when managed skill artifacts are present, including under commands delivery.\n\n#### Scenario: Skills-invocable tool under commands delivery\n- **WHEN** user runs `openspec update`\n- **AND** delivery is set to `commands`\n- **AND** a tool has no adapter-generated command files\n- **AND** that tool is marked `skills-invocable` and has managed skills installed\n- **THEN** the system SHALL include the tool in configured-tool detection\n- **AND** the system SHALL apply normal version/profile/delivery sync to that tool\n\n### Requirement: Update summary reflects effective per-tool delivery\nThe update command SHALL report effective artifact behavior when delivery intent and artifact type differ due to tool capability.\n\n#### Scenario: Summary for skills-invocable tools in commands delivery\n- **WHEN** update completes successfully\n- **AND** delivery is `commands`\n- **AND** at least one updated tool is `skills-invocable`\n- **THEN** output SHALL include a clear note that those tools use skills as their command surface\n- **AND** output SHALL avoid implying that command generation was skipped due to an error\n\n"
  },
  {
    "path": "openspec/changes/add-tool-command-surface-capabilities/tasks.md",
    "content": "## 0. Stacking Coordination\n\n- [ ] 0.1 Rebase this change on latest `main` before implementation\n- [ ] 0.2 If `simplify-skill-installation` is merged first, preserve its profile/delivery model and apply this change as a capability-aware refinement\n- [ ] 0.3 If this change merges first, ensure follow-up rebases do not reintroduce a blanket \"commands = remove all skills\" rule\n- [ ] 0.4 If `add-global-install-scope` is merged, verify combined scope × delivery × command-surface behavior remains deterministic\n\n## 1. Tool Command-Surface Capability Model\n\n- [ ] 1.1 Extend tool metadata in `src/core/config.ts` with an optional command-surface capability field\n- [ ] 1.2 Define supported capability values: `adapter`, `skills-invocable`, `none`\n- [ ] 1.3 Mark Trae as `skills-invocable`\n- [ ] 1.4 Add a shared capability resolver (explicit metadata override first, inferred fallback from adapter presence second)\n- [ ] 1.5 Add focused unit tests for capability resolution (explicit override, inferred adapter, inferred none)\n\n## 2. Init: Capability-Aware Delivery Planning\n\n- [ ] 2.1 Refactor init generation logic to compute per-tool effective actions (generate/remove skills and commands) instead of using only global booleans\n- [ ] 2.2 In `delivery=commands`, keep/generate skills for `skills-invocable` tools and do not remove those managed skill directories\n- [ ] 2.3 In `delivery=commands`, fail fast before writes when any selected tool resolves to `none`\n- [ ] 2.4 Update init output to clearly report effective behavior for `skills-invocable` tools (skills used as command surface)\n- [ ] 2.5 Ensure init no longer reports \"no adapter\" for tools intentionally using `skills-invocable`\n- [ ] 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`)\n\n## 3. Update: Capability-Aware Sync and Drift Detection\n\n- [ ] 3.1 Refactor update sync logic to apply delivery behavior per tool capability (not globally per run)\n- [ ] 3.2 In `delivery=commands`, keep/generate managed skills for `skills-invocable` tools\n- [ ] 3.3 In `delivery=commands`, fail before partial updates when configured tools include a `none` command surface\n- [ ] 3.4 Update profile/delivery drift detection to avoid perpetual drift for `skills-invocable` tools under commands delivery\n- [ ] 3.5 Ensure configured-tool detection still includes `skills-invocable` tools under commands delivery when managed skills exist\n- [ ] 3.6 Update summary output so skills-invocable behavior is reported as expected behavior (not implicit skip/error)\n- [ ] 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`)\n\n## 4. UX and Error Messaging\n\n- [ ] 4.1 Add interactive init compatibility note for `delivery=commands` when selected tools include `skills-invocable`\n- [ ] 4.2 Add deterministic non-interactive error text with incompatible tool IDs and suggested alternatives (`both` or `skills`)\n- [ ] 4.3 Align init and update wording so capability-related behavior/messages are consistent\n\n## 5. Documentation Updates\n\n- [ ] 5.1 Update `docs/supported-tools.md` to document command-surface semantics for Trae and clarify delivery interactions\n- [ ] 5.2 Update `docs/cli.md` delivery guidance to explain capability-aware behavior for `delivery=commands`\n- [ ] 5.3 Add a short troubleshooting note for \"commands-only + unsupported tool\" failures\n\n## 6. Verification\n\n- [ ] 6.1 Run targeted tests: `test/core/init.test.ts` and `test/core/update.test.ts`\n- [ ] 6.2 Run any new capability/unit test files added in this change\n- [ ] 6.3 Run full test suite (`pnpm test`) and resolve regressions\n- [ ] 6.4 Manual smoke check: `openspec init --tools trae` with `delivery=commands`\n- [ ] 6.5 Manual smoke check: mixed tools (`claude,trae`) with `delivery=commands`\n"
  },
  {
    "path": "openspec/changes/archive/2025-01-11-add-update-command/design.md",
    "content": "# Technical Design\n\n## Architecture Decisions\n\n### Simplicity First\n- No version tracking - always update when commanded\n- Full replacement for OpenSpec-managed files only (e.g., `openspec/README.md`)\n- Marker-based updates for user-owned files (e.g., `CLAUDE.md`)\n- Templates bundled with package - no network required\n- Minimal error handling - only check prerequisites\n\n### Template Strategy\n- Use existing template utilities\n  - `readmeTemplate` from `src/core/templates/readme-template.ts` for `openspec/README.md`\n  - `TemplateManager.getClaudeTemplate()` for `CLAUDE.md`\n- Directory name is fixed to `openspec` (from `OPENSPEC_DIR_NAME`)\n\n### File Operations\n- Use async utilities for consistency\n  - `FileSystemUtils.writeFile` for `openspec/README.md`\n  - `FileSystemUtils.updateFileWithMarkers` for `CLAUDE.md`\n- No atomic operations needed - users have git\n- Check directory existence before proceeding\n\n## Implementation\n\n### Update Command (`src/core/update.ts`)\n```typescript\nexport class UpdateCommand {\n  async execute(projectPath: string): Promise<void> {\n    const openspecDirName = OPENSPEC_DIR_NAME;\n    const openspecPath = path.join(projectPath, openspecDirName);\n\n    // 1. Check openspec directory exists\n    if (!await FileSystemUtils.directoryExists(openspecPath)) {\n      throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);\n    }\n\n    // 2. Update README.md (full replacement)\n    const readmePath = path.join(openspecPath, 'README.md');\n    await FileSystemUtils.writeFile(readmePath, readmeTemplate);\n\n    // 3. Update CLAUDE.md (marker-based)\n    const claudePath = path.join(projectPath, 'CLAUDE.md');\n    const claudeContent = TemplateManager.getClaudeTemplate();\n    await FileSystemUtils.updateFileWithMarkers(\n      claudePath,\n      claudeContent,\n      OPENSPEC_MARKERS.start,\n      OPENSPEC_MARKERS.end\n    );\n\n    // 4. Success message (ASCII-safe, checkmark optional by terminal)\n    console.log('Updated OpenSpec instructions');\n  }\n}\n```\n\n## Why This Approach\n\n### Benefits\n- **Dead simple**: ~40 lines of code total\n- **Fast**: No version checks, minimal parsing\n- **Predictable**: Same result every time; idempotent\n- **Maintainable**: Reuses existing utilities\n\n### Trade-offs Accepted\n- No version tracking (unnecessary complexity)\n- Full overwrite only for OpenSpec-managed files\n- Marker-managed updates for user-owned files\n\n## Error Handling\n\nOnly handle critical errors:\n- Missing `openspec` directory → throw error handled by CLI to present a friendly message\n- File write failures → let errors bubble up to CLI\n\n## Testing Strategy\n\nManual smoke tests are sufficient initially:\n1. Run `openspec init` in a test project\n2. Modify both files (including custom content around markers in `CLAUDE.md`)\n3. Run `openspec update`\n4. Verify `openspec/README.md` fully replaced; `CLAUDE.md` OpenSpec block updated without altering user content outside markers\n5. Run the command twice to verify idempotency and no duplicate markers\n6. Test with missing `openspec` directory (expect failure)"
  },
  {
    "path": "openspec/changes/archive/2025-01-11-add-update-command/proposal.md",
    "content": "# Add Update Command\n\n## Why\n\nUsers need a way to update their local OpenSpec instructions (README.md and CLAUDE.md) when the OpenSpec package releases new versions with improved AI agent instructions or structural conventions.\n\n## What Changes\n\n- Add new `openspec update` CLI command that updates OpenSpec instructions\n- Replace `openspec/README.md` with the latest template\n  - Safe because this file is fully OpenSpec-managed\n- Update only the OpenSpec-managed block in `CLAUDE.md` using markers\n  - Preserve all user content outside markers\n  - If `CLAUDE.md` is missing, create it with the managed block\n- Display success message after update (ASCII-safe): \"Updated OpenSpec instructions\"\n  - A leading checkmark MAY be shown when the terminal supports it\n  - Operation is idempotent (re-running yields identical results)\n\n## Impact\n\n- Affected specs: `cli-update` (new capability)\n- Affected code:\n  - `src/core/update.ts` (new command class, mirrors `InitCommand` placement)\n  - `src/cli/index.ts` (register new command)\n  - Uses existing templates via `TemplateManager` and `readmeTemplate`\n\n## Out of Scope\n\n- No `.openspec/config.json` is introduced by this change. The default directory name `openspec` is used."
  },
  {
    "path": "openspec/changes/archive/2025-01-11-add-update-command/specs/cli-update/spec.md",
    "content": "# Update Command Specification\n\n## Purpose\n\nAs a developer using OpenSpec, I want to update the OpenSpec instructions in my project when new versions are released, so that I can benefit from improvements to AI agent instructions.\n\n## Core Requirements\n\n### Update Behavior\n\nThe update command SHALL update OpenSpec instruction files to the latest templates.\n\nWHEN a user runs `openspec update` THEN the command SHALL:\n- Check if the `openspec` directory exists\n- Replace `openspec/README.md` with the latest template (complete replacement)\n- Update the OpenSpec-managed block in `CLAUDE.md` using markers\n  - Preserve user content outside markers\n  - Create `CLAUDE.md` if missing\n- Display ASCII-safe success message: \"Updated OpenSpec instructions\"\n\n### Prerequisites\n\nThe command SHALL require:\n- An existing `openspec` directory (created by `openspec init`)\n\nIF the `openspec` directory does not exist THEN:\n- Display error: \"No OpenSpec directory found. Run 'openspec init' first.\"\n- Exit with code 1\n\n### File Handling\n\nThe update command SHALL:\n- Completely replace `openspec/README.md` with the latest template\n- Update only the OpenSpec-managed block in `CLAUDE.md` using markers\n- Use the default directory name `openspec`\n- Be idempotent (repeated runs have no additional effect)\n\n## Edge Cases\n\n### File Permissions\nIF file write fails THEN let the error bubble up naturally with file path.\n\n### Missing CLAUDE.md\nIF CLAUDE.md doesn't exist THEN create it with the template content.\n\n### Custom Directory Name\nNot supported in this change. The default directory name `openspec` SHALL be used.\n\n## Success Criteria\n\nUsers SHALL be able to:\n- Update OpenSpec instructions with a single command\n- Get the latest AI agent instructions\n- See clear confirmation of the update\n\nThe update process SHALL be:\n- Simple and fast (no version checking)\n- Predictable (same result every time)\n- Self-contained (no network required)"
  },
  {
    "path": "openspec/changes/archive/2025-01-11-add-update-command/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Update Command Implementation\n- [x] 1.1 Create `src/core/update.ts` with `UpdateCommand` class\n- [x] 1.2 Check if `openspec` directory exists (use `FileSystemUtils.directoryExists`)\n- [x] 1.3 Write `readmeTemplate` to `openspec/README.md` using `FileSystemUtils.writeFile`\n- [x] 1.4 Update `CLAUDE.md` using markers via `FileSystemUtils.updateFileWithMarkers` and `TemplateManager.getClaudeTemplate()`\n- [x] 1.5 Display ASCII-safe success message: `Updated OpenSpec instructions`\n\n## 2. CLI Integration\n- [x] 2.1 Register `update` command in `src/cli/index.ts`\n- [x] 2.2 Add command description: `Update OpenSpec instruction files`\n- [x] 2.3 Handle errors with `ora().fail(...)` and exit code 1 (missing `openspec` directory, file write errors)\n\n## 3. Testing\n- [x] 3.1 Verify `openspec/README.md` is fully replaced with latest template\n- [x] 3.2 Verify `CLAUDE.md` OpenSpec block updates without altering user content outside markers\n- [x] 3.3 Verify idempotency (running twice yields identical files, no duplicate markers)\n- [x] 3.4 Verify error when `openspec` directory is missing with friendly message\n- [x] 3.5 Verify success message displays properly in ASCII-only terminals"
  },
  {
    "path": "openspec/changes/archive/2025-01-13-add-list-command/proposal.md",
    "content": "# Add List Command to OpenSpec CLI\n\n## Why\n\nDevelopers need visibility into available changes and their status to understand the project's evolution and pending work.\n\n## What Changes\n\n- Add `openspec list` command that displays all changes in the changes/ directory\n- Show each change name with task completion count (e.g., \"add-auth: 3/5 tasks\")\n- Display completion status indicator (✓ for fully complete, progress for partial)\n- Skip the archive/ subdirectory to focus on active changes\n- Simple table output for easy scanning\n\n## Impact\n\n- Affected specs: New capability `cli-list` will be added\n- Affected code:\n  - `src/cli/index.ts` - Add list command\n  - `src/core/list.ts` - New file with directory scanning and task parsing (~60 lines)"
  },
  {
    "path": "openspec/changes/archive/2025-01-13-add-list-command/specs/cli-list/spec.md",
    "content": "# List Command Specification\n\n## Purpose\n\nThe `openspec list` command SHALL provide developers with a quick overview of all active changes in the project, showing their names and task completion status.\n\n## Behavior\n\n### Command Execution\n\nWHEN `openspec list` is executed\nTHEN scan the `openspec/changes/` directory for change directories\nAND exclude the `archive/` subdirectory from results\nAND parse each change's `tasks.md` file to count task completion\n\n### Task Counting\n\nWHEN parsing a `tasks.md` file\nTHEN count tasks matching these patterns:\n- Completed: Lines containing `- [x]`\n- Incomplete: Lines containing `- [ ]`\nAND calculate total tasks as the sum of completed and incomplete\n\n### Output Format\n\nWHEN displaying the list\nTHEN show a table with columns:\n- Change name (directory name)\n- Task progress (e.g., \"3/5 tasks\" or \"✓ Complete\")\n- Status indicator:\n  - `✓` for fully completed changes (all tasks done)\n  - Progress fraction for partial completion\n\nExample output:\n```\nChanges:\n  add-auth-feature     3/5 tasks\n  update-api-docs      ✓ Complete\n  fix-validation       0/2 tasks\n  add-list-command     1/4 tasks\n```\n\n### Empty State\n\nWHEN no active changes exist (only archive/ or empty changes/)\nTHEN display: \"No active changes found.\"\n\n### Error Handling\n\nIF a change directory has no `tasks.md` file\nTHEN display the change with \"No tasks\" status\n\nIF `openspec/changes/` directory doesn't exist\nTHEN display error: \"No OpenSpec changes directory found. Run 'openspec init' first.\"\nAND exit with code 1\n\n### Sorting\n\nChanges SHALL be displayed in alphabetical order by change name for consistency.\n\n## Why\n\nDevelopers need a quick way to:\n- See what changes are in progress\n- Identify which changes are ready to archive\n- Understand the overall project evolution status\n- Get a bird's-eye view without opening multiple files\n\nThis command provides that visibility with minimal effort, following OpenSpec's philosophy of simplicity and clarity."
  },
  {
    "path": "openspec/changes/archive/2025-01-13-add-list-command/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Core Implementation\n- [x] 1.1 Create `src/core/list.ts` with list logic\n  - [x] 1.1.1 Implement directory scanning (exclude archive/)\n  - [x] 1.1.2 Implement task counting from tasks.md files\n  - [x] 1.1.3 Format output as simple table\n- [x] 1.2 Add list command to CLI in `src/cli/index.ts`\n  - [x] 1.2.1 Register `openspec list` command\n  - [x] 1.2.2 Connect to list.ts implementation\n\n## 2. Error Handling\n- [x] 2.1 Handle missing openspec/changes/ directory\n- [x] 2.2 Handle changes without tasks.md files\n- [x] 2.3 Handle empty changes directory\n\n## 3. Testing\n- [x] 3.1 Add tests for list functionality\n  - [x] 3.1.1 Test with multiple changes\n  - [x] 3.1.2 Test with completed changes\n  - [x] 3.1.3 Test with no changes\n  - [x] 3.1.4 Test error conditions\n\n## 4. Documentation\n- [x] 4.1 Update CLI help text with list command\n- [x] 4.2 Add list command to README if applicable"
  },
  {
    "path": "openspec/changes/archive/2025-08-05-initialize-typescript-project/design.md",
    "content": "# Technical Design\n\n## Technology Choices\n\n### TypeScript Configuration\n- **Strict mode**: Enable all strict type checking for better AI understanding\n- **Target**: ES2022 for modern JavaScript features\n- **Module**: ES2022 for modern ESM support\n- **Module Resolution**: Node for proper package resolution\n- **Output**: dist/ directory for compiled JavaScript\n- **Source Maps**: Enable for debugging TypeScript directly\n- **Declaration Files**: Generate .d.ts files for type definitions\n- **ES Module Interop**: true for better CommonJS compatibility\n- **Skip Lib Check**: false to ensure all types are validated\n\n### Package Structure\n```\nopenspec\n├── bin/            # CLI entry point\n├── dist/           # Compiled JavaScript\n├── src/            # TypeScript source\n│   ├── cli/        # Command implementations\n│   ├── core/       # Core OpenSpec logic\n│   └── utils/      # Shared utilities\n├── package.json\n├── tsconfig.json\n└── build.js        # Build script\n```\n\n### Dependency Strategy\n- **Minimal dependencies**: Only essential packages\n- **commander**: Industry-standard CLI framework\n- **@inquirer/prompts**: Modern prompting library\n- **No heavy frameworks**: Direct, readable implementation\n\n### Build Approach\n- Native TypeScript compilation via tsc\n- Simple build.js script for packaging\n- No complex build toolchain needed\n- ESM output with proper .js extensions in imports\n\n### Development Workflow\n1. `pnpm install` - Install dependencies\n2. `pnpm run build` - Compile TypeScript\n3. `pnpm run dev` - Development mode\n4. `pnpm link` - Test CLI locally\n\n### Node.js Requirements\n- **Minimum version**: Node.js 20.19.0\n- **Recommended**: Node.js 22 LTS\n- **Rationale**: Full ESM support without flags, modern JavaScript features\n\n### ESM Configuration\n- **Package type**: `\"type\": \"module\"` in package.json\n- **File extensions**: Use .js extensions in TypeScript imports (compiles correctly)\n- **Top-level await**: Available for cleaner async initialization\n- **Future-proof**: Aligns with JavaScript standards\n\n### TypeScript Best Practices\n- **All code in TypeScript**: No .js files in src/, only .ts\n- **Explicit types**: Prefer explicit typing over inference where it adds clarity\n- **Interfaces over types**: Use interfaces for object shapes, types for unions/aliases\n- **No any**: Strict mode prevents implicit any, use unknown when needed\n- **Async/await**: Modern async patterns throughout"
  },
  {
    "path": "openspec/changes/archive/2025-08-05-initialize-typescript-project/proposal.md",
    "content": "# Initialize TypeScript Project\n\n## Why\nThe OpenSpec project needs a proper TypeScript foundation to build the minimal CLI that helps developers set up OpenSpec file structures and keep AI instructions updated.\n\n## What Changes\n- Create TypeScript project configuration with ESM modules (package.json, tsconfig.json)\n- Set up the base directory structure for the CLI implementation\n- Configure build scripts and development tooling\n- Add essential dependencies for CLI development\n- Create .gitignore for Node.js/TypeScript projects\n- Set minimum Node.js version to 20.19.0 for native ESM support\n\n## Impact\n- Affected specs: None (initial project setup)\n- Affected code: None (greenfield project)\n- New directories: src/, dist/, node_modules/\n- New files: package.json, tsconfig.json, .gitignore, build.js"
  },
  {
    "path": "openspec/changes/archive/2025-08-05-initialize-typescript-project/tasks.md",
    "content": "# Tasks\n\n## 1. Project Configuration\n- [x] 1.1 Create package.json with project metadata, scripts, and ESM configuration\n- [x] 1.2 Configure TypeScript with tsconfig.json for ESM output\n- [x] 1.3 Add .gitignore for Node.js/TypeScript projects\n- [x] 1.4 Set Node.js engine requirement to >=20.19.0\n\n## 2. Directory Structure\n- [x] 2.1 Create src/ directory for source code\n- [x] 2.2 Create src/cli/ for CLI commands\n- [x] 2.3 Create src/core/ for core OpenSpec logic\n- [x] 2.4 Create src/utils/ for shared utilities\n\n## 3. Build Configuration\n- [x] 3.1 Create build.js for native TypeScript compilation\n- [x] 3.2 Configure development scripts (build, dev)\n- [x] 3.3 Set up package entry points with ESM exports\n- [x] 3.4 Configure proper file extensions handling for ESM\n\n## 4. Initial Dependencies\n- [x] 4.1 Add TypeScript as dev dependency\n- [x] 4.2 Add commander for CLI framework\n- [x] 4.3 Add @inquirer/prompts for user interaction\n- [x] 4.4 Add necessary type definitions"
  },
  {
    "path": "openspec/changes/archive/2025-08-06-add-init-command/design.md",
    "content": "# Technical Design for Init Command\n\n## Architecture Overview\n\nThe init command follows a modular architecture with clear separation of concerns:\n\n```\nCLI Layer (src/cli/index.ts)\n    ↓\nCore Logic (src/core/init.ts)\n    ↓\nTemplates (src/core/templates/)\n    ↓\nFile System Utils (src/utils/file-system.ts)\n```\n\n## Key Design Decisions\n\n### 1. Template Management\n\n**Decision**: Store templates as TypeScript modules rather than separate files\n**Rationale**: \n- Ensures templates are bundled with the compiled code\n- Allows for dynamic content insertion\n- Type-safe template handling\n- No need for complex file path resolution\n\n### 2. Interactive vs Non-Interactive Mode\n\n**Decision**: Support both interactive (default) and non-interactive modes\n**Rationale**:\n- Interactive mode for developer experience\n- Non-interactive for CI/CD and automation\n- Flags: `--yes` to accept defaults, `--no-input` for full automation\n\n### 3. Directory Structure Creation\n\n**Decision**: Create all directories upfront, then populate files\n**Rationale**:\n- Fail fast if permissions issues\n- Clear transaction boundary\n- Easier to clean up on failure\n\n### 4. Error Handling Strategy\n\n**Decision**: Implement rollback on failure\n**Rationale**:\n- Prevent partial installations\n- Clear error states\n- Better user experience\n\n## Implementation Details\n\n### File System Operations\n\n```typescript\n// Atomic directory creation with rollback\ninterface InitTransaction {\n  createdPaths: string[];\n  rollback(): Promise<void>;\n  commit(): Promise<void>;\n}\n```\n\n### Template System\n\n```typescript\ninterface Template {\n  path: string;\n  content: string | ((context: ProjectContext) => string);\n}\n\ninterface ProjectContext {\n  projectName: string;\n  description: string;\n  techStack: string[];\n  conventions: string;\n}\n```\n\n### CLI Command Structure\n\n```bash\nopenspec init [path]           # Initialize in specified path (default: current directory)\n  --yes                       # Accept all defaults\n  --no-input                  # Skip all prompts\n  --force                     # Overwrite existing OpenSpec directory\n  --dry-run                   # Show what would be created\n```\n\n## Security Considerations\n\n1. **Path Traversal**: Sanitize all user-provided paths\n2. **File Permissions**: Check write permissions before starting\n3. **Existing Files**: Never overwrite without explicit --force flag\n4. **Template Injection**: Sanitize user inputs in templates\n\n## Future Extensibility\n\nThe design supports future enhancements:\n- Custom template sources\n- Project type presets (API, web app, library)\n- Migration from other documentation systems\n- Integration with version control systems"
  },
  {
    "path": "openspec/changes/archive/2025-08-06-add-init-command/proposal.md",
    "content": "# Add Init Command for OpenSpec\n\n## Why\n\nProjects need a simple way to adopt OpenSpec conventions. Currently, users must manually create the directory structure and understand all the conventions, which creates friction for adoption. An init command would enable instant OpenSpec setup with proper structure and guidance.\n\n## What Changes\n\n- Add `openspec init` CLI command that creates the complete OpenSpec directory structure\n- Generate template files (README.md with AI instructions, project.md template)\n- Interactive prompt to select which AI tools to configure (Claude Code initially, others marked as \"coming soon\")\n- Support for multiple AI coding assistants with extensible plugin architecture\n- Smart file updates using content markers to preserve existing configurations\n- Custom directory naming with `--dir` flag\n- Validation to prevent overwriting existing OpenSpec structures\n- Clear error messages with helpful guidance (e.g., suggesting 'openspec update' for existing structures)\n- Display actionable next steps after successful initialization\n\n### Breaking Changes\n- None - this is a new feature\n\n## Impact\n\n- Affected specs: None (new feature)\n- Affected code: \n  - src/cli/index.ts (add init command)\n  - src/core/init.ts (new - initialization logic)\n  - src/core/templates/ (new - template files)\n  - src/core/configurators/ (new - AI tool plugins)\n  - src/utils/file-system.ts (new - file operations)"
  },
  {
    "path": "openspec/changes/archive/2025-08-06-add-init-command/specs/cli-init/spec.md",
    "content": "# CLI Init Specification\n\n## Purpose\n\nThe `openspec init` command SHALL create a complete OpenSpec directory structure in any project, enabling immediate adoption of OpenSpec conventions with support for multiple AI coding assistants.\n\n## Behavior\n\n### Progress Indicators\n\nWHEN executing initialization steps\nTHEN validate environment silently in background (no output unless error)\nAND display progress with ora spinners:\n- Show spinner: \"⠋ Creating OpenSpec structure...\"\n- Then success: \"✔ OpenSpec structure created\"\n- Show spinner: \"⠋ Configuring AI tools...\"\n- Then success: \"✔ AI tools configured\"\n\n### Directory Creation\n\nWHEN `openspec init` is executed\nTHEN create the following directory structure:\n```\nopenspec/\n├── project.md\n├── README.md\n├── specs/\n└── changes/\n    └── archive/\n```\n\n### File Generation\n\nThe command SHALL generate:\n- `README.md` containing complete OpenSpec instructions for AI assistants\n- `project.md` with project context template\n\n### AI Tool Configuration\n\nWHEN run interactively\nTHEN prompt user to select AI tools to configure:\n- Claude Code (updates/creates CLAUDE.md with OpenSpec markers)\n- Cursor (future)\n- Aider (future)\n\n### AI Tool Configuration Details\n\nWHEN Claude Code is selected\nTHEN create or update `CLAUDE.md` in the project root directory (not inside openspec/)\n\nWHEN CLAUDE.md does not exist\nTHEN create new file with OpenSpec content wrapped in markers:\n```markdown\n<!-- OPENSPEC:START -->\n# OpenSpec Project\n\nThis document provides instructions for AI coding assistants on how to use OpenSpec conventions for spec-driven development. Follow these rules precisely when working on OpenSpec-enabled projects.\n\nThis project uses OpenSpec for spec-driven development. Specifications are the source of truth.\n\nSee @openspec/README.md for detailed conventions and guidelines.\n<!-- OPENSPEC:END -->\n```\n\nWHEN CLAUDE.md already exists\nTHEN preserve all existing content\nAND insert OpenSpec content at the beginning of the file using markers\nAND ensure markers don't duplicate if they already exist\n\nThe marker system SHALL:\n- Use `<!-- OPENSPEC:START -->` to mark the beginning of managed content\n- Use `<!-- OPENSPEC:END -->` to mark the end of managed content\n- Allow OpenSpec to update its content without affecting user customizations\n- Preserve all content outside the markers intact\n\nWHY use markers:\n- Users may have existing CLAUDE.md instructions they want to keep\n- OpenSpec can update its instructions in future versions\n- Clear boundary between OpenSpec-managed and user-managed content\n\n### Interactive Mode\n\nWHEN run\nTHEN prompt user with: \"Which AI tool do you use?\"\nAND show single-select menu with available tools:\n- Claude Code\nAND show disabled options as \"coming soon\" (not selectable):\n- Cursor (coming soon)\n- Aider (coming soon)  \n- Continue (coming soon)\n\nUser navigation:\n- Use arrow keys to move between options\n- Press Enter to select the highlighted option\n\n### Safety Checks\n\nWHEN `openspec/` directory already exists\nTHEN display error with ora fail indicator:\n\"✖ Error: OpenSpec seems to already be initialized. Use 'openspec update' to update the structure.\"\n\nWHEN checking initialization feasibility\nTHEN verify write permissions in the target directory silently\nAND only display error if permissions are insufficient\n\n### Success Output\n\nWHEN initialization completes successfully\nTHEN display actionable prompts for AI-driven workflow:\n```\n✔ OpenSpec initialized successfully!\n\nNext steps - Copy these prompts to Claude:\n\n────────────────────────────────────────────────────────────\n1. Populate your project context:\n   \"Please read openspec/project.md and help me fill it out\n    with details about my project, tech stack, and conventions\"\n\n2. Create your first change proposal:\n   \"I want to add [YOUR FEATURE HERE]. Please create an\n    OpenSpec change proposal for this feature\"\n\n3. Learn the OpenSpec workflow:\n   \"Please explain the OpenSpec workflow from openspec/README.md\n    and how I should work with you on this project\"\n────────────────────────────────────────────────────────────\n```\n\nThe prompts SHALL:\n- Be copy-pasteable for immediate use with AI tools\n- Guide users through the AI-driven workflow\n- Replace placeholder text ([YOUR FEATURE HERE]) with actual features\n\n### Exit Codes\n\n- 0: Success\n- 1: General error (including when OpenSpec directory already exists)\n- 2: Insufficient permissions (reserved for future use)\n- 3: User cancelled operation (reserved for future use)\n\n## Why\n\nManual creation of OpenSpec structure is error-prone and creates adoption friction. A standardized init command ensures:\n- Consistent structure across all projects\n- Proper AI instruction files are always included\n- Quick onboarding for new projects\n- Clear conventions from the start"
  },
  {
    "path": "openspec/changes/archive/2025-08-06-add-init-command/tasks.md",
    "content": "# Implementation Tasks for Init Command\n\n## 1. Core Infrastructure\n- [x] 1.1 Create src/utils/file-system.ts with directory/file creation utilities\n- [x] 1.2 Create src/core/templates/index.ts for template management\n- [x] 1.3 Create src/core/init.ts with main initialization logic\n- [x] 1.4 Create src/core/config.ts for configuration management\n\n## 2. Template Files\n- [x] 2.1 Create src/core/templates/readme-template.ts with OpenSpec README content\n- [x] 2.2 Create src/core/templates/project-template.ts with customizable project.md\n- [x] 2.3 Create src/core/templates/claude-template.ts for CLAUDE.md content with markers\n\n## 3. AI Tool Configurators\n- [x] 3.1 Create src/core/configurators/base.ts with ToolConfigurator interface\n- [x] 3.2 Create src/core/configurators/claude.ts for Claude Code configuration\n- [x] 3.3 Create src/core/configurators/registry.ts for tool registration\n- [x] 3.4 Implement marker-based file updates for existing configurations\n\n## 4. Init Command Implementation\n- [x] 4.1 Add init command to src/cli/index.ts using Commander\n- [x] 4.2 Implement AI tool selection with multi-select prompt (Claude Code available, others \"coming soon\") - requires at least one selection\n- [x] 4.3 Add validation for existing OpenSpec directories with helpful error message\n- [x] 4.4 Implement directory structure creation\n- [x] 4.5 Implement file generation with templates and markers\n\n## 5. User Experience\n- [x] 5.1 Add colorful console output for better UX\n- [x] 5.2 Implement progress indicators (Step 1/3, 2/3, 3/3)\n- [x] 5.3 Add success message with actionable next steps (edit project.md, create first change)\n- [x] 5.4 Add error handling with helpful messages\n\n## 6. Testing and Documentation\n- [x] 6.1 Add unit tests for file system utilities\n- [x] 6.2 Add unit tests for marker-based file updates\n- [x] 6.3 Add integration tests for init command\n- [x] 6.4 Update package.json with proper bin configuration\n- [x] 6.5 Test the built CLI command end-to-end"
  },
  {
    "path": "openspec/changes/archive/2025-08-06-adopt-future-state-storage/proposal.md",
    "content": "# Adopt Future State Storage for OpenSpec Changes\n\n## Why\n\nThe current approach of storing spec changes as diff files (`.spec.md.diff`) creates friction for both humans and AI. Diff syntax with `+` and `-` prefixes makes specs hard to read, AI tools struggle with the format when understanding future state, and GitHub can't show nice comparisons between current and proposed specs in different folders.\n\n## What Changes\n\n- Change from storing diffs (`patches/[capability]/spec.md.diff`) to storing complete future state (`specs/[capability]/spec.md`)\n- Update all documentation to reflect new storage format\n- Migrate existing `add-init-command` change to new format\n- Add new `openspec-conventions` capability to document these conventions\n\n\n\n## Impact\n\n- Affected specs: New `openspec-conventions` capability\n- Affected code: \n  - openspec/README.md (lines 85-108)\n  - docs/PRD.md (lines 376-382, 778-783)\n  - docs/openspec-walkthrough.md (lines 58-62, 112-126)\n  - openspec/changes/add-init-command/ (migration needed)\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-06-adopt-future-state-storage/specs/openspec-conventions/spec.md",
    "content": "# OpenSpec Conventions Specification\n\n## Purpose\n\nOpenSpec conventions SHALL define how system capabilities are documented, how changes are proposed and tracked, and how specifications evolve over time. This meta-specification serves as the source of truth for OpenSpec's own conventions.\n\n## Core Principles\n\nThe system SHALL follow these principles:\n- Specs reflect what IS currently built and deployed\n- Changes contain proposals for what SHOULD be changed\n- AI drives the documentation process\n- Specs are living documentation kept in sync with deployed code\n\n## Directory Structure\n\nWHEN an OpenSpec project is initialized\nTHEN it SHALL have this structure:\n```\nopenspec/\n├── project.md              # Project-specific context\n├── README.md               # AI assistant instructions\n├── specs/                  # Current deployed capabilities\n│   └── [capability]/       # Single, focused capability\n│       ├── spec.md         # WHAT and WHY\n│       └── design.md       # HOW (optional, for established patterns)\n└── changes/                # Proposed changes\n    ├── [change-name]/      # Descriptive change identifier\n    │   ├── proposal.md     # Why, what, and impact\n    │   ├── tasks.md        # Implementation checklist\n    │   ├── design.md       # Technical decisions (optional)\n    │   └── specs/          # Complete future state\n    │       └── [capability]/\n    │           └── spec.md # Clean markdown (no diff syntax)\n    └── archive/            # Completed changes\n        └── YYYY-MM-DD-[name]/\n```\n\n## Change Storage Convention\n\n### Future State Storage\n\nWHEN creating a change proposal\nTHEN store the complete future state of affected specs\nAND use clean markdown without diff syntax\n\nThe `changes/[name]/specs/` directory SHALL contain:\n- Complete spec files as they will exist after the change\n- Clean markdown without `+` or `-` prefixes\n- All formatting and structure of the final intended state\n\n### Proposal Format\n\nWHEN documenting what changes\nTHEN the proposal SHALL explicitly describe each change:\n\n```markdown\n**[Section or Behavior Name]**\n- From: [current state/requirement]\n- To: [future state/requirement]\n- Reason: [why this change is needed]\n- Impact: [breaking/non-breaking, who's affected]\n```\n\nThis explicit format compensates for not having inline diffs and ensures reviewers understand exactly what will change.\n\n## Change Lifecycle\n\nThe change process SHALL follow these states:\n\n1. **Propose**: AI creates change with future state specs and explicit proposal\n2. **Review**: Humans review proposal and future state\n3. **Approve**: Change is approved for implementation\n4. **Implement**: Follow tasks.md checklist (can span multiple PRs)\n5. **Deploy**: Changes are deployed to production\n6. **Update**: Specs in `specs/` are updated to match deployed reality\n7. **Archive**: Change is moved to `archive/YYYY-MM-DD-[name]/`\n\n## Viewing Changes\n\nWHEN reviewing proposed changes\nTHEN reviewers can compare using:\n- GitHub PR diff view when changes are committed\n- Command line: `diff -u specs/[capability]/spec.md changes/[name]/specs/[capability]/spec.md`\n- Any visual diff tool comparing current vs future state\n\nThe system relies on tools to generate diffs rather than storing them.\n\n## Capability Naming\n\nCapabilities SHALL use:\n- Verb-noun patterns (e.g., `user-auth`, `payment-capture`)\n- Hyphenated lowercase names\n- Singular focus (one responsibility per capability)\n- No nesting (flat structure under `specs/`)\n\n## When Changes Require Proposals\n\nA proposal SHALL be created for:\n- New features or capabilities\n- Breaking changes to existing behavior\n- Architecture or pattern changes\n- Performance optimizations that change behavior\n- Security updates affecting access patterns\n\nA proposal is NOT required for:\n- Bug fixes restoring intended behavior\n- Typos or formatting fixes\n- Non-breaking dependency updates\n- Adding tests for existing behavior\n- Documentation clarifications\n\n## Why This Approach\n\nClean future state storage provides:\n- **Readability**: No diff syntax pollution\n- **AI-compatibility**: Standard markdown that AI tools understand\n- **Simplicity**: No special parsing or processing needed\n- **Tool-agnostic**: Any diff tool can show changes\n- **Clear intent**: Explicit proposals document reasoning"
  },
  {
    "path": "openspec/changes/archive/2025-08-06-adopt-future-state-storage/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Update Core Documentation\n- [x] 1.1 Update openspec/README.md section on \"Creating a Change Proposal\"\n  - [x] Replace `patches/` with `specs/` in directory structure\n  - [x] Update step 3 to show storing complete future state\n  - [x] Remove diff syntax instructions (+/- prefixes)\n\n## 2. Migrate Existing Change\n- [x] 2.1 Convert add-init-command change to new format\n  - [x] Create `specs/cli-init/spec.md` with clean content (no diff markers)\n  - [x] Delete old `patches/` directory\n- [x] 2.2 Test that the migrated change is clear and reviewable\n\n## 3. Update Documentation Examples\n- [x] 3.1 Update docs/PRD.md\n  - [x] Fix directory structure examples (lines 376-382)\n  - [x] Update archive examples (lines 778-783)\n  - [x] Ensure consistency throughout\n- [x] 3.2 Update docs/openspec-walkthrough.md\n  - [x] Replace diff examples with future state examples\n  - [x] Ensure the walkthrough reflects new approach\n\n## 4. Create New Spec\n- [x] 4.1 Finalize openspec-conventions spec in main specs/ directory\n  - [x] Document the future state storage approach\n  - [x] Include examples of good proposals\n  - [x] Make it the source of truth for conventions\n\n## 5. Validation\n- [x] 5.1 Verify all documentation is consistent\n- [x] 5.2 Test creating a new change with the new approach\n- [x] 5.3 Ensure GitHub PR view shows diffs clearly\n\n## 6. Deployment\n- [x] 6.1 Get approval for this change\n- [x] 6.2 Implement all tasks above\n- [x] 6.3 After deployment, archive this change with completion date"
  },
  {
    "path": "openspec/changes/archive/2025-08-11-add-complexity-guidelines/proposal.md",
    "content": "# Add Complexity Management Guidelines\n\n## Why\nOpenSpec currently lacks guidance on managing complexity, leading to over-engineered solutions when simple ones suffice.\n\n## What Changes\n- Add \"Start Simple\" section to openspec/README.md with default minimalism rules\n- Add complexity triggers to help identify when complexity is justified\n- Enhance AI assistant instructions in CLAUDE.md to bias toward simplicity\n\n## Impact\n- Affected specs: None (documentation only)\n- Affected code: openspec/README.md, CLAUDE.md"
  },
  {
    "path": "openspec/changes/archive/2025-08-11-add-complexity-guidelines/specs/openspec-docs/README.md",
    "content": "# OpenSpec Instructions\n\nThis document provides instructions for AI coding assistants on how to use OpenSpec conventions for spec-driven development. Follow these rules precisely when working on OpenSpec-enabled projects.\n\n## Core Principle\n\nOpenSpec is an AI-native system for change-driven development where:\n- **Specs** (`specs/`) reflect what IS currently built and deployed\n- **Changes** (`changes/`) contain proposals for what SHOULD be changed\n- **AI drives the process** - You generate proposals, humans review and approve\n- **Specs are living documentation** - Always kept in sync with deployed code\n\n## Start Simple\n\n**Default to minimal implementations:**\n- New features should be <100 lines of code initially\n- Use the simplest solution that works\n- Avoid premature optimization (no caching, parallelization, or complex patterns without proven need)\n- Choose boring technology over cutting-edge solutions\n\n**Complexity triggers** - Only add complexity when you have:\n- **Performance data** showing current solution is too slow\n- **Scale requirements** with specific numbers (>1000 users, >100MB data)\n- **Multiple use cases** requiring the same abstraction\n- **Regulatory compliance** mandating specific patterns\n- **Security threats** that simple solutions cannot address\n\nWhen triggered, document the specific justification in your change proposal.\n\n## Directory Structure\n\n```\nopenspec/\n├── project.md              # Project-specific context (tech stack, conventions)\n├── README.md               # This file - OpenSpec instructions\n├── specs/                  # Current truth - what IS built\n│   ├── [capability]/       # Single, focused capability\n│   │   ├── spec.md         # WHAT the capability does and WHY\n│   │   └── design.md       # HOW it's built (established patterns)\n│   └── ...\n├── changes/                # Proposed changes - what we're CHANGING\n│   ├── [change-name]/\n│   │   ├── proposal.md     # Why, what, impact (consolidated)\n│   │   ├── tasks.md        # Implementation checklist\n│   │   ├── design.md       # Technical decisions (optional, for complex changes)\n│   │   └── specs/          # Future state of affected specs\n│   │       └── [capability]/\n│   │           └── spec.md # Clean markdown (no diff syntax)\n│   └── archive/            # Completed changes (dated)\n```\n\n### Capability Organization\n\n**Use capabilities, not features** - Each directory under `specs/` represents a single, focused responsibility:\n- **Verb-noun naming**: `user-auth`, `payment-capture`, `order-checkout`\n- **10-minute rule**: Each capability should be understandable in <10 minutes\n- **Single purpose**: If it needs \"AND\" to describe it, split it\n\nExamples:\n```\n✅ GOOD: user-auth, user-sessions, payment-capture, payment-refunds\n❌ BAD: users, payments, core, misc\n```\n\n## Key Behavioral Rules\n\n### 1. Always Start by Reading\n\nBefore any task:\n1. **Read relevant specs** in `specs/[capability]/spec.md` to understand current state\n2. **Check pending changes** in `changes/` directory for potential conflicts\n3. **Read project.md** for project-specific conventions\n\n### 2. When to Create Change Proposals\n\n**ALWAYS create a change proposal for:**\n- New features or functionality\n- Breaking changes (API changes, schema updates)\n- Architecture changes or new patterns\n- Performance optimizations that change behavior\n- Security updates affecting auth/access patterns\n- Any change requiring multiple steps or affecting multiple systems\n\n**SKIP proposals for:**\n- Bug fixes that restore intended behavior\n- Typos, formatting, or comment updates\n- Dependency updates (unless breaking)\n- Configuration or environment variable changes\n- Adding tests for existing behavior\n- Documentation fixes\n\n**Complexity assessment:**\n- If your solution requires >100 lines of new code, justify the complexity\n- If adding dependencies, frameworks, or architectural patterns, document why simpler alternatives won't work\n- Default to single-file implementations until proven insufficient\n\n### 3. Creating a Change Proposal\n\nWhen a user requests a significant change:\n\n```bash\n# 1. Create the change directory\nopenspec/changes/[descriptive-name]/\n\n# 2. Generate proposal.md with all context\n## Why\n[1-2 sentences on the problem/opportunity]\n\n## What Changes  \n[Bullet list of changes, including breaking changes]\n\n## Impact\n- Affected specs: [list capabilities that will change]\n- Affected code: [list key files/systems]\n\n# 3. Create future state specs for ALL affected capabilities\n# - Store complete spec files as they will exist after the change\n# - Use clean markdown without diff syntax (+/- prefixes)\n# - Include all formatting and structure of the final intended state\nspecs/\n└── [capability]/\n    └── spec.md\n\n# 4. Create tasks.md with implementation steps\n## 1. [Task Group]\n- [ ] 1.1 [Specific task]\n- [ ] 1.2 [Specific task]\n\n# 5. For complex changes, add design.md\n[Technical decisions and trade-offs]\n```\n\n### 4. The Change Lifecycle\n\n1. **Propose** → Create change directory with all documentation\n2. **Review** → User reviews and approves the proposal\n3. **Implement** → Follow the approved tasks.md (can be multiple PRs)\n4. **Deploy** → User confirms deployment\n5. **Update Specs** → Sync specs/ with new reality (IF the change affects system capabilities)\n6. **Archive** → Move to `changes/archive/YYYY-MM-DD-[name]/`\n\n### 5. Implementing Changes\n\nWhen implementing an approved change:\n1. Follow the tasks.md checklist exactly\n2. **Mark completed tasks** in tasks.md as you finish them (e.g., `- [x] 1.1 Task completed`)\n3. Ensure code matches the proposed behavior\n4. Update any affected tests\n5. **Keep change in `changes/` directory** - do NOT archive in implementation PR\n\n**Multiple Implementation PRs:**\n- Changes can be implemented across multiple PRs\n- Each PR should update tasks.md to mark what was completed\n- Different developers can work on different task groups\n- Example: PR #1 completes tasks 1.1-1.3, PR #2 completes tasks 2.1-2.4\n\n### 6. Updating Specs and Archiving After Deployment\n\n**Create a separate PR after deployment** that:\n1. Moves change to `changes/archive/YYYY-MM-DD-[name]/`\n2. Updates relevant files in `specs/` to reflect new reality (if needed)\n3. If design.md exists, incorporates proven patterns into `specs/[capability]/design.md`\n\nThis ensures changes are only archived when truly complete and deployed.\n\n### 7. Types of Changes That Don't Require Specs\n\nSome changes only affect development infrastructure and don't need specs:\n- Initial project setup (package.json, tsconfig.json, etc.)\n- Development tooling changes (linters, formatters, build tools)\n- CI/CD configuration\n- Development dependencies\n\nFor these changes:\n1. Implement → Deploy → Mark tasks complete → Archive\n2. Skip the \"Update Specs\" step entirely\n\n### What Deserves a Spec?\n\nAsk yourself:\n- Is this a system capability that users or other systems interact with?\n- Does it have ongoing behavior that needs documentation?\n- Would a new developer need to understand this to work with the system?\n\nIf NO to all → No spec needed (likely just tooling/infrastructure)\n\n## Understanding Specs vs Code\n\n### Specs Document WHAT and WHY\n```markdown\n# Authentication Spec\n\nUsers SHALL authenticate with email and password.\n\nWHEN credentials are valid THEN issue JWT token.\nWHEN credentials are invalid THEN return generic error.\n\nWHY: Prevent user enumeration attacks.\n```\n\n### Code Documents HOW\n```javascript\n// Implementation details\nconst user = await db.users.findOne({ email });\nconst valid = await bcrypt.compare(password, user.hashedPassword);\n```\n\n**Key Distinction**: Specs capture intent, constraints, and decisions that aren't obvious from code.\n\n## Common Scenarios\n\n### New Feature Request\n```\nUser: \"Add password reset functionality\"\n\nYou should:\n1. Read specs/user-auth/spec.md\n2. Check changes/ for pending auth changes\n3. Create changes/add-password-reset/ with proposal\n4. Wait for approval before implementing\n```\n\n### Bug Fix\n```\nUser: \"Getting null pointer error when bio is empty\"\n\nYou should:\n1. Check if spec says bios are optional\n2. If yes → Fix directly (it's a bug)\n3. If no → Create change proposal (it's a behavior change)\n```\n\n### Infrastructure Setup\n```\nUser: \"Initialize TypeScript project\"\n\nYou should:\n1. Create change proposal for TypeScript setup\n2. Implement configuration files (PR #1)\n3. Mark tasks complete in tasks.md\n4. After deployment, create separate PR to archive\n   (no specs update needed - this is tooling, not a capability)\n```\n\n## Summary Workflow\n\n1. **Receive request** → Determine if it needs a change proposal\n2. **Read current state** → Check specs and pending changes\n3. **Create proposal** → Generate complete change documentation\n4. **Get approval** → User reviews the proposal\n5. **Implement** → Follow approved tasks, mark completed items in tasks.md\n6. **Deploy** → User deploys the implementation\n7. **Archive PR** → Create separate PR to:\n   - Move change to archive\n   - Update specs if needed\n   - Mark change as complete\n\n## PR Workflow Examples\n\n### Single Developer, Simple Change\n```\nPR #1: Implementation\n- Implement all tasks\n- Update tasks.md marking items complete\n- Get merged and deployed\n\nPR #2: Archive (after deployment)\n- Move changes/feature-x/ → changes/archive/2025-01-15-feature-x/\n- Update specs if needed\n```\n\n### Multiple Developers, Complex Change\n```\nPR #1: Alice implements auth components\n- Complete tasks 1.1, 1.2, 1.3\n- Update tasks.md marking these complete\n\nPR #2: Bob implements UI components  \n- Complete tasks 2.1, 2.2\n- Update tasks.md marking these complete\n\nPR #3: Alice fixes integration issues\n- Complete remaining task 1.4\n- Update tasks.md\n\n[Deploy all changes]\n\nPR #4: Archive\n- Move to archive with deployment date\n- Update specs to reflect new auth flow\n```\n\n### Key Rules\n- **Never archive in implementation PRs** - changes aren't done until deployed\n- **Always update tasks.md** - shows accurate progress\n- **One archive PR per change** - clear completion boundary\n- **Archive PR includes spec updates** - keeps specs current\n\n## Capability Organization Best Practices\n\n### Naming Capabilities\n- Use **verb-noun** patterns: `user-auth`, `payment-capture`, `order-checkout`\n- Be specific: `payment-capture` not just `payments`\n- Keep flat: Avoid nesting capabilities within capabilities\n- Singular focus: If you need \"AND\" to describe it, split it\n\n### When to Split Capabilities\nSplit when you have:\n- Multiple unrelated API endpoints\n- Different user personas or actors\n- Separate deployment considerations\n- Independent evolution paths\n\n#### Capability Boundary Guidelines\n- Would you import these separately? → Separate capabilities\n- Different deployment cadence? → Separate capabilities\n- Different teams own them? → Separate capabilities\n- Shared data models are OK, shared business logic means combine\n\nExamples:\n- user-auth (login/logout) vs user-sessions (token management) → SEPARATE\n- payment-capture vs payment-refunds → SEPARATE (different workflows)\n- user-profile vs user-settings → COMBINE (same data model, same owner)\n\n### Cross-Cutting Concerns\nFor system-wide policies (rate limiting, error handling, security), document them in:\n- `project.md` for project-wide conventions\n- Within relevant capability specs where they apply\n- Or create a dedicated capability if complex enough (e.g., `api-rate-limiting/`)\n\n### Examples of Well-Organized Capabilities\n```\nspecs/\n├── user-auth/              # Login, logout, password reset\n├── user-sessions/          # Token management, refresh\n├── user-profile/           # Profile CRUD operations\n├── payment-capture/        # Processing payments\n├── payment-refunds/        # Handling refunds\n└── order-checkout/         # Checkout workflow\n```\n\nFor detailed guidance, see the [Capability Organization Guide](../docs/capability-organization.md).\n\n## Common Scenarios and Clarifications\n\n### Decision Ambiguity: Bug vs Behavior Change\n\nWhen specs are missing or ambiguous:\n- If NO spec exists → Treat current code behavior as implicit spec, require proposal\n- If spec is VAGUE → Require proposal to clarify spec alongside fix\n- If code and spec DISAGREE → Spec is truth, code is buggy (fix without proposal)\n- If unsure → Default to creating a proposal (safer option)\n\nExample:\n```\nUser: \"The API returns 404 for missing users but should return 400\"\nAI: Is this a bug (spec says 400) or behavior change (spec says 404)?\n```\n\n### When You Don't Know the Scope\nIt's OK to explore first! Tell the user you need to investigate, then create an informed proposal.\n\n### Exploration Phase (When Needed)\n\nBEFORE creating proposal, you may need exploration when:\n- User request is vague or high-level\n- Multiple implementation approaches exist\n- Scope is unclear without seeing code\n\nExploration checklist:\n1. Tell user you need to explore first\n2. Use Grep/Read to understand current state\n3. Create initial proposal based on findings\n4. Refine with user feedback\n\nExample:\n```\nUser: \"Add caching to improve performance\"\nAI: \"Let me explore the codebase to understand the current architecture and identify caching opportunities.\"\n[After exploration]\nAI: \"Based on my analysis, I've identified three areas where caching would help. Here's my proposal...\"\n```\n\n### When No Specs Exist\nTreat current code as implicit spec. Your proposal should document current state AND proposed changes.\n\n### When in Doubt\nDefault to creating a proposal. It's easier to skip an unnecessary proposal than fix an undocumented change.\n\n### AI Workflow Adaptations\n\nTask tracking with OpenSpec:\n- Track exploration tasks separately from implementation\n- Document proposal creation steps as you go\n- Keep implementation tasks separate until proposal approved\n\nParallel operations encouraged:\n- Read multiple specs simultaneously\n- Check multiple pending changes at once\n- Batch related searches for efficiency\n\nProgress communication:\n- \"Exploring codebase to understand scope...\"\n- \"Creating proposal based on findings...\"\n- \"Implementing approved changes...\"\n\n### For AI Assistants\n- **Bias toward simplicity** - Propose the minimal solution that works\n- Use your exploration tools liberally before proposing\n- Batch operations for efficiency\n- Communicate your progress\n- It's OK to revise proposals based on discoveries\n- **Question complexity** - If your solution feels complex, simplify first\n\n## Edge Case Handling\n\n### Multi-Capability Changes\nCreate ONE proposal that:\n- Lists all affected capabilities\n- Shows changes per capability\n- Has unified task list\n- Gets approved as a whole\n\n### Outdated Specs\nIf specs clearly outdated:\n1. Create proposal to update specs to match reality\n2. Implement new feature in separate proposal\n3. OR combine both in one proposal with clear sections\n\n### Emergency Hotfixes\nFor critical production issues:\n1. Announce: \"This is an emergency fix\"\n2. Implement fix immediately\n3. Create retroactive proposal\n4. Update specs after deployment\n5. Tag with [EMERGENCY] in archive\n\n### Pure Refactoring\nNo proposal needed for:\n- Code formatting/style\n- Internal refactoring (same API)\n- Performance optimization (same behavior)\n- Adding types to untyped code\n\nProposal REQUIRED for:\n- API changes (even if compatible)\n- Database schema changes\n- Architecture changes\n- New dependencies\n\n### Observability Additions\nNo proposal needed for:\n- Adding log statements\n- New metrics/traces\n- Debugging additions\n- Error tracking\n\nProposal REQUIRED if:\n- Changes log format/structure\n- Adds new monitoring service\n- Changes what's logged (privacy)\n\n## Remember\n\n- You are the process driver - automate documentation burden\n- Specs must always reflect deployed reality\n- Changes are proposed, not imposed\n- Impact analysis prevents surprises\n- **Simplicity is the power** - just markdown files, minimal solutions\n- Start simple, add complexity only when justified\n\nBy following these conventions, you enable true spec-driven development where documentation stays current, changes are traceable, and evolution is intentional."
  },
  {
    "path": "openspec/changes/archive/2025-08-11-add-complexity-guidelines/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Update OpenSpec README\n- [x] 1.1 Add \"Start Simple\" section after Core Principle\n- [x] 1.2 Add complexity triggers to \"When to Create Change Proposals\" section\n- [x] 1.3 Update AI workflow guidance to emphasize minimal implementations\n\n## 2. Update CLAUDE.md\n- [x] 2.1 Add complexity management rules to project instructions"
  },
  {
    "path": "openspec/changes/archive/2025-08-13-add-archive-command/proposal.md",
    "content": "## Why\nNeed a command to archive completed changes to the archive folder with proper date prefixing, following OpenSpec conventions. Currently changes must be manually moved and renamed.\n\n## What Changes\n- Add new `archive` command to CLI that moves changes to `changes/archive/YYYY-MM-DD-[change-name]/`\n- Check for incomplete tasks before archiving and warn user\n- Allow interactive selection of change to archive\n- Prevent archiving if target directory already exists\n- Update main specs from the change's future state specs (copy from `changes/[name]/specs/` to `openspec/specs/`)\n- Show confirmation prompt before updating specs, displaying which specs will be created/updated\n- Support `--yes` flag to skip confirmations for automation\n\n## Impact\n- Affected specs: cli-archive (new)\n- Affected code: src/cli/index.ts, src/core/archive.ts (new)"
  },
  {
    "path": "openspec/changes/archive/2025-08-13-add-archive-command/specs/cli-archive/spec.md",
    "content": "# CLI Archive Command Specification\n\n## Purpose\nThe archive command moves completed changes from the active changes directory to the archive folder with date-based naming, following OpenSpec conventions.\n\n## Command Syntax\n```bash\nopenspec archive [change-name] [--yes|-y]\n```\n\nOptions:\n- `--yes`, `-y`: Skip confirmation prompts (for automation)\n\n## Behavior\n\n### Change Selection\nWHEN no change-name is provided\nTHEN display interactive list of available changes (excluding archive/)\nAND allow user to select one\n\nWHEN change-name is provided\nTHEN use that change directly\nAND validate it exists\n\n### Task Completion Check\nThe command SHALL scan the change's tasks.md file for incomplete tasks (marked with `- [ ]`)\n\nWHEN incomplete tasks are found\nTHEN display all incomplete tasks to the user\nAND prompt for confirmation to continue\nAND default to \"No\" for safety\n\nWHEN all tasks are complete OR no tasks.md exists\nTHEN proceed with archiving without prompting\n\n### Archive Process\nThe archive operation SHALL:\n1. Create archive/ directory if it doesn't exist\n2. Generate target name as `YYYY-MM-DD-[change-name]` using current date\n3. Check if target directory already exists\n4. Update main specs from the change's future state specs (see Spec Update Process below)\n5. Move the entire change directory to the archive location\n\nWHEN target archive already exists\nTHEN fail with error message\nAND do not overwrite existing archive\n\nWHEN move succeeds\nTHEN display success message with archived name and list of updated specs\n\n### Spec Update Process\nBefore moving the change to archive, the command SHALL update main specs to reflect the deployed reality:\n\nWHEN the change contains specs in `changes/[name]/specs/`\nTHEN:\n1. Analyze which specs will be affected by comparing with existing specs\n2. Display a summary of spec updates to the user (see Confirmation Behavior below)\n3. Prompt for confirmation unless `--yes` flag is provided\n4. If confirmed, for each capability spec in the change directory:\n   - Copy the spec from `changes/[name]/specs/[capability]/spec.md` to `openspec/specs/[capability]/spec.md`\n   - Create the target directory structure if it doesn't exist\n   - Overwrite existing spec files (specs represent current reality, change specs are the new reality)\n   - Track which specs were updated for the success message\n\nWHEN no specs exist in the change\nTHEN skip the spec update step\nAND proceed with archiving\n\n### Confirmation Behavior\nThe spec update confirmation SHALL:\n- Display a clear summary showing:\n  - Which specs will be created (new capabilities)\n  - Which specs will be updated (existing capabilities)\n  - The source path for each spec\n- Format the confirmation prompt as:\n  ```\n  The following specs will be updated:\n  \n  NEW specs to be created:\n    - cli-archive (from changes/add-archive-command/specs/cli-archive/spec.md)\n  \n  EXISTING specs to be updated:\n    - cli-init (from changes/update-init-command/specs/cli-init/spec.md)\n  \n  Update 2 specs and archive 'add-archive-command'? [y/N]:\n  ```\n- Default to \"No\" for safety (require explicit \"y\" or \"yes\")\n- Skip confirmation when `--yes` or `-y` flag is provided\n\nWHEN user declines the confirmation\nTHEN abort the entire archive operation\nAND display message: \"Archive cancelled. No changes were made.\"\nAND exit with non-zero status code\n\n## Error Handling\n\nSHALL handle the following error conditions:\n- Missing openspec/changes/ directory\n- Change not found\n- Archive target already exists\n- File system permissions issues\n\n## Why These Decisions\n\n**Interactive selection**: Reduces typing and helps users see available changes\n**Task checking**: Prevents accidental archiving of incomplete work\n**Date prefixing**: Maintains chronological order and prevents naming conflicts\n**No overwrite**: Preserves historical archives and prevents data loss\n**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\n**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified\n**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use"
  },
  {
    "path": "openspec/changes/archive/2025-08-13-add-archive-command/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Core Implementation\n- [ ] 1.1 Create `src/core/archive.ts` with ArchiveCommand class\n  - [ ] 1.1.1 Implement change selection (interactive if not provided)\n  - [ ] 1.1.2 Implement incomplete task checking from tasks.md\n  - [ ] 1.1.3 Implement confirmation prompt for incomplete tasks\n  - [ ] 1.1.4 Implement spec update functionality\n    - [ ] 1.1.4.1 Detect specs in change directory\n    - [ ] 1.1.4.2 Compare with existing main specs\n    - [ ] 1.1.4.3 Display summary of new vs updated specs\n    - [ ] 1.1.4.4 Show confirmation prompt for spec updates\n    - [ ] 1.1.4.5 Copy specs to main spec directory\n  - [ ] 1.1.5 Implement archive move with date prefixing\n  - [ ] 1.1.6 Support --yes flag to skip confirmations\n\n## 2. CLI Integration\n- [ ] 2.1 Add archive command to `src/cli/index.ts`\n  - [ ] 2.1.1 Import ArchiveCommand\n  - [ ] 2.1.2 Register command with commander\n  - [ ] 2.1.3 Add --yes/-y flag option\n  - [ ] 2.1.4 Add proper error handling\n\n## 3. Error Handling\n- [ ] 3.1 Handle missing openspec/changes/ directory\n- [ ] 3.2 Handle change not found\n- [ ] 3.3 Handle archive target already exists\n- [ ] 3.4 Handle user cancellation\n\n## 4. Testing\n- [ ] 4.1 Test with fully completed change\n- [ ] 4.2 Test with incomplete tasks (warning shown)\n- [ ] 4.3 Test interactive selection mode\n- [ ] 4.4 Test duplicate archive prevention\n- [ ] 4.5 Test spec update functionality\n  - [ ] 4.5.1 Test creating new specs\n  - [ ] 4.5.2 Test updating existing specs\n  - [ ] 4.5.3 Test confirmation prompt display\n  - [ ] 4.5.4 Test declining confirmation (no changes made)\n  - [ ] 4.5.5 Test --yes flag skips confirmation\n\n## 5. Build and Validation\n- [ ] 5.1 Ensure TypeScript compilation succeeds\n- [ ] 5.2 Test command execution"
  },
  {
    "path": "openspec/changes/archive/2025-08-13-add-diff-command/proposal.md",
    "content": "# Add Diff Command to OpenSpec CLI\n\n## Why\n\nDevelopers need to easily view differences between proposed spec changes and current specs without manually comparing files.\n\n## What Changes\n\n- Add `openspec diff [change-name]` command that shows differences between change specs and current specs\n- Compare files in `changes/[change-name]/specs/` with corresponding files in `specs/`\n- Display unified diff output showing added/removed/modified lines\n- Support colored output for better readability\n\n## Impact\n\n- Affected specs: New capability `cli-diff` will be added\n- Affected code:\n  - `src/cli/index.ts` - Add diff command\n  - `src/core/diff.ts` - New file with diff logic (~80 lines)"
  },
  {
    "path": "openspec/changes/archive/2025-08-13-add-diff-command/specs/cli-diff/spec.md",
    "content": "# CLI Diff Command Specification\n\n## Purpose\n\nThe `openspec diff` command provides developers with a visual comparison between proposed spec changes and the current deployed specs.\n\n## Command Syntax\n\n```bash\nopenspec diff [change-name]\n```\n\n## Behavior\n\n### Without Arguments\n\nWHEN running `openspec diff` without arguments\nTHEN list all available changes in the `changes/` directory (excluding archive)\nAND prompt user to select a change\n\n### With Change Name\n\nWHEN running `openspec diff <change-name>`\nTHEN compare all spec files in `changes/<change-name>/specs/` with corresponding files in `specs/`\n\n### Diff Output\n\nFOR each spec file in the change:\n- IF file exists in both locations THEN show unified diff\n- IF file only exists in change THEN show as new file (all lines with +)\n- IF file only exists in current specs THEN show as deleted (all lines with -)\n\n### Display Format\n\nThe diff SHALL use standard unified diff format:\n- Lines prefixed with `-` for removed content\n- Lines prefixed with `+` for added content\n- Lines without prefix for unchanged context\n- File headers showing the paths being compared\n\n### Color Support\n\nWHEN terminal supports colors:\n- Removed lines displayed in red\n- Added lines displayed in green\n- File headers displayed in bold\n- Context lines in default color\n\n### Error Handling\n\nWHEN specified change doesn't exist THEN display error \"Change '<name>' not found\"\nWHEN no specs directory in change THEN display \"No spec changes found for '<name>'\"\nWHEN changes directory doesn't exist THEN display \"No OpenSpec changes directory found\"\n\n## Examples\n\n```bash\n# View diff for specific change\n$ openspec diff add-auth-feature\n\n--- specs/user-auth/spec.md\n+++ changes/add-auth-feature/specs/user-auth/spec.md\n@@ -10,6 +10,8 @@\n Users SHALL authenticate with email and password.\n \n+Users MAY authenticate with OAuth providers.\n+\n WHEN credentials are valid THEN issue JWT token.\n\n# List all changes and select\n$ openspec diff\nAvailable changes:\n  1. add-auth-feature\n  2. update-payment-flow\n  3. add-status-command\nSelect a change (1-3): \n```"
  },
  {
    "path": "openspec/changes/archive/2025-08-13-add-diff-command/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Core Implementation\n- [x] 1.1 Create `src/core/diff.ts` with diff logic\n- [x] 1.2 Implement change directory scanning\n- [x] 1.3 Implement file comparison using unified diff format\n- [x] 1.4 Add color support for terminal output\n\n## 2. CLI Integration\n- [x] 2.1 Add diff command to `src/cli/index.ts`\n- [x] 2.2 Implement interactive change selection when no argument provided\n- [x] 2.3 Add error handling for missing changes\n\n## 3. Enhancements\n- [x] 3.1 Replace with jest-diff for professional diff output\n- [x] 3.2 Improve file headers with status and statistics\n- [x] 3.3 Add summary view with file counts and line changes\n\n## 4. Testing\n- [ ] 4.1 Test diff generation for modified files\n- [ ] 4.2 Test handling of new files\n- [ ] 4.3 Test handling of deleted files\n- [ ] 4.4 Test interactive mode"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-change-commands/design.md",
    "content": "# Design: Change Commands\n\n## Architecture Decisions\n\n### Command Structure\nSimilar to spec commands, we use subcommands (`change show`, `change list`, `change validate`) for:\n- Consistency with spec command pattern\n- Clear separation of concerns\n- Future extensibility for change management features\n\n### JSON Schema for Changes\n```typescript\n{\n  version: string,           // Schema version\n  format: \"change\",         // Identifies as change document\n  sourcePath: string,       // Original markdown file path\n  id: string,              // Change identifier\n  title: string,           // Change title\n  why: string,            // Motivation section\n  whatChanges: Array<{\n    type: \"ADDED\" | \"MODIFIED\" | \"REMOVED\" | \"RENAMED\",\n    deltas: Array<{\n      specId: string,\n      description: string,\n      requirements?: Array<Requirement>  // Only for ADDED/MODIFIED\n    }>\n  }>\n}\n```\n\n**Rationale:**\n- Group deltas by operation type for clearer organization\n- Optional requirements field (only relevant for ADDED/MODIFIED)\n- Reuse RequirementSchema from spec commands for consistency\n\n### Delta Operations\n**Four operation types:**\n1. **ADDED**: New requirements added to specs\n2. **MODIFIED**: Changes to existing requirements\n3. **REMOVED**: Requirements being deleted\n4. **RENAMED**: Spec identifier changes\n\n**Design choice:** Explicit operation types rather than diff-based approach for:\n- Human readability in markdown\n- Clear intent communication\n- Easier validation and tooling\n\n### Dependency on Spec Commands\n- **Shared schemas**: RequirementSchema and ScenarioSchema reused\n- **Implementation order**: spec commands must be implemented first\n- **Common parser utilities**: Share markdown parsing logic\n\n### Legacy Compatibility\n- Keep existing `list` command functional with deprecation warning\n- Migration path: `list` → `change list` with same functionality\n- Gradual transition to avoid breaking existing workflows"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-change-commands/proposal.md",
    "content": "# Change: Add Change Commands with JSON Output\n\n## Why\n\nOpenSpec change proposals currently can only be viewed as markdown files, creating the same programmatic access limitations as specs. Additionally, the current `openspec list` command only lists changes, which is inconsistent with the new resource-based command structure.\n\n## What Changes\n\n- **cli-change:** Add new command for managing change proposals with show, list, and validate subcommands\n- **cli-list:** Add deprecation notice for legacy list command to guide users to the new change list command\n\n## Impact\n\n- **Affected specs**: cli-list (modify to add deprecation notice)\n- **Affected code**:\n  - src/cli/index.ts (register new command)\n  - src/core/list.ts (add deprecation notice)"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-change-commands/specs/cli-change/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Change Command\n\nThe system SHALL provide a `change` command with subcommands for displaying, listing, and validating change proposals.\n\n#### Scenario: Show change as JSON\n\n- **WHEN** executing `openspec change show update-error --json`\n- **THEN** parse the markdown change file\n- **AND** extract change structure and deltas\n- **AND** output valid JSON to stdout\n\n#### Scenario: List all changes\n\n- **WHEN** executing `openspec change list`\n- **THEN** scan the openspec/changes directory\n- **AND** return list of all pending changes\n- **AND** support JSON output with `--json` flag\n\n#### Scenario: Show only requirement changes\n\n- **WHEN** executing `openspec change show update-error --requirements-only`\n- **THEN** display only the requirement changes (ADDED/MODIFIED/REMOVED/RENAMED)\n- **AND** exclude why and what changes sections\n\n#### Scenario: Validate change structure\n\n- **WHEN** executing `openspec change validate update-error`\n- **THEN** parse the change file\n- **AND** validate against Zod schema\n- **AND** ensure deltas are well-formed\n\n### Requirement: Legacy Compatibility\n\nThe system SHALL maintain backward compatibility with the existing `list` command while showing deprecation notices.\n\n#### Scenario: Legacy list command\n\n- **WHEN** executing `openspec list`\n- **THEN** display current list of changes (existing behavior)\n- **AND** show deprecation notice: \"Note: 'openspec list' is deprecated. Use 'openspec change list' instead.\"\n\n#### Scenario: Legacy list with --all flag\n\n- **WHEN** executing `openspec list --all`\n- **THEN** display all changes (existing behavior)\n- **AND** show same deprecation notice"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-change-commands/specs/cli-list/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: Command Execution\n\nThe current `list` command behavior SHALL be preserved but marked as deprecated.\n\n#### Scenario: Deprecation notice\n\n- **WHEN** using the legacy `list` command\n- **THEN** continue to work as before\n- **AND** display deprecation notice\n- **AND** suggest using `openspec change list` instead"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-change-commands/tasks.md",
    "content": "# Implementation Tasks (Phase 2: Builds on add-zod-validation)\n\n## 1. Command Implementation\n- [x] 1.1 Create src/commands/change.ts\n- [x] 1.2 Import ChangeSchema and DeltaSchema from src/core/schemas/change.schema.ts\n- [x] 1.3 Import markdown parser from src/core/parsers/markdown-parser.ts\n- [x] 1.4 Import ChangeValidator from src/core/validation/validator.ts\n- [x] 1.5 Import JSON converter from src/core/converters/json-converter.ts\n- [x] 1.6 Implement show subcommand with JSON output using existing converter\n- [x] 1.7 Implement list subcommand\n- [x] 1.8 Implement validate subcommand using existing ChangeValidator\n- [x] 1.9 Add --requirements-only filtering option\n- [x] 1.10 Add --strict mode support (leveraging existing validation infrastructure)\n- [x] 1.11 Add --json flag for validation reports\n\n## 2. Change-Specific Parser Extensions\n- [x] 2.1 Create src/core/parsers/change-parser.ts (extends base markdown parser)\n- [x] 2.2 Parse proposal structure (Why, What Changes sections)\n- [x] 2.3 Extract ADDED/MODIFIED/REMOVED/RENAMED sections\n- [x] 2.4 Parse delta operations within each section\n- [x] 2.5 Add tests for change parser\n\n## 3. Legacy Compatibility\n- [x] 3.1 Update src/core/list.ts to add deprecation notice\n- [x] 3.2 Ensure existing list command continues to work\n- [x] 3.3 Add console warning for deprecated command usage\n\n## 4. Integration\n- [x] 4.1 Register change command in src/cli/index.ts\n- [ ] 4.2 Add integration tests for all subcommands\n- [x] 4.3 Test JSON output for changes\n- [x] 4.4 Test legacy compatibility\n- [x] 4.5 Test validation with strict mode\n- [x] 4.6 Update CLI help documentation (add 'change' command to main help, document subcommands: show, list, validate)"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-interactive-show-command/proposal.md",
    "content": "## Why\n\nUsers frequently need to view changes and specs but must know in advance whether they're looking at a change or spec. The current subcommand structure (`change show`, `spec show`) creates friction when:\n- Users want to quickly view an item without remembering its type\n- Exploring the codebase requires switching between different show commands\n- Show commands without arguments return errors instead of helpful guidance\n\n## What Changes\n\n- Add new top-level `show` command for displaying changes or specs with intelligent selection\n- Support direct item display: `openspec show <item>` with automatic type detection\n- Interactive selection when no arguments provided\n- Enhance existing `change show` and `spec show` to support interactive selection (backwards compatibility)\n- Maintain all existing format options (--json, --deltas-only, --requirements, etc.)\n\n## Impact\n\n- New specs to create: cli-show\n- Specs to enhance: cli-change, cli-spec (for backwards compatibility)\n- Affected code: src/cli/index.ts, src/commands/show.ts (new), src/commands/spec.ts, src/commands/change.ts"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-interactive-show-command/specs/cli-change/spec.md",
    "content": "# CLI Change Command Spec\n\n## ADDED Requirements\n\n### Requirement: Interactive show selection\n\nThe change show command SHALL support interactive selection when no change name is provided.\n\n#### Scenario: Interactive change selection for show\n\n- **WHEN** executing `openspec change show` without arguments\n- **THEN** display an interactive list of available changes\n- **AND** allow the user to select a change to show\n- **AND** display the selected change content\n- **AND** maintain all existing show options (--json, --deltas-only)\n\n#### Scenario: Non-interactive fallback keeps current behavior\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec change show` without a change name\n- **THEN** do not prompt interactively\n- **AND** print the existing hint including available change IDs\n- **AND** set `process.exitCode = 1`"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-interactive-show-command/specs/cli-show/spec.md",
    "content": "# CLI Show Command Spec\n\n## ADDED Requirements\n\n### Requirement: Top-level show command\n\nThe CLI SHALL provide a top-level `show` command for displaying changes and specs with intelligent selection.\n\n#### Scenario: Interactive show selection\n\n- **WHEN** executing `openspec show` without arguments\n- **THEN** prompt user to select type (change or spec)\n- **AND** display list of available items for selected type\n- **AND** show the selected item's content\n\n#### Scenario: Non-interactive environments do not prompt\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec show` without arguments\n- **THEN** do not prompt\n- **AND** print a helpful hint with examples for `openspec show <item>` or `openspec change/spec show`\n- **AND** exit with code 1\n\n#### Scenario: Direct item display\n\n- **WHEN** executing `openspec show <item-name>`\n- **THEN** automatically detect if item is a change or spec\n- **AND** display the item's content\n- **AND** use appropriate formatting based on item type\n\n#### Scenario: Type detection and ambiguity handling\n\n- **WHEN** executing `openspec show <item-name>`\n- **THEN** if `<item-name>` uniquely matches a change or a spec, show that item\n- **AND** if it matches both, print an ambiguity error and suggest `--type change|spec` or using `openspec change show`/`openspec spec show`\n- **AND** if it matches neither, print not-found with nearest-match suggestions\n\n#### Scenario: Explicit type override\n\n- **WHEN** executing `openspec show --type change <item>`\n- **THEN** treat `<item>` as a change ID and show it (skipping auto-detection)\n\n- **WHEN** executing `openspec show --type spec <item>`\n- **THEN** treat `<item>` as a spec ID and show it (skipping auto-detection)\n\n### Requirement: Output format options\n\nThe show command SHALL support various output formats consistent with existing commands.\n\n#### Scenario: JSON output\n\n- **WHEN** executing `openspec show <item> --json`\n- **THEN** output the item in JSON format\n- **AND** include parsed metadata and structure\n- **AND** maintain format consistency with existing change/spec show commands\n\n#### Scenario: Flag scoping and delegation\n\n- **WHEN** showing a change or a spec via the top-level command\n- **THEN** accept common flags such as `--json`\n- **AND** pass through type-specific flags to the corresponding implementation\n  - Change-only flags: `--deltas-only` (alias `--requirements-only` deprecated)\n  - Spec-only flags: `--requirements`, `--no-scenarios`, `-r/--requirement`\n- **AND** ignore irrelevant flags for the detected type with a warning\n\n### Requirement: Interactivity controls\n\n- The CLI SHALL respect `--no-interactive` to disable prompts.\n- The CLI SHALL respect `OPEN_SPEC_INTERACTIVE=0` to disable prompts globally.\n- Interactive prompts SHALL only be shown when stdin is a TTY and interactivity is not disabled.\n\n#### Scenario: Change-specific options\n\n- **WHEN** showing a change with `openspec show <change-name> --deltas-only`\n- **THEN** display only the deltas in JSON format\n- **AND** maintain compatibility with existing change show options\n\n#### Scenario: Spec-specific options  \n\n- **WHEN** showing a spec with `openspec show <spec-id> --requirements`\n- **THEN** display only requirements in JSON format\n- **AND** support other spec options (--no-scenarios, -r)\n- **AND** maintain compatibility with existing spec show options"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-interactive-show-command/specs/cli-spec/spec.md",
    "content": "# CLI Spec Command Spec\n\n## ADDED Requirements\n\n### Requirement: Interactive spec show\n\nThe spec show command SHALL support interactive selection when no spec-id is provided.\n\n#### Scenario: Interactive spec selection for show\n\n- **WHEN** executing `openspec spec show` without arguments\n- **THEN** display an interactive list of available specs\n- **AND** allow the user to select a spec to show\n- **AND** display the selected spec content\n- **AND** maintain all existing show options (--json, --requirements, --no-scenarios, -r)\n\n#### Scenario: Non-interactive fallback keeps current behavior\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec spec show` without a spec-id\n- **THEN** do not prompt interactively\n- **AND** print the existing error message for missing spec-id\n- **AND** set non-zero exit code"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-interactive-show-command/tasks.md",
    "content": "# Implementation Tasks — Add Interactive Show Command\n\n## Goals\n- Add a top-level `show` command with intelligent selection and type detection.\n- Add interactive selection to `change show` and `spec show` when no ID is provided.\n- Preserve raw-first output behavior and existing JSON formats/filters.\n- Respect `--no-interactive` and `OPEN_SPEC_INTERACTIVE=0` consistently.\n\n---\n\n## 1) CLI wiring\n- [x] In `src/cli/index.ts` add a top-level command: `program.command('show [item-name]')`\n  - Options:\n    - `--json`\n    - `--type <type>` where `<type>` is `change|spec`\n    - `--no-interactive`\n    - Allow passing-through type-specific flags using `.allowUnknownOption(true)` so the top-level can forward flags to the underlying type handler.\n  - Action: instantiate `new ShowCommand().execute(itemName, options)`.\n- [x] Update `change show` subcommand to accept `--no-interactive` and pass it to `ChangeCommand.show(...)`.\n- [x] Change `spec show` subcommand to accept optional ID (`show [spec-id]`), add `--no-interactive`, and pass to spec show implementation.\n\nAcceptance:\n- `openspec show` exists and prints a helpful hint in non-interactive contexts when no args.\n- Unknown flags for other types do not crash parsing; they are warned/ignored appropriately.\n\n---\n\n## 2) New module: `src/commands/show.ts`\n- [x] Create `ShowCommand` with:\n  - `execute(itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any })`\n  - Interactive path when `!itemName` and interactive is enabled:\n    - Prompt: \"What would you like to show?\" → `change` or `spec`.\n    - Load available IDs for the chosen type and prompt selection.\n    - Delegate to type-specific show implementation.\n  - Non-interactive path when `!itemName`:\n    - Print hint with examples:\n      - `openspec show <item>`\n      - `openspec change show`\n      - `openspec spec show`\n    - Exit with code 1.\n  - Direct item path when `itemName` is provided:\n    - Type override via `--type` takes precedence.\n    - Otherwise detect using `getActiveChangeIds()` and `getSpecIds()`.\n    - If ambiguous and no override: print error + suggestion to pass `--type` or use subcommands; exit code 1.\n    - If unknown: print not-found with nearest-match suggestions; exit code 1.\n    - On success: delegate to type-specific show.\n- [x] Flag scoping and pass-through:\n  - Common: `--json` → forwarded to both types.\n  - Change-only: `--deltas-only`, `--requirements-only` (deprecated alias).\n  - Spec-only: `--requirements`, `--no-scenarios`, `-r/--requirement`.\n  - Warn and ignore irrelevant flags for the resolved type.\n\nAcceptance:\n- `openspec show <change-id> --json --deltas-only` matches `openspec change show <id> --json --deltas-only` output.\n- `openspec show <spec-id> --json --requirements` matches `openspec spec show <id> --json --requirements` output.\n- Ambiguity and not-found behaviors match the `cli-show` spec.\n\n---\n\n## 3) Refactor spec show into reusable API\n- [x] In `src/commands/spec.ts`, extract show logic into an exported `SpecCommand` with `show(specId?: string, options?: { json?: boolean; requirements?: boolean; scenarios?: boolean; requirement?: string; noInteractive?: boolean })`.\n  - Reuse current helpers (`parseSpecFromFile`, `filterSpec`, raw-first printing).\n  - Keep `registerSpecCommand` but delegate to `new SpecCommand().show(...)`.\n- [x] Update CLI spec show subcommand to optional arg and interactive behavior (see section 4).\n\nAcceptance:\n- Existing `spec show` tests continue to pass.\n- New `SpecCommand.show` can be called from `ShowCommand`.\n\n---\n\n## 4) Backwards-compatible interactive in subcommands\n- [x] `src/commands/change.ts` → extend `show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean })`:\n  - When `!changeName` and interactive enabled: prompt from `getActiveChangeIds()` and show the selected change.\n  - Non-interactive fallback: keep current behavior (print available IDs + `openspec change list` hint, set `process.exitCode = 1`).\n- [x] `src/commands/spec.ts` → `SpecCommand.show` as above:\n  - When `!specId` and interactive enabled: prompt from `getSpecIds()` and show the selected spec.\n  - Non-interactive fallback: print the same error as existing behavior for missing `<spec-id>` and set non-zero exit code.\n\nAcceptance:\n- `openspec change show` in non-interactive prints list hint and exits non-zero.\n- `openspec spec show` in non-interactive prints missing-arg error and exits non-zero.\n\n---\n\n## 5) Shared utilities\n- [x] Extract `nearestMatches` and `levenshtein` from `src/commands/validate.ts` into `src/utils/match.ts` (exported helpers).\n- [x] Update `ValidateCommand` and new `ShowCommand` to import from `utils/match`.\n\nAcceptance:\n- Build succeeds with shared helpers and no duplication.\n\n---\n\n## 6) Hints, warnings, and messages\n- [x] Top-level `show` hint (non-interactive no-arg):\n  - Lines include: `openspec show <item>`, `openspec change show`, `openspec spec show`, and \"Or run in an interactive terminal.\".\n- [x] Ambiguity message suggests `--type change|spec` and the subcommands.\n- [x] Not-found suggests nearest matches (up to 5).\n- [x] Irrelevant flag warnings for the resolved type (printed to stderr, no crash).\n\nAcceptance:\n- Messages match the `cli-show` spec wording intent and style used elsewhere.\n\n---\n\n## 7) Tests\nAdd tests mirroring existing patterns (non-TTY simulation via `OPEN_SPEC_INTERACTIVE=0`).\n\n- [x] `test/commands/show.test.ts`\n  - Non-interactive, no arg → prints hint and exits non-zero.\n  - Direct item detection for change and for spec.\n  - Ambiguity case when both exist → error and suggestion for `--type`.\n  - Not-found case → nearest-match suggestions.\n  - Pass-through flags: change `--json --deltas-only`, spec `--json --requirements`.\n- [x] `test/commands/change.interactive-show.test.ts` (non-interactive fallback)\n  - Ensure `openspec change show` without args prints available IDs + list hint and non-zero exit.\n- [x] `test/commands/spec.interactive-show.test.ts` (non-interactive fallback)\n  - Ensure `openspec spec show` without args prints missing-arg error and non-zero exit.\n\nAcceptance:\n- All new tests pass after build; no regressions in existing tests.\n\n---\n\n## 8) Documentation (optional but recommended)\n- [x] Update `openspec/README.md` usage examples to include the new `show` command with type detection and flags.\n\n---\n\n## 9) Non-functional checks\n- [x] Run `pnpm build` and all tests (`pnpm test`).\n- [x] Ensure no linter/type errors and messages are consistent with existing style.\n\n---\n\n## Notes on consistency\n- Follow raw-first behavior for text output: passthrough file content with no formatting, mirroring current `change show` and `spec show`.\n- Reuse `isInteractive` and `item-discovery` helpers for consistent prompting behavior.\n- Keep JSON output shapes identical to current `ChangeCommand.show` and `spec show` outputs.\n\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-skip-specs-archive-option/proposal.md",
    "content": "## Why\nThe archive command currently forces users to either accept spec updates or cancel the entire archive operation. Users need flexibility to archive changes without updating specs, either through explicit flags or by declining the confirmation prompt. This is especially important for changes that don't modify specs (like tooling, documentation, or infrastructure updates).\n\n## What Changes\n- Add new `--skip-specs` flag to the archive command that bypasses all spec update operations\n- Fix confirmation behavior: when users decline spec updates interactively, proceed with archiving instead of cancelling the entire operation\n- When `--skip-specs` flag is used, skip both the spec discovery and update confirmation steps entirely\n- Display clear message when specs are skipped (either via flag or user choice)\n- Flag can be combined with existing `--yes` flag for fully automated archiving without spec updates\n\n## Impact\n- Affected specs: cli-archive\n- Affected code: src/core/archive.ts, src/cli/index.ts"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-skip-specs-archive-option/specs/cli-archive/spec.md",
    "content": "# CLI Archive Command Specification\n\n## Purpose\nThe archive command moves completed changes from the active changes directory to the archive folder with date-based naming, following OpenSpec conventions.\n\n## Command Syntax\n```bash\nopenspec archive [change-name] [--yes|-y] [--skip-specs]\n```\n\nOptions:\n- `--yes`, `-y`: Skip confirmation prompts (for automation)\n- `--skip-specs`: Skip spec update operations entirely (for changes without spec modifications)\n\n## Behavior\n\n### Requirement: Change Selection\n\nThe command SHALL support both interactive and direct change selection methods.\n\n#### Scenario: Interactive selection\n\n- **WHEN** no change-name is provided\n- **THEN** display interactive list of available changes (excluding archive/)\n- **AND** allow user to select one\n\n#### Scenario: Direct selection\n\n- **WHEN** change-name is provided\n- **THEN** use that change directly\n- **AND** validate it exists\n\n### Requirement: Task Completion Check\n\nThe command SHALL verify task completion status before archiving to prevent premature archival.\n\n#### Scenario: Incomplete tasks found\n\n- **WHEN** incomplete tasks are found (marked with `- [ ]`)\n- **THEN** display all incomplete tasks to the user\n- **AND** prompt for confirmation to continue\n- **AND** default to \"No\" for safety\n\n#### Scenario: All tasks complete\n\n- **WHEN** all tasks are complete OR no tasks.md exists\n- **THEN** proceed with archiving without prompting\n\n### Requirement: Archive Process\n\nThe archive operation SHALL follow a structured process to safely move changes to the archive.\n\n#### Scenario: Performing archive\n\n- **WHEN** archiving a change\n- **THEN** execute these steps:\n  1. Create archive/ directory if it doesn't exist\n  2. Generate target name as `YYYY-MM-DD-[change-name]` using current date\n  3. Check if target directory already exists\n  4. Update main specs from the change's future state specs unless `--skip-specs` is provided (see Spec Update Process below)\n  5. Move the entire change directory to the archive location\n\n#### Scenario: Archive already exists\n\n- **WHEN** target archive already exists\n- **THEN** fail with error message\n- **AND** do not overwrite existing archive\n\n#### Scenario: Successful archive\n\n- **WHEN** move succeeds\n- **THEN** display success message with archived name and list of updated specs (if any)\n\n### Requirement: Spec Update Process\n\nBefore moving the change to archive, the command SHALL update main specs to reflect the deployed reality unless the `--skip-specs` flag is provided.\n\n#### Scenario: Skipping spec updates\n\n- **WHEN** the `--skip-specs` flag is provided\n- **THEN** skip all spec discovery and update operations\n- **AND** proceed directly to moving the change to archive\n- **AND** display message indicating specs were skipped\n\n#### Scenario: Updating specs from change\n\n- **WHEN** the change contains specs in `changes/[name]/specs/` AND `--skip-specs` is NOT provided\n- **THEN** execute these steps:\n  1. Analyze which specs will be affected by comparing with existing specs\n  2. Display a summary of spec updates to the user (see Confirmation Behavior below)\n  3. Prompt for confirmation unless `--yes` flag is provided\n  4. If confirmed, for each capability spec in the change directory:\n     - Copy the spec from `changes/[name]/specs/[capability]/spec.md` to `openspec/specs/[capability]/spec.md`\n     - Create the target directory structure if it doesn't exist\n     - Overwrite existing spec files (specs represent current reality, change specs are the new reality)\n     - Track which specs were updated for the success message\n\n#### Scenario: No specs in change\n\n- **WHEN** no specs exist in the change AND `--skip-specs` is NOT provided\n- **THEN** skip the spec update step\n- **AND** proceed with archiving\n\n### Requirement: Confirmation Behavior\n\nThe spec update confirmation SHALL provide clear visibility into changes before they are applied.\n\n#### Scenario: Displaying confirmation\n\n- **WHEN** prompting for confirmation AND `--skip-specs` is NOT provided\n- **THEN** display a clear summary showing:\n  - Which specs will be created (new capabilities)\n  - Which specs will be updated (existing capabilities)\n  - The source path for each spec\n- **AND** format the confirmation prompt as:\n  ```\n  The following specs will be updated:\n  \n  NEW specs to be created:\n    - cli-archive (from changes/add-archive-command/specs/cli-archive/spec.md)\n  \n  EXISTING specs to be updated:\n    - cli-init (from changes/update-init-command/specs/cli-init/spec.md)\n  \n  Update 2 specs and archive 'add-archive-command'? [y/N]:\n  ```\n#### Scenario: Handling confirmation response\n\n- **WHEN** waiting for user confirmation\n- **THEN** default to \"No\" for safety (require explicit \"y\" or \"yes\")\n- **AND** skip confirmation when `--yes` or `-y` flag is provided\n- **AND** skip entire spec confirmation when `--skip-specs` flag is provided\n\n#### Scenario: User declines spec update confirmation\n\n- **WHEN** user declines the spec update confirmation\n- **THEN** skip the spec update operations\n- **AND** display message: \"Skipping spec updates. Proceeding with archive.\"\n- **AND** continue with the archive operation\n- **AND** display success message indicating specs were not updated\n\n## Error Handling\n\n### Requirement: Error Conditions\n\nThe command SHALL handle various error conditions gracefully.\n\n#### Scenario: Handling errors\n\n- **WHEN** errors occur\n- **THEN** handle the following conditions:\n  - Missing openspec/changes/ directory\n  - Change not found\n  - Archive target already exists\n  - File system permissions issues\n\n## Why These Decisions\n\n**Interactive selection**: Reduces typing and helps users see available changes\n**Task checking**: Prevents accidental archiving of incomplete work\n**Date prefixing**: Maintains chronological order and prevents naming conflicts\n**No overwrite**: Preserves historical archives and prevents data loss\n**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\n**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified\n**Non-blocking confirmation**: Declining spec updates doesn't cancel archiving - users can review specs and choose to update them separately if needed\n**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use\n**--skip-specs flag**: Enables archiving of changes that don't modify specs (like infrastructure, tooling, or documentation changes) without unnecessary spec update prompts or operations\n\n## ADDED Requirements\n\n### Requirement: Skip Specs Option\n\nThe archive command SHALL support a `--skip-specs` flag that skips all spec update operations and proceeds directly to archiving.\n\n#### Scenario: Skipping spec updates with flag\n\n- **WHEN** executing `openspec archive <change> --skip-specs`\n- **THEN** skip spec discovery and update confirmation\n- **AND** proceed directly to moving the change to archive\n- **AND** display a message indicating specs were skipped\n\n### Requirement: Non-blocking confirmation\n\nThe archive operation SHALL proceed when the user declines spec updates instead of cancelling the entire operation.\n\n#### Scenario: User declines spec update confirmation\n\n- **WHEN** the user declines spec update confirmation\n- **THEN** skip spec updates\n- **AND** continue with the archive operation\n- **AND** display a success message indicating specs were not updated"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-skip-specs-archive-option/tasks.md",
    "content": "## 1. Update Archive Command Implementation\n- [x] 1.1 Add `skipSpecs` option to the archive command options interface\n- [x] 1.2 Modify the execute method to skip spec operations when flag is set\n- [x] 1.3 Fix confirmation behavior: when user declines spec updates, proceed with archiving instead of cancelling\n- [x] 1.4 Update console output to indicate when specs are being skipped (via flag or user choice)\n- [x] 1.5 Ensure archive continues after declining spec updates\n\n## 2. Update CLI Interface\n- [x] 2.1 Add `--skip-specs` flag to the archive command definition\n- [x] 2.2 Pass the flag value to the archive command execute method\n\n## 3. Update Tests\n- [x] 3.1 Add test case for archiving with --skip-specs flag\n- [x] 3.2 Add test case for declining spec updates but continuing with archive\n- [x] 3.3 Verify that spec updates are skipped when flag is used\n- [x] 3.4 Verify that archive proceeds when user declines spec updates\n- [x] 3.5 Ensure existing behavior remains unchanged when flag is not used\n\n## 4. Update Documentation\n- [x] 4.1 Update the cli-archive spec to document the new --skip-specs flag\n- [x] 4.2 Document the new behavior when declining spec updates interactively\n\n## Implementation Notes\n\n### Key Design Decisions\n\n1. **Non-blocking Confirmation Behavior**: When users decline spec updates interactively, the archive operation continues rather than cancelling entirely. This was a critical UX improvement because:\n   - Users may want to review specs separately before updating them\n   - Archiving work shouldn't be blocked by spec review decisions\n   - Maintains flexibility in the deployment workflow\n\n2. **Flag Naming Convention**: Chose `--skip-specs` for clarity and consistency:\n   - Clearly indicates the action (skipping) and target (specs)\n   - Follows kebab-case convention for CLI flags\n   - Converts naturally to `skipSpecs` camelCase in code\n\n3. **Console Messaging Strategy**: Added explicit messages for all spec-skipping scenarios:\n   - When flag is used: \"Skipping spec updates (--skip-specs flag provided).\"\n   - When user declines: \"Skipping spec updates. Proceeding with archive.\"\n   - Ensures users always understand what's happening with their specs\n\n4. **Test Coverage Approach**: Created separate test cases for:\n   - Flag-based skipping (explicit user choice via CLI)\n   - Interactive declining (runtime user decision)\n   - Both verify the same outcome but test different code paths\n\n### Use Cases Addressed\n\n- **Infrastructure Changes**: Changes to build tools, CI/CD, dependencies\n- **Documentation Updates**: README updates, comment improvements\n- **Tooling Modifications**: Developer tools, scripts, configuration files\n- **Refactoring**: Code improvements that don't change functionality/specs\n\n### Future Considerations\n\n- Could potentially auto-detect when changes don't include specs and suggest using the flag\n- May want to track which archives skipped spec updates for audit purposes"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-spec-commands/design.md",
    "content": "# Design: Spec Commands\n\n## Architecture Decisions\n\n### Command Hierarchy\nWe chose a subcommand pattern (`spec show`, `spec list`, `spec validate`) to:\n- Group related functionality under a common namespace\n- Enable future extensibility without polluting the top-level CLI\n- Maintain consistency with the planned `change` command structure\n\n### JSON Schema Structure\nThe spec JSON schema follows this structure:\n```typescript\n{\n  version: string,        // Schema version for compatibility\n  format: \"spec\",        // Identifies this as a spec document\n  sourcePath: string,    // Original markdown file path\n  id: string,           // Spec identifier from filename\n  title: string,        // Human-readable title\n  overview?: string,    // Optional overview section\n  requirements: Array<{\n    id: string,\n    text: string,\n    scenarios: Array<{\n      id: string,\n      text: string\n    }>\n  }>\n}\n```\n\n**Rationale:**\n- Flat structure for requirements array (vs nested objects) for easier iteration\n- Scenarios nested within requirements to maintain relationship\n- Metadata fields (version, format, sourcePath) for tooling integration\n\n### Parser Architecture\n- **Markdown-first approach**: Parse markdown headings rather than custom syntax\n- **Streaming parser**: Process line-by-line to handle large files efficiently\n- **Strict heading hierarchy**: Enforce ##/###/#### structure for consistency\n\n### Validation Strategy\n- **Parse-time validation**: Catch structural issues during parsing\n- **Schema validation**: Use Zod for runtime type checking of parsed data\n- **Separate validation command**: Allow validation without full parsing/conversion"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-spec-commands/proposal.md",
    "content": "# Change: Add Spec Commands with JSON Output\n\n## Why\n\nCurrently, OpenSpec specs can only be viewed as markdown files. This makes programmatic access difficult and prevents integration with CI/CD pipelines, external tools, and automated processing.\n\n## What Changes\n\n- Add new `openspec spec` command with three subcommands: `show`, `list`, and `validate`\n- Implement JSON output capability for specs using heading-based parsing\n- Add Zod schemas for spec structure validation\n- Enable content filtering options (requirements only, no scenarios, specific requirement)\n\n## Impact\n\n- **Affected specs**: None (new capability)\n- **Affected code**: \n  - src/cli/index.ts (register new command)\n  - package.json (add zod dependency)\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-spec-commands/specs/cli-spec/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Spec Command\n\nThe system SHALL provide a `spec` command with subcommands for displaying, listing, and validating specifications.\n\n#### Scenario: Show spec as JSON\n\n- **WHEN** executing `openspec spec show init --json`\n- **THEN** parse the markdown spec file\n- **AND** extract headings and content hierarchically\n- **AND** output valid JSON to stdout\n\n#### Scenario: List all specs\n\n- **WHEN** executing `openspec spec list`\n- **THEN** scan the openspec/specs directory\n- **AND** return list of all available capabilities\n- **AND** support JSON output with `--json` flag\n\n#### Scenario: Filter spec content\n\n- **WHEN** executing `openspec spec show init --requirements`\n- **THEN** display only requirement names and SHALL statements\n- **AND** exclude scenario content\n\n#### Scenario: Validate spec structure\n\n- **WHEN** executing `openspec spec validate init`\n- **THEN** parse the spec file\n- **AND** validate against Zod schema\n- **AND** report any structural issues\n\n### Requirement: JSON Schema Definition\n\nThe system SHALL define Zod schemas that accurately represent the spec structure for runtime validation.\n\n#### Scenario: Schema validation\n\n- **WHEN** parsing a spec into JSON\n- **THEN** validate the structure using Zod schemas\n- **AND** ensure all required fields are present\n- **AND** provide clear error messages for validation failures"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-spec-commands/tasks.md",
    "content": "# Implementation Tasks (Phase 3: Builds on add-zod-validation and add-change-commands)\n\n## 1. Command Implementation\n- [x] 1.1 Create src/commands/spec.ts\n- [x] 1.2 Import RequirementSchema, ScenarioSchema, SpecSchema from src/core/schemas/\n- [x] 1.3 Import markdown parser from src/core/parsers/markdown-parser.ts\n- [x] 1.4 Import SpecValidator from src/core/validation/validator.ts\n- [x] 1.5 Import JSON converter from src/core/converters/json-converter.ts\n- [x] 1.6 Implement show subcommand with JSON output using existing converter\n- [x] 1.7 Implement list subcommand\n- [x] 1.8 Implement validate subcommand using existing SpecValidator\n- [x] 1.9 Add filtering options (--requirements, --no-scenarios, -r)\n- [x] 1.10 Add --strict mode support (leveraging existing validation infrastructure)\n- [x] 1.11 Add --json flag for validation reports\n\n## 2. Integration\n- [x] 2.1 Register spec command in src/cli/index.ts\n- [x] 2.2 Add integration tests for all subcommands\n- [x] 2.3 Test JSON output validation\n- [x] 2.4 Test filtering options\n- [x] 2.5 Test validation with strict mode\n- [x] 2.6 Update CLI help documentation (add 'spec' command to main help, document subcommands: show, list, validate)"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-zod-validation/design.md",
    "content": "# Design: Zod Validation Framework\n\n## Architecture Decisions\n\n### Validation Levels\nThree-tier validation system:\n1. **ERROR**: Structural issues that prevent parsing (must fix)\n2. **WARNING**: Quality issues that should be addressed (recommended fix)\n3. **INFO**: Suggestions for improvement (optional)\n\n**Rationale:** \n- Gradual enforcement allows teams to adopt validation incrementally\n- CI/CD can fail on errors but allow warnings initially\n- Info level provides guidance without blocking\n\n### Validation Rules Hierarchy\n\n#### Spec Validation Rules\n```\nERROR level:\n- Missing ## Overview or ## Requirements sections\n- Invalid heading hierarchy\n- Malformed requirement/scenario structure\n\nWARNING level:\n- Requirements without scenarios\n- Requirements missing SHALL keyword\n- Empty overview section\n\nINFO level:\n- Very long requirement text (>500 chars)\n- Scenarios without Given/When/Then structure\n```\n\n#### Change Validation Rules\n```\nERROR level:\n- Missing ## Why or ## What Changes sections\n- Invalid delta operation types\n- Malformed delta structure\n\nWARNING level:\n- Why section too brief (<50 chars)\n- Deltas without clear descriptions\n- Missing requirements in ADDED/MODIFIED\n\nINFO level:\n- Very long why section (>1000 chars)\n- Too many deltas in single change (>10)\n```\n\n### Strict Mode\n- **Default**: Show all levels, fail on ERROR only\n- **--strict flag**: Fail on both ERROR and WARNING\n- **Use case**: Gradual quality improvement in CI/CD pipelines\n\n### Archive Command Safety\n**Problem:** Invalid specs could be archived, polluting the archive.\n\n**Solution:** \n1. Pre-archive validation (default behavior)\n2. --no-validate flag with safeguards:\n   - Interactive confirmation prompt\n   - Prominent warning message\n   - Console logging with timestamp\n   - Not recommended for CI/CD usage\n\n**Rationale:**\n- Protect archive integrity by default\n- Allow emergency overrides with accountability\n- Clear audit trail for validation bypasses\n\n### Validation Report Format\n```json\n{\n  \"valid\": boolean,\n  \"issues\": [\n    {\n      \"level\": \"ERROR\" | \"WARNING\" | \"INFO\",\n      \"path\": \"requirements[0].scenarios\",\n      \"message\": \"Requirement must have at least one scenario\",\n      \"line\": 15,\n      \"column\": 0\n    }\n  ],\n  \"summary\": {\n    \"errors\": 2,\n    \"warnings\": 5,\n    \"info\": 3\n  }\n}\n```\n\n**Benefits:**\n- Machine-readable for tooling integration\n- Human-friendly messages\n- Line/column info for IDE integration\n- Summary for quick assessment\n\n### Implementation Strategy\n1. **Zod schemas with refinements**: Built-in validation in type definitions\n2. **Custom validators**: Additional business logic validation\n3. **Composable rules**: Mix and match for different contexts\n4. **Extensible framework**: Easy to add new rules without refactoring"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-zod-validation/proposal.md",
    "content": "# Change: Add Zod Runtime Validation\n\n## Why\n\nWhile the spec and change commands can output JSON, they currently don't perform strict runtime validation beyond basic structure checking. This can lead to invalid specs or changes being processed, silent failures when required fields are missing, and poor error messages.\n\n## What Changes\n\n- Enhance existing `spec validate` and `change validate` commands with strict Zod validation\n- Add validation to the archive command to ensure changes are valid before applying\n- Add validation to the diff command to ensure changes are well-formed\n- Provide detailed validation reports in JSON format\n- Add `--strict` mode that fails on warnings\n\n## Impact\n\n- **Affected specs**: cli-spec, cli-change, cli-archive, cli-diff\n- **Affected code**:\n  - src/commands/spec.ts (enhance validate subcommand)\n  - src/commands/change.ts (enhance validate subcommand)\n  - src/core/archive.ts (add pre-archive validation)\n  - src/core/diff.ts (add validation check)"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-zod-validation/specs/cli-archive/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Archive Validation\n\nThe archive command SHALL validate changes before applying them to ensure data integrity.\n\n#### Scenario: Pre-archive validation\n\n- **WHEN** executing `openspec archive change-name`\n- **THEN** validate the change structure first\n- **AND** only proceed if validation passes\n- **AND** show validation errors if it fails\n\n#### Scenario: Force archive without validation\n\n- **WHEN** executing `openspec archive change-name --no-validate`\n- **THEN** skip validation (unsafe mode)\n- **AND** show warning about skipping validation"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-zod-validation/specs/cli-diff/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Diff Command Enhancement\n\nThe diff command SHALL validate change structure before displaying differences.\n\n#### Scenario: Validate before diff\n\n- **WHEN** executing `openspec diff change-name`\n- **THEN** validate change structure\n- **AND** show validation warnings if present\n- **AND** continue with diff display"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-add-zod-validation/tasks.md",
    "content": "# Implementation Tasks (Foundation Phase)\n\n## 1. Core Schemas\n- [x] 1.1 Add zod dependency to package.json\n- [x] 1.2 Create src/core/schemas/base.schema.ts with ScenarioSchema and RequirementSchema\n- [x] 1.3 Create src/core/schemas/spec.schema.ts with SpecSchema\n- [x] 1.4 Create src/core/schemas/change.schema.ts with DeltaSchema and ChangeSchema\n- [x] 1.5 Create src/core/schemas/index.ts to export all schemas\n\n## 2. Parser Implementation\n- [x] 2.1 Create src/core/parsers/markdown-parser.ts\n- [x] 2.2 Implement heading extraction (##, ###, ####)\n- [x] 2.3 Implement content capture between headings\n- [x] 2.4 Add tests for parser edge cases\n\n## 3. Validation Infrastructure\n- [x] 3.1 Create src/core/validation/types.ts with ValidationLevel, ValidationIssue, ValidationReport types\n- [x] 3.2 Create src/core/validation/constants.ts with validation rules and thresholds\n- [x] 3.3 Create src/core/validation/validator.ts with SpecValidator and ChangeValidator classes\n\n## 4. Enhanced Validation Rules\n- [x] 4.1 Add RequirementValidation refinements (must have scenarios, must contain SHALL)\n- [x] 4.2 Add SpecValidation refinements (must have requirements)\n- [x] 4.3 Add ChangeValidation refinements (must have deltas, why section length)\n- [x] 4.4 Implement custom error messages for each rule\n\n## 5. JSON Converter\n- [x] 5.1 Create src/core/converters/json-converter.ts\n- [x] 5.2 Implement spec-to-JSON conversion\n- [x] 5.3 Implement change-to-JSON conversion\n- [x] 5.4 Add metadata fields (version, format, sourcePath)\n\n## 6. Archive Command Enhancement\n- [x] 6.1 Add pre-archive validation check using new validators\n- [x] 6.2 Add --no-validate flag with required confirmation prompt and warning message: \"⚠️  WARNING: Skipping validation may archive invalid specs. Continue? (y/N)\"\n- [x] 6.3 Display validation errors before aborting\n- [x] 6.4 Log all --no-validate usages to console with timestamp and affected files\n- [x] 6.5 Add tests for validation scenarios including --no-validate confirmation flow\n\n## 7. Diff Command Enhancement\n- [x] 7.1 Add validation check before diff using new validators\n- [x] 7.2 Show validation warnings (non-blocking)\n- [x] 7.3 Continue with diff even if warnings present\n\n## 8. Testing\n- [x] 8.1 Unit tests for all schemas\n- [x] 8.2 Unit tests for parser\n- [x] 8.3 Unit tests for validation rules\n- [x] 8.4 Integration tests for validation reports\n- [x] 8.5 Test various invalid spec/change formats\n- [x] 8.6 Test strict mode behavior\n- [x] 8.7 Test pre-archive validation\n- [x] 8.8 Test validation report JSON output\n\n## 9. Documentation\n- [x] 9.1 Document schema structure and validation rules (openspec/VALIDATION.md)\n- [x] 9.2 Update CLI help for archive (document --no-validate flag and its warnings)\n- [x] 9.3 Update CLI help for diff (document validation warnings behavior)\n- [x] 9.4 Create migration guide for future command integration (openspec/MIGRATION.md)"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-adopt-delta-based-changes/proposal.md",
    "content": "# Adopt Delta-Based Changes for Specifications\n\n## Why\n\nThe current approach of storing complete future states in change proposals creates a poor review experience. When reviewing changes on GitHub, reviewers see entire spec files (often 100+ lines) as \"added\" in green, making it impossible to identify what actually changed. With the recent structured format adoption, we now have clear section boundaries that enable a better approach: storing only additions and modifications.\n\n## What Changes\n\nStore only the requirements that actually change, not complete future states:\n\n- **ADDED Requirements**: New capabilities being introduced\n- **MODIFIED Requirements**: Existing requirements being changed (must match current header)\n- **REMOVED Requirements**: Deprecated capabilities\n- **RENAMED Requirements**: Explicit header changes (e.g., `FROM: Old Name` → `TO: New Name`)\n\nThe archive command will programmatically apply these deltas using normalized header matching (trim leading/trailing whitespace) instead of manually copying entire files.\n\n## Impact\n\n**Affected specs**: openspec-conventions, cli-archive, cli-diff\n\n**Benefits**:\n- GitHub diffs show only actual changes (25 lines instead of 150+)\n- Reviewers immediately see what's being added, modified, or removed\n- Conflicts are more apparent when two changes modify the same requirement\n- Archive command can programmatically apply changes\n\n**Format**: Delta format only - all changes must use ADDED/MODIFIED/REMOVED sections.\n\n## Example\n\nInstead of storing a 150-line complete future spec, store only:\n\n```markdown\n# User Authentication - Changes\n\n## ADDED Requirements\n\n### Requirement: OAuth Support\nUsers SHALL authenticate via OAuth providers including Google and GitHub.\n\n#### Scenario: OAuth login flow\n- **WHEN** user selects OAuth provider\n- **THEN** redirect to provider authorization\n- **AND** exchange authorization code for tokens\n\n## MODIFIED Requirements\n\n### Requirement: Session Management\nSessions SHALL expire after 30 minutes of inactivity.\n\n#### Scenario: Inactive session timeout  \n- **WHEN** no activity for 30 minutes ← (was 60 minutes)\n- **THEN** invalidate session token\n- **AND** require re-authentication\n\n## RENAMED Requirements\n- FROM: `### Requirement: Basic Authentication`\n- TO: `### Requirement: Email Authentication`\n```\n\nThis makes reviews focused and changes explicit.\n\n## Conflict Resolution\n\nGit naturally detects conflicts when two changes modify the same requirement header. This is actually better than full-state storage where Git might silently merge incompatible changes.\n\n## Decisions and Product Guidelines\n\nTo keep the archive flow lean and predictable, the following decisions apply:\n\n- New spec creation: When a target spec does not exist, auto-generate a minimal skeleton and insert ADDED requirements only. Skeleton format:\n  - `# [Spec Name] Specification`\n  - `## Purpose` with placeholder: \"TBD — created by archiving change [change-name]. Update Purpose after archive.\"\n  - `## Requirements`\n  - If a non-existent spec includes MODIFIED/REMOVED/RENAMED, abort with guidance to create via ADDED-only first.\n\n- Requirement identification: Match requirements by exact header `### Requirement: [Name]` with trim-only normalization and case-sensitive comparison. Use a requirement-block extractor that preserves the exact header and captures full content (including scenarios) for both main specs and delta files.\n\n- Application order and atomicity: Apply deltas in order RENAMED → REMOVED → MODIFIED → ADDED. Validate all operations first, apply in-memory, and write each spec once. On any validation failure, abort without writing partial results. An aggregated totals line is displayed across all specs: `Totals: + A, ~ M, - R, → N`.\n\n- Validation matrix: Enforce that MODIFIED/REMOVED exist; ADDED do not exist; RENAMED FROM exists and TO does not; no duplicates after all operations; and no cross-section conflicts (e.g., same item in MODIFIED and REMOVED). When a rename and modify apply to the same item, MODIFIED must reference the NEW header.\n\n- Idempotency: Keep v1 simple. Abort on precondition failures (e.g., ADDED already exists) with clear errors. Do not implement no-op detection in v1.\n\n- Output and UX: For each spec, display operation counts using standard symbols `+ ~ - →`. Optionally include a short aggregated totals line at the end. Keep messages concise and actionable.\n\n- Error messaging: Standardize messages as `[spec] [operation] failed for header \"### Requirement: X\" — reason`. On abort, explicitly state: `Aborted. No files were changed.`\n- Subsections: Any subsections under a requirement (e.g., `#### Scenario: ...`) are preserved verbatim during parsing and application.\n\n- Backward compatibility: Reject full future-state spec copies for existing specs with guidance to convert to deltas. Allow brand-new specs to be created via ADDED-only deltas using the skeleton above.\n\n- Dry-run: Deferred for v1 to keep scope minimal."
  },
  {
    "path": "openspec/changes/archive/2025-08-19-adopt-delta-based-changes/specs/cli-archive/spec.md",
    "content": "# CLI Archive Command - Changes\n\n## MODIFIED Requirements\n\n### Requirement: Spec Update Process\n\nBefore moving the change to archive, the command SHALL apply delta changes to main specs to reflect the deployed reality.\n\n#### Scenario: Applying delta changes\n\n- **WHEN** archiving a change with delta-based specs\n- **THEN** parse and apply delta changes as defined in openspec-conventions\n- **AND** validate all operations before applying\n\n#### Scenario: Validating delta changes\n\n- **WHEN** processing delta changes\n- **THEN** perform validations as specified in openspec-conventions\n- **AND** if validation fails, show specific errors and abort\n\n#### Scenario: Conflict detection\n\n- **WHEN** applying deltas would create duplicate requirement headers\n- **THEN** abort with error message showing the conflict\n- **AND** suggest manual resolution\n\n## ADDED Requirements\n\n### Requirement: Display Output\n\nThe command SHALL provide clear feedback about delta operations.\n\n#### Scenario: Showing delta application\n\n- **WHEN** applying delta changes\n- **THEN** display for each spec:\n  - Number of requirements added\n  - Number of requirements modified\n  - Number of requirements removed\n  - Number of requirements renamed\n- **AND** use standard output symbols (+ ~ - →) as defined in openspec-conventions:\n  ```\n  Applying changes to specs/user-auth/spec.md:\n    + 2 added\n    ~ 3 modified\n    - 1 removed\n    → 1 renamed\n  ```"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-adopt-delta-based-changes/specs/cli-diff/spec.md",
    "content": "# CLI Diff Command - Changes\n\n## REMOVED Requirements\n\n### Requirement: Display Format\n\nThe diff command SHALL display unified diff output in text format.\n\n**Reason for removal**: The standard unified diff format is replaced by requirement-level side-by-side comparison that better shows semantic changes rather than line-by-line text differences.\n\n#### Scenario: Unified diff output (deprecated)\n\n- **WHEN** running `openspec diff <change>`\n- **THEN** show a unified text diff of files\n- **AND** include `+`/`-` prefixed lines representing additions and removals\n\n## MODIFIED Requirements\n\n### Requirement: Diff Output\n\nThe command SHALL show a requirement-level comparison displaying only changed requirements.\n\n#### Scenario: Side-by-side comparison of changes\n\n- **WHEN** running `openspec diff <change>`\n- **THEN** display only requirements that have changed\n- **AND** show them in a side-by-side format that:\n  - Clearly shows the current version on the left\n  - Shows the future version on the right\n  - Indicates new requirements (not in current)\n  - Indicates removed requirements (not in future)\n  - Aligns modified requirements for easy comparison\n\n## ADDED Requirements\n\n### Requirement: Validation\n\nThe command SHALL validate that changes can be applied successfully.\n\n#### Scenario: Invalid delta references\n\n- **WHEN** delta references non-existent requirement\n- **THEN** show error message with specific requirement\n- **AND** continue showing other valid changes\n- **AND** clearly mark failed changes in the output"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-adopt-delta-based-changes/specs/openspec-conventions/spec.md",
    "content": "# OpenSpec Conventions - Changes\n\n## MODIFIED Requirements\n\n### Requirement: Header-Based Requirement Identification\n\nRequirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes.\n\n#### Scenario: Matching requirements programmatically\n\n- **WHEN** processing delta changes\n- **THEN** use the `### Requirement: [Name]` header as the unique identifier\n- **AND** match using normalized headers: `normalize(header) = trim(header)`\n- **AND** compare headers with case-sensitive equality after normalization\n\n#### Scenario: Handling requirement renames\n\n- **WHEN** renaming a requirement\n- **THEN** use a special `## RENAMED Requirements` section\n- **AND** specify both old and new names explicitly:\n  ```markdown\n  ## RENAMED Requirements\n  - FROM: `### Requirement: Old Name`\n  - TO: `### Requirement: New Name`\n  ```\n- **AND** if content also changes, include under MODIFIED using the NEW header\n\n#### Scenario: Validating header uniqueness\n\n- **WHEN** creating or modifying requirements\n- **THEN** ensure no duplicate headers exist within a spec\n- **AND** validation tools SHALL flag duplicate headers as errors\n\n### Requirement: Change Storage Convention\n\nChange proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states.\n\n#### Scenario: Creating change proposals with additions\n\n- **WHEN** creating a change proposal that adds new requirements\n- **THEN** include only the new requirements under `## ADDED Requirements`\n- **AND** each requirement SHALL include its complete content\n- **AND** use the standard structured format for requirements and scenarios\n\n#### Scenario: Creating change proposals with modifications  \n\n- **WHEN** creating a change proposal that modifies existing requirements\n- **THEN** include the modified requirements under `## MODIFIED Requirements`\n- **AND** use the same header text as in the current spec (normalized)\n- **AND** include the complete modified requirement (not a diff)\n- **AND** optionally annotate what changed with inline comments like `← (was X)`\n\n#### Scenario: Creating change proposals with removals\n\n- **WHEN** creating a change proposal that removes requirements\n- **THEN** list them under `## REMOVED Requirements`\n- **AND** use the normalized header text for identification\n- **AND** include reason for removal\n- **AND** document any migration path if applicable\n\n\nThe `changes/[name]/specs/` directory SHALL contain:\n- Delta files showing only what changes\n- Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements\n- Normalized header matching for requirement identification\n- Complete requirements using the structured format\n- Clear indication of change type for each requirement\n\n#### Scenario: Using standard output symbols\n\n- **WHEN** displaying delta operations in CLI output\n- **THEN** use these standard symbols:\n  - `+` for ADDED (green)\n  - `~` for MODIFIED (yellow)\n  - `-` for REMOVED (red)\n  - `→` for RENAMED (cyan)\n\n### Requirement: Archive Process Enhancement\n\nThe archive process SHALL programmatically apply delta changes to current specifications using header-based matching.\n\n#### Scenario: Archiving changes with deltas\n\n- **WHEN** archiving a completed change\n- **THEN** the archive command SHALL:\n  1. Parse RENAMED sections first and apply renames\n  2. Parse REMOVED sections and remove by normalized header match\n  3. Parse MODIFIED sections and replace by normalized header match (using new names if renamed)\n  4. Parse ADDED sections and append new requirements\n- **AND** validate that all MODIFIED/REMOVED headers exist in current spec\n- **AND** validate that ADDED headers don't already exist\n- **AND** generate the updated spec in the main specs/ directory\n\n#### Scenario: Handling conflicts during archive\n\n- **WHEN** delta changes conflict with current spec state\n- **THEN** the archive command SHALL report specific conflicts\n- **AND** require manual resolution before proceeding\n- **AND** provide clear guidance on resolving conflicts\n\n "
  },
  {
    "path": "openspec/changes/archive/2025-08-19-adopt-delta-based-changes/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Update Conventions\n- [x] 1.1 Update openspec-conventions spec with delta-based approach\n- [x] 1.2 Add Header-Based Requirement Identification\n- [x] 1.3 Define ADDED/MODIFIED/REMOVED/RENAMED sections\n- [x] 1.4 Document standard output symbols (+ ~ - →)\n- [x] 1.5 Update openspec/README.md with delta-based conventions\n- [x] 1.6 Update examples to use delta format\n\n## 2. Update Diff Command\n- [ ] 2.1 Update cli-diff spec with requirement-level comparison\n- [ ] 2.2 Parse specs into requirement-level structures\n- [ ] 2.3 Apply deltas to generate future state\n- [ ] 2.4 Implement side-by-side comparison view (changes only)\n- [ ] 2.5 Add tests for requirement-level comparison\n- [ ] 2.6 Add tests for side-by-side view formatting\n\n## 3. Update Archive Command\n- [x] 3.1 Update cli-archive spec with delta processing behavior\n- [x] 3.2 Implement requirement-block extractor that preserves exact headers (`### Requirement: [Name]`) and captures full content (including scenarios)\n- [x] 3.3 Implement normalized header matching (trim-only, case-sensitive)\n- [x] 3.4 Parse delta sections (ADDED/MODIFIED/REMOVED/RENAMED)\n- [x] 3.5 New spec creation when target spec does not exist\n  - [x] 3.5.1 Auto-generate minimal skeleton: `# [Spec Name] Specification`, `## Purpose` placeholder, `## Requirements`\n  - [x] 3.5.2 Allow only ADDED operations for non-existent specs; abort if MODIFIED/REMOVED/RENAMED present\n- [x] 3.6 Apply changes in order: RENAMED → REMOVED → MODIFIED → ADDED\n- [x] 3.7 Validation and conflict checks\n  - [x] 3.7.1 MODIFIED/REMOVED requirements exist (after applying rename mappings)\n  - [x] 3.7.2 ADDED requirements don't already exist (consider post-rename state)\n  - [x] 3.7.3 RENAMED FROM headers exist; TO headers don't (including collisions with ADDED)\n  - [x] 3.7.4 No duplicate headers within specs after all operations\n  - [x] 3.7.5 Detect cross-section conflicts (e.g., same requirement in MODIFIED and REMOVED)\n  - [x] 3.7.6 When a rename exists, require MODIFIED to reference the NEW header\n- [x] 3.8 Atomic updates\n  - [x] 3.8.1 Validate all deltas first; stage updates in-memory per spec\n  - [x] 3.8.2 Single write per spec; abort entire archive on any validation failure (no partial writes)\n- [x] 3.9 Output and error messaging\n  - [x] 3.9.1 Display per-spec operation counts with symbols: `+` added, `~` modified, `-` removed, `→` renamed\n  - [x] 3.9.2 Optionally display an aggregated totals line across all specs\n  - [x] 3.9.3 Standardize error message format: `[spec] [operation] failed for header \"### Requirement: X\" — reason`; end with `Aborted. No files were changed.` on failure\n- [x] 3.10 Idempotency behavior (v1): abort on precondition failures (e.g., ADDED already exists); do not implement no-op detection\n- [x] 3.11 Tests\n  - [x] 3.11.1 Header normalization (trim-only) matching\n  - [x] 3.11.2 Apply in correct order (RENAMED → REMOVED → MODIFIED → ADDED)\n  - [x] 3.11.3 Validation edge cases (missing headers, duplicates, rename collisions, conflicting sections)\n  - [x] 3.11.4 Rename + modify interplay (MODIFIED uses new header)\n  - [x] 3.11.5 New spec creation via skeleton\n  - [x] 3.11.6 Multi-spec mixed operations with independent validation and write\n\n## Notes\n- Archive command is critical path - must work reliably\n- All new changes must use delta format\n- Header normalization: normalize(header) = trim(header)\n- Diff command shows only changed requirements in side-by-side comparison"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-adopt-verb-noun-cli-structure/design.md",
    "content": "# Design: Verb–Noun CLI Structure Adoption\n\n## Overview\nWe will make verb commands (`list`, `show`, `validate`, `diff`, `archive`) the primary interface and keep noun commands (`spec`, `change`) as deprecated aliases for one release.\n\n## Decisions\n\n1. Keep routing centralized in `src/cli/index.ts`.\n2. Add `--specs`/`--changes` to `openspec list`, with `--changes` as default.\n3. Show deprecation warnings for `openspec change list` and, more generally, for any `openspec change ...` and `openspec spec ...` subcommands.\n4. Do not change `show`/`validate` behavior beyond help text; they already support `--type` for disambiguation.\n\n## Backward Compatibility\nAll noun-based commands continue to work with clear deprecation warnings directing users to verb-first equivalents.\n\n## Out of Scope\nJSON output parity for `openspec list` across modes and `show --specs/--changes` discovery are follow-ups.\n\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-adopt-verb-noun-cli-structure/proposal.md",
    "content": "# Change: Adopt Verb–Noun CLI Structure (Deprecate Noun-Based Commands)\n\n## Why\n\nMost widely used CLIs (git, docker, kubectl) start with an action (verb) followed by the object (noun). This matches how users think: “do X to Y”. Using verbs as top-level commands improves clarity, discoverability, and extensibility.\n\n## What Changes\n\n- Promote top-level verb commands as primary entry points: `list`, `show`, `validate`, `diff`, `archive`.\n- Deprecate noun-based top-level commands: `openspec spec ...` and `openspec change ...`.\n- Introduce consistent noun scoping via flags where applicable (e.g., `--changes`, `--specs`) and keep smart defaults.\n- Clarify disambiguation for `show` and `validate` when names collide.\n\n### Mappings (From → To)\n\n- **List**\n  - From: `openspec change list`\n  - To: `openspec list --changes` (default), or `openspec list --specs`\n\n- **Show**\n  - From: `openspec spec show <spec-id>` / `openspec change show <change-id>`\n  - To: `openspec show <item-id>` with auto-detect, use `--type spec|change` if ambiguous\n\n- **Validate**\n  - From: `openspec spec validate <spec-id>` / `openspec change validate <change-id>`\n  - To: `openspec validate <item-id> --type spec|change`, or bulk: `openspec validate --specs` / `--changes` / `--all`\n\n### Backward Compatibility\n\n- Keep `openspec spec` and `openspec change` available with deprecation warnings for one release cycle.\n- Update help text to point users to the verb–noun alternatives.\n\n## Impact\n\n- **Affected specs**:\n  - `cli-list`: Add support for `--specs` and explicit `--changes` (default remains changes)\n  - `openspec-conventions`: Add explicit requirement establishing verb–noun CLI design and deprecation guidance\n- **Affected code**:\n  - `src/cli/index.ts`: Un-deprecate top-level `list`; mark `change list` as deprecated; ensure help text and warnings align\n  - `src/core/list.ts`: Support listing specs via `--specs` and default to changes; shared output shape\n  - Optional follow-ups: tighten `show`/`validate` help and ambiguity handling\n\n## Explicit Changes\n\n**CLI Design**\n- From: Mixed model with nouns (`spec`, `change`) and some top-level verbs; `openspec list` currently deprecated\n- To: Verbs as primary: `openspec list|show|validate|diff|archive`; nouns scoped via flags or item ids; noun commands deprecated\n- Reason: Align with common CLIs; improve UX; simpler mental model\n- Impact: Non-breaking with deprecation period; users migrate incrementally\n\n**Listing Behavior**\n- From: `openspec change list` (primary), `openspec list` (deprecated)\n- To: `openspec list` as primary, defaulting to `--changes`; add `--specs` to list specs\n- Reason: Consistent verb–noun style; better discoverability\n- Impact: New option; preserves existing behavior via default\n\n## Rollout and Deprecation Policy\n\n- Show deprecation warnings on noun-based commands for one release.\n- Document new usage in `openspec/README.md` and CLI help.\n- After one release, consider removing noun-based commands, or keep as thin aliases without warnings.\n\n## Open Questions\n\n- Should `show` also accept `--changes`/`--specs` for discovery without an id? (Out of scope here; current auto-detect and `--type` remain.)\n\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-adopt-verb-noun-cli-structure/specs/cli-list/spec.md",
    "content": "# Delta: CLI List Command\n\n## MODIFIED Requirements\n\n### Requirement: Command Execution\nThe command SHALL scan and analyze either active changes or specs based on the selected mode.\n\n#### Scenario: Scanning for changes (default)\n- **WHEN** `openspec list` is executed without flags\n- **THEN** scan the `openspec/changes/` directory for change directories\n- **AND** exclude the `archive/` subdirectory from results\n- **AND** parse each change's `tasks.md` file to count task completion\n\n#### Scenario: Scanning for specs\n- **WHEN** `openspec list --specs` is executed\n- **THEN** scan the `openspec/specs/` directory for capabilities\n- **AND** read each capability's `spec.md`\n- **AND** parse requirements to compute requirement counts\n\n### Requirement: Output Format\nThe command SHALL display items in a clear, readable table format with mode-appropriate progress or counts.\n\n#### Scenario: Displaying change list (default)\n- **WHEN** displaying the list of changes\n- **THEN** show a table with columns:\n  - Change name (directory name)\n  - Task progress (e.g., \"3/5 tasks\" or \"✓ Complete\")\n\n#### Scenario: Displaying spec list\n- **WHEN** displaying the list of specs\n- **THEN** show a table with columns:\n  - Spec id (directory name)\n  - Requirement count (e.g., \"requirements 12\")\n\n### Requirement: Empty State\nThe command SHALL provide clear feedback when no items are present for the selected mode.\n\n#### Scenario: Handling empty state (changes)\n- **WHEN** no active changes exist (only archive/ or empty changes/)\n- **THEN** display: \"No active changes found.\"\n\n#### Scenario: Handling empty state (specs)\n- **WHEN** no specs directory exists or contains no capabilities\n- **THEN** display: \"No specs found.\"\n\n### Requirement: Flags\nThe command SHALL accept flags to select the noun being listed.\n\n#### Scenario: Selecting specs\n- **WHEN** `--specs` is provided\n- **THEN** list specs instead of changes\n\n#### Scenario: Selecting changes\n- **WHEN** `--changes` is provided\n- **THEN** list changes explicitly (same as default behavior)\n\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-adopt-verb-noun-cli-structure/specs/openspec-conventions/spec.md",
    "content": "# Delta: OpenSpec Conventions — Verb–Noun CLI Design\n\n## ADDED Requirements\n\n### Requirement: Verb–Noun CLI Command Structure\nOpenSpec CLI design SHALL use verbs as top-level commands with nouns provided as arguments or flags for scoping.\n\n#### Scenario: Verb-first command discovery\n- **WHEN** a user runs a command like `openspec list`\n- **THEN** the verb communicates the action clearly\n- **AND** nouns refine scope via flags or arguments (e.g., `--changes`, `--specs`)\n\n#### Scenario: Backward compatibility for noun commands\n- **WHEN** users run noun-prefixed commands such as `openspec spec ...` or `openspec change ...`\n- **THEN** the CLI SHALL continue to support them for at least one release\n- **AND** display a deprecation warning that points to verb-first alternatives\n\n#### Scenario: Disambiguation guidance\n- **WHEN** item names are ambiguous between changes and specs\n- **THEN** `openspec show` and `openspec validate` SHALL accept `--type spec|change`\n- **AND** the help text SHALL document this clearly\n\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-adopt-verb-noun-cli-structure/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. CLI Behavior and Help\n- [x] 1.1 Un-deprecate top-level `openspec list`; mark `change list` as deprecated with warning that points to `openspec list`\n- [x] 1.2 Add support to list specs via `openspec list --specs` and keep `--changes` as default\n- [x] 1.3 Update command descriptions and `--help` output to emphasize verb–noun pattern\n- [x] 1.4 Keep `openspec spec ...` and `openspec change ...` commands working but print deprecation notices\n\n## 2. Core List Logic\n- [x] 2.1 Extend `src/core/list.ts` to accept a mode: `changes` (default) or `specs`\n- [x] 2.2 Implement `specs` listing: scan `openspec/specs/*/spec.md`, compute requirement count via parser, format output consistently\n- [x] 2.3 Share output structure for both modes; preserve current text table; ensure JSON parity in future change\n\n## 3. Specs and Conventions\n- [x] 3.1 Update `openspec/specs/cli-list/spec.md` to document `--specs` (and default to changes)\n- [x] 3.2 Update `openspec/specs/openspec-conventions/spec.md` with a requirement for verb–noun CLI design and deprecation guidance\n\n## 4. Tests and Docs\n- [x] 4.1 Update tests: ensure `openspec list` works for changes and specs; keep `change list` tests but assert warning\n- [ ] 4.2 Update README and any usage docs to show new primary commands\n- [ ] 4.3 Add migration notes in repo CHANGELOG or README\n\n## 5. Follow-ups (Optional, not in this change)\n- [ ] 5.1 Consider `openspec show --specs/--changes` for discovery without ids\n- [ ] 5.2 Consider JSON output for `openspec list` with `--json` for both modes\n\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-bulk-validation-interactive-selection/proposal.md",
    "content": "## Why\n\nCurrently, users must validate changes and specs individually by specifying each ID. This creates friction when:\n- Teams want to validate all changes/specs before a release\n- Developers need to ensure consistency across multiple related changes  \n- Users run validation commands without arguments and receive errors instead of helpful guidance\n- The subcommand structure requires users to know in advance whether they're validating a change or spec\n\n## What Changes\n\n- Add new top-level `validate` command with intuitive flags (--all, --changes, --specs)\n- Enhance existing `change validate` and `spec validate` to support interactive selection (backwards compatibility)\n- Interactive selection by default when no arguments provided\n- Support direct item validation: `openspec validate <item>` with automatic type detection\n\n## Impact\n\n- New specs to create: cli-validate\n- Specs to enhance: cli-change, cli-spec (for backwards compatibility)\n- Affected code: src/cli/index.ts, src/commands/validate.ts (new), src/commands/spec.ts, src/commands/change.ts"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-bulk-validation-interactive-selection/specs/cli-change/spec.md",
    "content": "# CLI Change Command Spec\n\n## ADDED Requirements\n\n### Requirement: Interactive validation selection\n\nThe change validate command SHALL support interactive selection when no change name is provided.\n\n#### Scenario: Interactive change selection for validation\n\n- **WHEN** executing `openspec change validate` without arguments\n- **THEN** display an interactive list of available changes\n- **AND** allow the user to select a change to validate\n- **AND** validate the selected change\n\n#### Scenario: Non-interactive fallback keeps current behavior\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec change validate` without a change name\n- **THEN** do not prompt interactively\n- **AND** print the existing hint including available change IDs\n- **AND** set `process.exitCode = 1`"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-bulk-validation-interactive-selection/specs/cli-spec/spec.md",
    "content": "# CLI Spec Command Spec\n\n## ADDED Requirements\n\n### Requirement: Interactive spec validation\n\nThe spec validate command SHALL support interactive selection when no spec-id is provided.\n\n#### Scenario: Interactive spec selection for validation\n\n- **WHEN** executing `openspec spec validate` without arguments\n- **THEN** display an interactive list of available specs\n- **AND** allow the user to select a spec to validate\n- **AND** validate the selected spec\n- **AND** maintain all existing validation options (--strict, --json)\n\n#### Scenario: Non-interactive fallback keeps current behavior\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec spec validate` without a spec-id\n- **THEN** do not prompt interactively\n- **AND** print the existing error message for missing spec-id\n- **AND** set non-zero exit code"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-bulk-validation-interactive-selection/specs/cli-validate/spec.md",
    "content": "# CLI Validate Command Spec\n\n## ADDED Requirements\n\n### Requirement: Top-level validate command\n\nThe CLI SHALL provide a top-level `validate` command for validating changes and specs with flexible selection options.\n\n#### Scenario: Interactive validation selection\n\n- **WHEN** executing `openspec validate` without arguments\n- **THEN** prompt user to select what to validate (all, changes, specs, or specific item)\n- **AND** perform validation based on selection\n- **AND** display results with appropriate formatting\n\n#### Scenario: Non-interactive environments do not prompt\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec validate` without arguments\n- **THEN** do not prompt interactively\n- **AND** print a helpful hint listing available commands/flags and exit with code 1\n\n#### Scenario: Direct item validation\n\n- **WHEN** executing `openspec validate <item-name>`\n- **THEN** automatically detect if item is a change or spec\n- **AND** validate the specified item\n- **AND** display validation results\n\n### Requirement: Bulk and filtered validation\n\nThe validate command SHALL support flags for bulk validation (--all) and filtered validation by type (--changes, --specs).\n\n#### Scenario: Validate everything\n\n- **WHEN** executing `openspec validate --all`\n- **THEN** validate all changes in openspec/changes/ (excluding archive)\n- **AND** validate all specs in openspec/specs/\n- **AND** display a summary showing passed/failed items\n- **AND** exit with code 1 if any validation fails\n\n#### Scenario: Scope of bulk validation\n\n- **WHEN** validating with `--all` or `--changes`\n- **THEN** include all change proposals under `openspec/changes/`\n- **AND** exclude the `openspec/changes/archive/` directory\n\n- **WHEN** validating with `--specs`\n- **THEN** include all specs that have a `spec.md` under `openspec/specs/<id>/spec.md`\n\n#### Scenario: Validate all changes\n\n- **WHEN** executing `openspec validate --changes`\n- **THEN** validate all changes in openspec/changes/ (excluding archive)\n- **AND** display results for each change\n- **AND** show summary statistics\n\n#### Scenario: Validate all specs\n\n- **WHEN** executing `openspec validate --specs`\n- **THEN** validate all specs in openspec/specs/\n- **AND** display results for each spec\n- **AND** show summary statistics\n\n### Requirement: Validation options and progress indication\n\nThe validate command SHALL support standard validation options (--strict, --json) and display progress during bulk operations.\n\n#### Scenario: Strict validation\n\n- **WHEN** executing `openspec validate --all --strict`\n- **THEN** apply strict validation to all items\n- **AND** treat warnings as errors\n- **AND** fail if any item has warnings or errors\n\n#### Scenario: JSON output\n\n- **WHEN** executing `openspec validate --all --json`\n- **THEN** output validation results as JSON\n- **AND** include detailed issues for each item\n- **AND** include summary statistics\n\n#### Scenario: JSON output schema for bulk validation\n\n- **WHEN** executing `openspec validate --all --json` (or `--changes` / `--specs`)\n- **THEN** output a JSON object with the following shape:\n  - `items`: Array of objects with fields `{ id: string, type: \"change\"|\"spec\", valid: boolean, issues: Issue[], durationMs: number }`\n  - `summary`: Object `{ totals: { items: number, passed: number, failed: number }, byType: { change?: { items: number, passed: number, failed: number }, spec?: { items: number, passed: number, failed: number } } }`\n  - `version`: String identifier for the schema (e.g., `\"1.0\"`)\n- **AND** exit with code 1 if any `items[].valid === false`\n\nWhere `Issue` follows the existing per-item validation report shape `{ level: \"ERROR\"|\"WARNING\"|\"INFO\", path: string, message: string }`.\n\n#### Scenario: Show validation progress\n\n- **WHEN** validating multiple items (--all, --changes, or --specs)\n- **THEN** show progress indicator or status updates\n- **AND** indicate which item is currently being validated\n- **AND** display running count of passed/failed items\n\n#### Scenario: Concurrency limits for performance\n\n- **WHEN** validating multiple items\n- **THEN** run validations with a bounded concurrency (e.g., 4–8 in parallel)\n- **AND** ensure progress indicators remain responsive\n\n### Requirement: Item type detection and ambiguity handling\n\nThe validate command SHALL handle ambiguous names and explicit type overrides to ensure clear, deterministic behavior.\n\n#### Scenario: Direct item validation with automatic type detection\n\n- **WHEN** executing `openspec validate <item-name>`\n- **THEN** if `<item-name>` uniquely matches a change or a spec, validate that item\n\n#### Scenario: Ambiguity between change and spec names\n\n- **GIVEN** `<item-name>` exists both as a change and as a spec\n- **WHEN** executing `openspec validate <item-name>`\n- **THEN** print an ambiguity error explaining both matches\n- **AND** suggest passing `--type change` or `--type spec`, or using `openspec change validate` / `openspec spec validate`\n- **AND** exit with code 1 without performing validation\n\n#### Scenario: Unknown item name\n\n- **WHEN** the `<item-name>` matches neither a change nor a spec\n- **THEN** print a not-found error\n- **AND** show nearest-match suggestions when available\n- **AND** exit with code 1\n\n#### Scenario: Explicit type override\n\n- **WHEN** executing `openspec validate --type change <item>`\n- **THEN** treat `<item>` as a change ID and validate it (skipping auto-detection)\n\n- **WHEN** executing `openspec validate --type spec <item>`\n- **THEN** treat `<item>` as a spec ID and validate it (skipping auto-detection)\n\n### Requirement: Interactivity controls\n\n- The CLI SHALL respect `--no-interactive` to disable prompts.\n- The CLI SHALL respect `OPEN_SPEC_INTERACTIVE=0` to disable prompts globally.\n- Interactive prompts SHALL only be shown when stdin is a TTY and interactivity is not disabled.\n\n#### Scenario: Disabling prompts via flags or environment\n\n- **WHEN** `openspec validate` is executed with `--no-interactive` or with environment `OPEN_SPEC_INTERACTIVE=0`\n- **THEN** the CLI SHALL not display interactive prompts\n- **AND** SHALL print non-interactive hints or chosen outputs as appropriate"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-bulk-validation-interactive-selection/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Change Command: Interactive Validation Selection\n- [x] 1.1 Add `--no-interactive` flag to `change validate` in `src/cli/index.ts`\n- [x] 1.2 Implement interactivity gate respecting TTY and `OPEN_SPEC_INTERACTIVE=0` in `src/commands/change.ts`\n- [x] 1.3 When no `[change-name]` is provided and interactivity is allowed, prompt with a list of active changes (exclude `archive/`) and validate the selected one\n- [x] 1.4 Preserve current non-interactive fallback: print available change IDs and hint, set `process.exitCode = 1`\n- [x] 1.5 Tests: add coverage for interactive and non-interactive flows\n  - Added `test/commands/change.interactive-validate.test.ts`\n\n## 2. Spec Command: Interactive Validation Selection\n- [x] 2.1 Make `spec validate` accept optional `[spec-id]` in `src/commands/spec.ts` registration\n- [x] 2.2 Add `--no-interactive` flag to `spec validate`\n- [x] 2.3 Implement interactivity gate respecting TTY and `OPEN_SPEC_INTERACTIVE=0`\n- [x] 2.4 When no `[spec-id]` provided and interactivity allowed, prompt to select from `openspec/specs/*/spec.md` and validate the selected spec\n- [x] 2.5 Preserve current non-interactive fallback when no spec-id and no interactivity: print existing error and exit code non-zero\n- [x] 2.6 Tests: add coverage for interactive and non-interactive flows\n  - Added `test/commands/spec.interactive-validate.test.ts`\n\n## 3. New Top-level `validate` Command\n- [x] 3.1 Add `validate` command in `src/cli/index.ts`\n  - Options: `--all`, `--changes`, `--specs`, `--type <change|spec>`, `--strict`, `--json`, `--no-interactive`\n  - Usage: `openspec validate [item-name]`\n- [x] 3.2 Create `src/commands/validate.ts` implementing:\n  - [x] 3.2.1 Interactive selector when no args (choices: All, Changes, Specs, Specific item)\n  - [x] 3.2.2 Non-interactive fallback with helpful hint and exit code 1\n  - [x] 3.2.3 Direct item validation with automatic type detection\n  - [x] 3.2.4 Ambiguity error when name exists as both change and spec; suggest `--type` or subcommands\n  - [x] 3.2.5 Unknown item handling with nearest-match suggestions\n  - [x] 3.2.6 Bulk validation for `--all`, `--changes`, `--specs` (exclude `openspec/changes/archive/`)\n  - [x] 3.2.7 Respect `--strict` and `--json` options; JSON shape per spec\n  - [x] 3.2.8 Exit with code 1 if any validation fails\n  - [x] 3.2.9 Bounded concurrency (default 4–8) for bulk validation\n  - [x] 3.2.10 Progress indication during bulk runs (current item, running counts)\n\n## 4. Utilities and Shared Helpers\n- [x] 4.1 Add `src/utils/interactive.ts` with `isInteractive(stdin: NodeJS.ReadStream, noInteractiveFlag?: boolean): boolean`\n  - Considers: `process.stdin.isTTY`, `--no-interactive`, `OPEN_SPEC_INTERACTIVE=0`\n- [x] 4.2 Add `src/utils/item-discovery.ts` with:\n  - `getActiveChangeIds(root = process.cwd()): Promise<string[]>` (exclude `archive/`)\n  - `getSpecIds(root = process.cwd()): Promise<string[]>` (folders with `spec.md`)\n- [ ] 4.3 Optional: `src/utils/concurrency.ts` helper for bounded parallelism\n- [x] 4.4 Reuse `src/core/validation/validator.ts` for item validation\n\n## 5. JSON Output (Bulk Validation)\n- [x] 5.1 Implement JSON schema:\n  - `items: Array<{ id: string, type: \"change\"|\"spec\", valid: boolean, issues: Issue[], durationMs: number }>`\n  - `summary: { totals: { items: number, passed: number, failed: number }, byType: { change?: { items: number, passed: number, failed: number }, spec?: { items: number, passed: number, failed: number } } }`\n  - `version: \"1.0\"`\n- [x] 5.2 Ensure process exit code is 1 if any `items[].valid === false`\n- [x] 5.3 Tests for JSON shape (keys, types, counts) and exit code behavior\n  - Added `test/commands/validate.test.ts`\n\n## 6. Progress and UX\n- [x] 6.1 Use `ora` or minimal console progress to show current item and running counts\n- [x] 6.2 Keep output stable in `--json` mode (no extra logs to stdout; use stderr for progress if needed)\n- [x] 6.3 Ensure responsiveness with concurrency limits\n\n## 7. Tests\n- [x] 7.1 Add top-level validate tests: `test/commands/validate.test.ts`\n  - Includes non-interactive hint, --all JSON, --specs with concurrency, ambiguity error\n- [ ] 7.2 Add unit tests for `isInteractive` and item discovery helpers\n- [x] 7.3 Extend existing change/spec command tests to cover interactive `validate`\n  - Added `test/commands/change.interactive-validate.test.ts`, `test/commands/spec.interactive-validate.test.ts`\n\n## 8. CLI Help and Docs\n- [x] 8.1 Update command descriptions/options in `src/cli/index.ts`\n- [x] 8.2 Verify help output includes `validate` command and flags\n- [x] 8.3 Ensure existing specs under `openspec/changes/bulk-validation-interactive-selection/specs/*` remain satisfied\n\n## 9. Non-functional\n- [x] 9.1 Code style and types: explicit types for exported APIs; avoid `any`\n- [x] 9.2 No linter errors; stable formatting; avoid unrelated refactors\n- [x] 9.3 Maintain existing behavior for unaffected commands\n\n## 10. Acceptance Criteria Mapping\n- [x] AC-1: `openspec change validate` interactive selection when no arg (TTY only; respects `--no-interactive`/env) — matches cli-change spec\n- [x] AC-2: `openspec spec validate` interactive selection when no arg (TTY only; respects `--no-interactive`/env) — matches cli-spec spec\n- [x] AC-3: New `openspec validate` supports interactive selection, bulk/filtered validation, JSON schema, progress, concurrency, exit codes — matches cli-validate spec\n\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-fix-update-tool-selection/proposal.md",
    "content": "# Fix Update Command Tool Selection\n\n## Problem\n\nThe `openspec update` command currently forces the creation/update of CLAUDE.md regardless of which AI tool was selected during initialization. This violates the tool-agnostic design principle and creates confusion for users who selected different AI assistants.\n\nAdditionally, different team members may use different AI tools, so we cannot rely on a shared configuration file.\n\n## Solution\n\nModify the update command to:\n1. Only update AI tool configuration files that already exist\n2. Never create new AI tool configuration files\n3. Always update the core OpenSpec files (README.md, etc.)\n\n## Implementation\n\n- Remove hardcoded CLAUDE.md update from update command\n- Implement file existence check before updating any AI tool config\n- Update each existing AI tool config file with its appropriate markers\n- No configuration file needed (avoids team conflicts)\n\n## Success Criteria\n\n- Update command only modifies existing AI tool configuration files\n- No new AI tool files created during update\n- Team members can use different AI tools without conflicts\n- Existing projects continue to work (backward compatibility)\n\n## Why\n\nUsers need predictable, tool-agnostic behavior from `openspec update`. Creating or forcing updates for AI tool files that a project does not use causes confusion and merge conflicts. Restricting updates to existing files and always updating core OpenSpec files keeps the workflow consistent for mixed-tool teams.\n\n## What Changes\n\n- **cli-update:** Modify update behavior to update only existing AI tool configuration files and never create new ones; always update core OpenSpec files and display an ASCII-safe success message.\n\n## ADDED Requirements\n\nRemoved from proposal to follow conventions. See `specs/cli-update/spec.md` for the delta requirements content."
  },
  {
    "path": "openspec/changes/archive/2025-08-19-fix-update-tool-selection/specs/cli-update/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Tool-Agnostic Updates\n\nThe update command SHALL update only existing AI tool configuration files and SHALL NOT create new ones.\n\n#### Scenario: Updating existing tool files\n\n- **WHEN** a user runs `openspec update`\n- **THEN** update each AI tool configuration file that exists (e.g., CLAUDE.md, COPILOT.md)\n- **AND** do not create missing tool configuration files\n- **AND** preserve user content outside OpenSpec markers\n\n### Requirement: Core Files Always Updated\n\nThe update command SHALL always update the core OpenSpec files and display an ASCII-safe success message.\n\n#### Scenario: Successful update\n\n- **WHEN** the update completes successfully\n- **THEN** replace `openspec/README.md` with the latest template\n- **AND** update existing AI tool configuration files within markers\n- **AND** display the message: \"Updated OpenSpec instructions\""
  },
  {
    "path": "openspec/changes/archive/2025-08-19-fix-update-tool-selection/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Update Update Command\n- [x] Remove hardcoded CLAUDE.md update from `src/core/update.ts`\n- [x] Add logic to check for existing AI tool configuration files\n- [x] Update only existing files using their appropriate configurators\n- [x] Iterate through all registered configurators to check for existing files\n\n## 2. Update Configurator Registry\n- [x] Add method to get all configurators for update command\n- [x] Ensure each configurator can check if its file exists\n\n## 3. Add Tests\n- [x] Test update command with only CLAUDE.md present\n- [x] Test update command with no AI tool files present\n- [x] Test update command with multiple AI tool files present\n- [x] Test that update never creates new AI tool files\n\n## 4. Update Documentation\n- [x] Update README to clarify team-friendly behavior\n- [x] Document that update only modifies existing files"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-improve-validate-error-messages/proposal.md",
    "content": "# improve-validate-error-messages\n\n## Why\n\nDevelopers struggle to resolve validation failures because current errors lack actionable guidance. Common issues include: missing deltas, missing required sections, and misformatted scenarios that are silently ignored. Without clear remediation steps, users cannot quickly correct structure or formatting, leading to frustration and rework. Improving error messages with concrete fixes, file/section hints, and suggested commands will significantly reduce time-to-green and make OpenSpec more approachable.\n\n## What Changes\n\n- Validation errors SHALL include specific remediation steps (what to change and where).\n- \"No deltas found\" error SHALL guide users to create `specs/` with proper delta headers and suggest debug commands.\n- Missing required sections (Spec: Purpose/Requirements; Change: Why/What Changes) SHALL include expected header names and a minimal skeleton example.\n- Likely misformatted scenarios (bulleted WHEN/THEN/AND) SHALL emit a targeted warning explaining the `#### Scenario:` format and show a conversion template.\n- All reported issues SHALL include the source file path and structured location (e.g., `deltas[0].requirements[0]`).\n- Non-JSON output SHOULD end with a short \"Next steps\" footer when invalid.\n\n## Impact\n\n- Affected CLI: validate\n- Affected code:\n  - `src/commands/validate.ts`\n  - `src/core/validation/validator.ts`\n  - `src/core/validation/constants.ts`\n  - `src/core/parsers/*` (wrapping thrown errors with richer context)\n\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-improve-validate-error-messages/specs/cli-validate/spec.md",
    "content": "# Validate Command\n\n## ADDED Requirements\n\n### Requirement: Validation SHALL provide actionable remediation steps\nValidation output SHALL include specific guidance to fix each error, including expected structure, example headers, and suggested commands to verify fixes.\n\n#### Scenario: No deltas found in change\n- **WHEN** validating a change with zero parsed deltas\n- **THEN** show error \"No deltas found\" with guidance:\n  - Ensure `openspec/changes/{id}/specs/` exists with `.md` files\n  - Use delta headers: `## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, `## RENAMED Requirements`\n  - Each requirement must include at least one `#### Scenario:` block\n  - Try: `openspec change show {id} --json --deltas-only` to inspect what was parsed\n\n#### Scenario: Missing required sections\n- **WHEN** a required section is missing\n- **THEN** the validator SHALL include expected header names and a minimal skeleton:\n  - For Spec: `## Purpose`, `## Requirements`\n  - For Change: `## Why`, `## What Changes`\n  - Show an example snippet of the missing section\n\n### Requirement: Validator SHALL detect likely misformatted scenarios and warn with a fix\nThe validator SHALL recognize bulleted lines that look like scenarios (e.g., lines beginning with WHEN/THEN/AND) and emit a targeted warning with a conversion example to `#### Scenario:`.\n\n#### Scenario: Bulleted WHEN/THEN under a Requirement\n- **WHEN** bullets that start with WHEN/THEN/AND are found under a requirement without any `#### Scenario:` headers\n- **THEN** emit warning: \"Scenarios must use '#### Scenario:' headers\", and show a conversion template:\n```\n#### Scenario: Short name\n- **WHEN** ...\n- **THEN** ...\n- **AND** ...\n```\n\n### Requirement: All issues SHALL include file paths and structured locations\nError, warning, and info messages SHALL include:\n- Source file path (`openspec/changes/{id}/proposal.md`, `.../specs/{cap}/spec.md`)\n- Structured path (e.g., `deltas[0].requirements[0].scenarios`)\n\n#### Scenario: Zod validation error\n- **WHEN** a schema validation fails\n- **THEN** the message SHALL include `file`, `path`, and a remediation hint if applicable\n\n### Requirement: Invalid results SHALL include a Next steps footer in human-readable output\nThe CLI SHALL append a Next steps footer when the item is invalid and not using `--json`, including:\n- Summary line with counts\n- Top-3 guidance bullets (contextual to the most frequent or blocking errors)\n- A suggestion to re-run with `--json` and/or the debug command\n\n#### Scenario: Change invalid summary\n- **WHEN** a change validation fails\n- **THEN** print \"Next steps\" with 2-3 targeted bullets and suggest `openspec change show <id> --json --deltas-only`\n\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-improve-validate-error-messages/tasks.md",
    "content": "## 1. Enhance validation messages\n- [x] 1.1 Add remediation guidance for \"No deltas found\"\n- [x] 1.2 Include file path and structured path in all issues\n- [x] 1.3 Improve messages for missing required sections (Spec, Change)\n- [x] 1.4 Detect likely misformatted scenarios and warn with conversion example\n- [x] 1.5 Add \"Next steps\" footer for non-JSON invalid output\n\n## 2. Update constants and helpers\n- [x] 2.1 Centralize guidance snippets in `VALIDATION_MESSAGES`\n- [x] 2.2 Provide minimal skeleton examples for missing sections\n\n## 3. Parser integration\n- [x] 3.1 Capture parser-thrown errors and wrap with richer context\n- [x] 3.2 Add file/section references to surfaced parser errors\n\n## 4. Tests\n- [x] 4.1 Unit tests for validator message composition\n- [x] 4.2 CLI integration tests for human-readable output (with footer)\n- [x] 4.3 JSON mode tests (structure unchanged, content enriched)\n\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-structured-spec-format/proposal.md",
    "content": "## Why\n\nOpenSpec specifications lack a consistent structure that makes sections visually identifiable and programmatically parseable across different specs. This makes it harder to maintain consistency and build tooling.\n\n## What Changes\n\n**Specification Format Section**\n- From: No formal structure requirements for specifications\n- To: Structured format with `### Requirement:` and `#### Scenario:` headers\n- Reason: Visual consistency and parseability across all specs\n- Impact: Non-breaking - existing specs can migrate gradually\n\n**Keyword Formatting**\n- From: Inconsistent use of WHEN/THEN/AND keywords\n- To: Bold keywords (**WHEN**, **THEN**, **AND**) in scenario bullets\n- Reason: Improved readability and consistent visual hierarchy\n- Impact: Non-breaking - formatting enhancement only\n\n**Format Flexibility**\n- From: Implicit understanding that different content needs different formats\n- To: Explicit allowance for alternative formats (OpenAPI, JSON Schema, etc.)\n- Reason: Address concern that not all specs fit requirement/scenario pattern\n- Impact: Non-breaking - clarifies existing practice\n\n**Migration Guidelines**\n- From: No migration guidance\n- To: Documented gradual migration approach\n- Reason: Allows incremental adoption without disrupting existing specs\n- Impact: Non-breaking - opt-in migration as specs are modified\n\n## Impact\n\n- Affected specs: openspec-conventions (enhancement to existing capability)\n- Affected code: None initially - this is a documentation standard enhancement\n- Migration: Gradual - existing specs migrate as they're modified\n- Tooling: Enables future parsing tools but doesn't require them\n"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-structured-spec-format/specs/openspec-conventions/spec.md",
    "content": "# OpenSpec Conventions Specification\n\n## ADDED Requirements\n\n### Requirement: Structured Format Adoption\n\nBehavioral specifications SHALL adopt the structured format with `### Requirement:` and `#### Scenario:` headers as the default.\n\n#### Scenario: Use structured headings for behavior\n\n- **WHEN** documenting behavioral requirements\n- **THEN** use `### Requirement:` for requirements\n- **AND** use `#### Scenario:` for scenarios with bold WHEN/THEN/AND keywords\n\n## Purpose\n\nOpenSpec conventions SHALL define how system capabilities are documented, how changes are proposed and tracked, and how specifications evolve over time. This meta-specification serves as the source of truth for OpenSpec's own conventions.\n\n## Core Principles\n\nThe system SHALL follow these principles:\n- Specs reflect what IS currently built and deployed\n- Changes contain proposals for what SHOULD be changed\n- AI drives the documentation process\n- Specs are living documentation kept in sync with deployed code\n\n## Directory Structure\n\nWHEN an OpenSpec project is initialized\nTHEN it SHALL have this structure:\n```\nopenspec/\n├── project.md              # Project-specific context\n├── README.md               # AI assistant instructions\n├── specs/                  # Current deployed capabilities\n│   └── [capability]/       # Single, focused capability\n│       ├── spec.md         # WHAT and WHY\n│       └── design.md       # HOW (optional, for established patterns)\n└── changes/                # Proposed changes\n    ├── [change-name]/      # Descriptive change identifier\n    │   ├── proposal.md     # Why, what, and impact\n    │   ├── tasks.md        # Implementation checklist\n    │   ├── design.md       # Technical decisions (optional)\n    │   └── specs/          # Complete future state\n    │       └── [capability]/\n    │           └── spec.md # Clean markdown (no diff syntax)\n    └── archive/            # Completed changes\n        └── YYYY-MM-DD-[name]/\n```\n\n## Specification Format\n\n### Requirement: Structured Format for Behavioral Specs\n\nBehavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability.\n\n#### Scenario: Writing requirement sections\n\n- **WHEN** documenting a requirement in a behavioral specification\n- **THEN** use a level-3 heading with format `### Requirement: [Name]`\n- **AND** immediately follow with a SHALL statement describing core behavior\n- **AND** keep requirement names descriptive and under 50 characters\n\n#### Scenario: Documenting scenarios\n\n- **WHEN** documenting specific behaviors or use cases\n- **THEN** use level-4 headings with format `#### Scenario: [Description]`\n- **AND** use bullet points with bold keywords for steps:\n  - **GIVEN** for initial state (optional)\n  - **WHEN** for conditions or triggers\n  - **THEN** for expected outcomes\n  - **AND** for additional outcomes or conditions\n\n#### Scenario: Adding implementation details\n\n- **WHEN** a step requires additional detail\n- **THEN** use sub-bullets under the main step\n- **AND** maintain consistent indentation\n  - Sub-bullets provide examples or specifics\n  - Keep sub-bullets concise\n\n### Requirement: Format Flexibility\n\nThe structured format SHALL be the default for behavioral specifications, but alternative formats MAY be used when more appropriate for the content type.\n\n#### Scenario: Documenting API specifications\n\n- **WHEN** documenting REST API endpoints or GraphQL schemas\n- **THEN** OpenAPI, GraphQL SDL, or similar formats MAY be used\n- **AND** the spec SHALL clearly indicate the format being used\n- **AND** behavioral aspects SHALL still follow the structured format\n\n#### Scenario: Documenting data schemas\n\n- **WHEN** documenting data structures, database schemas, or configurations\n- **THEN** JSON Schema, SQL DDL, or similar formats MAY be used\n- **AND** include the structured format for behavioral rules and constraints\n\n#### Scenario: Using simplified format\n\n- **WHEN** documenting simple capabilities without complex scenarios\n- **THEN** a simplified WHEN/THEN format without full structure MAY be used\n- **AND** this should be consistent within the capability\n\n## Change Storage Convention\n\n### Future State Storage\n\nWHEN creating a change proposal\nTHEN store the complete future state of affected specs\nAND use clean markdown without diff syntax\n\nThe `changes/[name]/specs/` directory SHALL contain:\n- Complete spec files as they will exist after the change\n- Clean markdown without `+` or `-` prefixes\n- All formatting and structure of the final intended state\n\n### Proposal Format\n\nWHEN documenting what changes\nTHEN the proposal SHALL explicitly describe each change:\n\n```markdown\n**[Section or Behavior Name]**\n- From: [current state/requirement]\n- To: [future state/requirement]\n- Reason: [why this change is needed]\n- Impact: [breaking/non-breaking, who's affected]\n```\n\nThis explicit format compensates for not having inline diffs and ensures reviewers understand exactly what will change.\n\n## Change Lifecycle\n\nThe change process SHALL follow these states:\n\n1. **Propose**: AI creates change with future state specs and explicit proposal\n2. **Review**: Humans review proposal and future state\n3. **Approve**: Change is approved for implementation\n4. **Implement**: Follow tasks.md checklist (can span multiple PRs)\n5. **Deploy**: Changes are deployed to production\n6. **Update**: Specs in `specs/` are updated to match deployed reality\n7. **Archive**: Change is moved to `archive/YYYY-MM-DD-[name]/`\n\n## Viewing Changes\n\nWHEN reviewing proposed changes\nTHEN reviewers can compare using:\n- GitHub PR diff view when changes are committed\n- Command line: `diff -u specs/[capability]/spec.md changes/[name]/specs/[capability]/spec.md`\n- Any visual diff tool comparing current vs future state\n\nThe system relies on tools to generate diffs rather than storing them.\n\n## Capability Naming\n\nCapabilities SHALL use:\n- Verb-noun patterns (e.g., `user-auth`, `payment-capture`)\n- Hyphenated lowercase names\n- Singular focus (one responsibility per capability)\n- No nesting (flat structure under `specs/`)\n\n## When Changes Require Proposals\n\nA proposal SHALL be created for:\n- New features or capabilities\n- Breaking changes to existing behavior\n- Architecture or pattern changes\n- Performance optimizations that change behavior\n- Security updates affecting access patterns\n\nA proposal is NOT required for:\n- Bug fixes restoring intended behavior\n- Typos or formatting fixes\n- Non-breaking dependency updates\n- Adding tests for existing behavior\n- Documentation clarifications\n\n## Why This Approach\n\nClean future state storage provides:\n- **Readability**: No diff syntax pollution\n- **AI-compatibility**: Standard markdown that AI tools understand\n- **Simplicity**: No special parsing or processing needed\n- **Tool-agnostic**: Any diff tool can show changes\n- **Clear intent**: Explicit proposals document reasoning\n\nThe structured format adds:\n- **Visual Consistency**: Requirement and Scenario prefixes make sections instantly recognizable\n- **Parseability**: Consistent structure enables tooling and automation\n- **Flexibility**: Alternative formats supported where appropriate\n- **Gradual Adoption**: Existing specs can migrate incrementally"
  },
  {
    "path": "openspec/changes/archive/2025-08-19-structured-spec-format/tasks.md",
    "content": "## 1. Update OpenSpec Conventions Spec\n\n- [x] 1.1 Add \"Specification Format\" section to openspec-conventions\n- [x] 1.2 Document structured format with Requirement/Scenario headers\n- [x] 1.3 Define bold keyword usage (WHEN/THEN/AND) for scenarios\n- [x] 1.4 Include examples demonstrating the format within the spec itself\n\n## 2. Update Documentation\n\n- [x] 2.1 Update the \"Why This Approach\" section with structured format benefits\n- [x] 2.2 Ensure spec follows its own format as a demonstration\n\n## 3. Update Existing Specs\n\n- [x] 3.1 Update cli-init spec to use structured format in Behavior section\n- [x] 3.2 Update cli-list spec to use structured format in Behavior section\n- [x] 3.3 Update cli-update spec to use structured format in Behavior section\n- [x] 3.4 Update cli-diff spec to use structured format in Behavior section\n- [x] 3.5 Update cli-archive spec to use structured format in Behavior section"
  },
  {
    "path": "openspec/changes/archive/2025-09-12-add-view-dashboard-command/proposal.md",
    "content": "# Change: Add View Dashboard Command\n\n## Why\n\nUsers need a quick, at-a-glance overview of their OpenSpec project status without running multiple commands. Currently, users must run `openspec list --changes` and `openspec list --specs` separately to understand the project state. A unified dashboard view would improve developer experience and provide immediate insight into project progress.\n\n## What Changes\n\n### Added `openspec view` Command\n\nThe new command provides an interactive dashboard displaying:\n- Summary metrics (total specs, requirements, changes, task progress)\n- Active changes with visual progress bars\n- Completed changes\n- Specifications with requirement counts\n\n### Specifications Affected\n\n- **cli-view** (NEW): Complete specification for the view dashboard command\n\n## Implementation Details\n\n### File Structure\n- Created `/src/core/view.ts` implementing the `ViewCommand` class\n- Registered command in `/src/cli/index.ts`\n- Reuses existing utilities from `task-progress.ts` and `MarkdownParser`\n\n### Visual Design\n- Uses Unicode box drawing characters for borders\n- Color coding: cyan for specs, yellow for active, green for completed\n- Progress bars using filled (█) and empty (░) blocks\n- Clean alignment with proper padding\n\n### Technical Approach\n- Async data fetching from changes and specs directories\n- Parallel processing of specs and changes\n- Error handling for missing or invalid data\n- Maintains consistency with existing list command output"
  },
  {
    "path": "openspec/changes/archive/2025-09-12-add-view-dashboard-command/specs/cli-view/spec.md",
    "content": "# CLI View Command - Changes\n\n## ADDED Requirements\n\n### Requirement: Dashboard Display\n\nThe system SHALL provide a `view` command that displays a dashboard overview of specs and changes.\n\n#### Scenario: Basic dashboard display\n\n- **WHEN** user runs `openspec view`\n- **THEN** system displays a formatted dashboard with sections for summary, active changes, completed changes, and specifications\n\n#### Scenario: No OpenSpec directory\n\n- **WHEN** user runs `openspec view` in a directory without OpenSpec\n- **THEN** system displays error message \"✗ No openspec directory found\"\n\n### Requirement: Summary Section\n\nThe dashboard SHALL display a summary section with key project metrics.\n\n#### Scenario: Complete summary display\n\n- **WHEN** dashboard is rendered with specs and changes\n- **THEN** system shows total number of specifications and requirements\n- **AND** shows number of active changes in progress\n- **AND** shows number of completed changes\n- **AND** shows overall task progress percentage\n\n#### Scenario: Empty project summary\n\n- **WHEN** no specs or changes exist\n- **THEN** summary shows zero counts for all metrics\n\n### Requirement: Active Changes Display\n\nThe dashboard SHALL show active changes with visual progress indicators.\n\n#### Scenario: Active changes with progress bars\n\n- **WHEN** there are in-progress changes with tasks\n- **THEN** system displays each change with change name left-aligned\n- **AND** visual progress bar using Unicode characters\n- **AND** percentage completion on the right\n\n#### Scenario: No active changes\n\n- **WHEN** all changes are completed or no changes exist\n- **THEN** active changes section is omitted from display\n\n### Requirement: Completed Changes Display\n\nThe dashboard SHALL list completed changes in a separate section.\n\n#### Scenario: Completed changes listing\n\n- **WHEN** there are completed changes (all tasks done)\n- **THEN** system shows them with checkmark indicators in a dedicated section\n\n#### Scenario: Mixed completion states\n\n- **WHEN** some changes are complete and others active\n- **THEN** system separates them into appropriate sections\n\n### Requirement: Specifications Display\n\nThe dashboard SHALL display specifications sorted by requirement count.\n\n#### Scenario: Specs listing with counts\n\n- **WHEN** specifications exist in the project\n- **THEN** system shows specs sorted by requirement count (descending) with count labels\n\n#### Scenario: Specs with parsing errors\n\n- **WHEN** a spec file cannot be parsed\n- **THEN** system includes it with 0 requirement count\n\n### Requirement: Visual Formatting\n\nThe dashboard SHALL use consistent visual formatting with colors and symbols.\n\n#### Scenario: Color coding\n\n- **WHEN** dashboard elements are displayed\n- **THEN** system uses cyan for specification items\n- **AND** yellow for active changes\n- **AND** green for completed items\n- **AND** dim gray for supplementary text\n\n#### Scenario: Progress bar rendering\n\n- **WHEN** displaying progress bars\n- **THEN** system uses filled blocks (█) for completed portions and light blocks (░) for remaining\n\n### Requirement: Error Handling\n\nThe view command SHALL handle errors gracefully.\n\n#### Scenario: File system errors\n\n- **WHEN** file system operations fail\n- **THEN** system continues with available data and omits inaccessible items\n\n#### Scenario: Invalid data structures\n\n- **WHEN** specs or changes have invalid format\n- **THEN** system skips invalid items and continues rendering"
  },
  {
    "path": "openspec/changes/archive/2025-09-12-add-view-dashboard-command/tasks.md",
    "content": "# Implementation Tasks\n\n## Design Phase\n- [x] Research existing list command implementation\n- [x] Design dashboard layout and information architecture\n- [x] Choose appropriate command verb (`view`)\n- [x] Define visual elements (progress bars, colors, layout)\n\n## Core Implementation\n- [x] Create ViewCommand class in `/src/core/view.ts`\n- [x] Implement getChangesData method for fetching change information\n- [x] Implement getSpecsData method for fetching spec information\n- [x] Implement displaySummary method for summary metrics\n- [x] Add progress bar visualization with Unicode characters\n- [x] Implement color coding using chalk\n\n## Integration\n- [x] Import ViewCommand in CLI index\n- [x] Register `openspec view` command with commander\n- [x] Add proper error handling and ora spinner integration\n- [x] Ensure command appears in help documentation\n\n## Data Processing\n- [x] Reuse TaskProgress utilities for change progress\n- [x] Integrate MarkdownParser for spec requirement counting\n- [x] Handle async operations for file system access\n- [x] Sort specifications by requirement count\n\n## Testing and Validation\n- [x] Build project successfully with new command\n- [x] Test command with sample data\n- [x] Verify correct requirement counts match list --specs\n- [x] Test progress bar display for various completion states\n- [x] Run existing test suite to ensure no regressions\n- [x] Verify TypeScript compilation with no errors\n\n## Documentation\n- [x] Add command description in CLI help\n- [x] Create change proposal documentation\n- [x] Update README with view command example (if needed)\n- [x] Add view command to user documentation (if exists)\n\n## Polish\n- [x] Ensure consistent formatting and alignment\n- [x] Add helpful footer text referencing list commands\n- [x] Optimize for terminal width considerations\n- [x] Review and refine color choices for accessibility"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-add-agents-md-config/proposal.md",
    "content": "# Add AGENTS.md Standard Support To Init/Update\n\n## Summary\n- Teach `openspec init` to manage a root-level `AGENTS.md` file using the same marker system as `CLAUDE.md`.\n- Allow `openspec update` to refresh or scaffold that root `AGENTS.md` so AGENTS-compatible tools always receive current instructions.\n- Keep the existing `openspec/AGENTS.md` template as the canonical source while ensuring assistants that read `AGENTS.md` opt-in instructions get the latest guidance automatically.\n\n## Motivation\nThe README now points teams to AGENTS.md-compatible assistants, but the CLI only manages `CLAUDE.md`. Projects must hand-roll a root `AGENTS.md` file to benefit from the standard, and updates will drift unless maintainers remember to copy content manually. Extending `init` and `update` closes that gap so OpenSpec actually delivers on the promise of first-class AGENTS support.\n\n## Proposal\n1. Extend the `openspec init` selection flow with an \"AGENTS.md standard\" option that creates or refreshes a root `AGENTS.md` file wrapped in OpenSpec markers, mirroring the existing CLAUDE integration.\n2. When generating the file, pull the managed content from the same template used in `openspec/AGENTS.md`, ensuring both locations stay in sync.\n3. Update `openspec update` so it always refreshes the root `AGENTS.md` (creating it if missing) alongside `openspec/AGENTS.md` and any other configured assistants.\n4. Document the new behavior in CLI specs and verify marker handling (no duplicates, preserve user content outside the block) with tests for both commands.\n\n## Out of Scope\n- Adding additional AGENTS-specific prompts or workflows beyond the shared instructions block.\n- Non-interactive flags or bulk configuration for multiple standards in one run.\n- Broader restructuring of how templates are stored or loaded.\n\n## Risks & Mitigations\n- **Risk:** Accidentally overwriting user-edited content surrounding the managed block.\n  - **Mitigation:** Reuse the existing marker-update helper shared with `CLAUDE.md`, and add tests that cover files containing custom text before and after the block.\n- **Risk:** Divergence between `openspec/AGENTS.md` and the root file.\n  - **Mitigation:** Source the root file content from the canonical template rather than duplicating strings inline.\n- **Risk:** Confusion about when the file is created.\n  - **Mitigation:** Log creation vs update, and ensure help text references the AGENTS option during `init`.\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-add-agents-md-config/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: AI Tool Configuration\nThe command SHALL configure AI coding assistants with OpenSpec instructions based on user selection.\n\n#### Scenario: Prompting for AI tool selection\n\n- **WHEN** run\n- **THEN** prompt user to select AI tools to configure:\n  - Claude Code (✅ OpenSpec custom slash commands available)\n  - Cursor (✅ OpenSpec custom slash commands available)\n  - AGENTS.md (works with Codex, Amp, Copilot, …)\n\n### Requirement: AI Tool Configuration Details\nThe command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.\n\n#### Scenario: Configuring Claude Code\n\n- **WHEN** Claude Code is selected\n- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)\n\n#### Scenario: Configuring AGENTS standard\n\n- **WHEN** the AGENTS.md standard is selected\n- **THEN** create or update `AGENTS.md` in the project root directory (not inside openspec/)\n\n#### Scenario: Creating new CLAUDE.md\n\n- **WHEN** CLAUDE.md does not exist\n- **THEN** create new file with OpenSpec content wrapped in markers:\n```markdown\n<!-- OPENSPEC:START -->\n# OpenSpec Project\n\nThis document provides instructions for AI coding assistants on how to use OpenSpec conventions for spec-driven development. Follow these rules precisely when working on OpenSpec-enabled projects.\n\nThis project uses OpenSpec for spec-driven development. Specifications are the source of truth.\n\nSee @openspec/AGENTS.md for detailed conventions and guidelines.\n<!-- OPENSPEC:END -->\n```\n\n#### Scenario: Creating new AGENTS.md\n\n- **WHEN** AGENTS.md does not exist in the project root\n- **THEN** create new file with OpenSpec content wrapped in markers using the same template as CLAUDE.md\n\n#### Scenario: Updating existing CLAUDE.md\n\n- **WHEN** CLAUDE.md already exists\n- **THEN** preserve all existing content\n- **AND** insert OpenSpec content at the beginning of the file using markers\n- **AND** ensure markers don't duplicate if they already exist\n\n#### Scenario: Updating existing AGENTS.md\n\n- **WHEN** AGENTS.md already exists in the project root\n- **THEN** preserve all existing content\n- **AND** ensure the OpenSpec-managed block at the beginning of the file is refreshed without duplicating markers\n\n#### Scenario: Managing content with markers\n\n- **WHEN** using the marker system\n- **THEN** use `<!-- OPENSPEC:START -->` to mark the beginning of managed content\n- **AND** use `<!-- OPENSPEC:END -->` to mark the end of managed content\n- **AND** allow OpenSpec to update its content without affecting user customizations\n- **AND** preserve all content outside the markers intact\n\nWHY use markers:\n- Users may have existing CLAUDE.md or AGENTS.md instructions they want to keep\n- OpenSpec can update its instructions in future versions\n- Clear boundary between OpenSpec-managed and user-managed content\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-add-agents-md-config/specs/cli-update/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Update Behavior\nThe update command SHALL update OpenSpec instruction files to the latest templates in a team-friendly manner.\n\n#### Scenario: Running update command\n\n- **WHEN** a user runs `openspec update`\n- **THEN** the command SHALL:\n  - Check if the `openspec` directory exists\n  - Replace `openspec/AGENTS.md` with the latest template (complete replacement)\n  - Create or refresh a root-level `AGENTS.md` file using the managed marker block (create if missing)\n  - Update **only existing** AI tool configuration files (e.g., CLAUDE.md)\n    - Check each registered AI tool configurator\n    - For each configurator, check if its file exists\n    - Update only files that already exist using their markers\n    - Preserve user content outside markers\n  - Display success message listing updated files\n\n### Requirement: Tool-Agnostic Updates\nThe update command SHALL handle file updates in a predictable and safe manner while respecting team tool choices.\n\n#### Scenario: Updating files\n\n- **WHEN** updating files\n- **THEN** completely replace `openspec/AGENTS.md` with the latest template\n- **AND** create or update the root-level `AGENTS.md` using the OpenSpec markers\n- **AND** update only the OpenSpec-managed blocks in **existing** AI tool files using markers\n- **AND** use the default directory name `openspec`\n- **AND** be idempotent (repeated runs have no additional effect)\n- **AND** respect team members' AI tool choices by not creating additional tool files beyond the root `AGENTS.md`\n\n### Requirement: Core Files Always Updated\nThe update command SHALL always update the core OpenSpec files and display an ASCII-safe success message.\n\n#### Scenario: Successful update\n\n- **WHEN** the update completes successfully\n- **THEN** replace `openspec/AGENTS.md` with the latest template\n- **AND** ensure the root-level `AGENTS.md` matches the latest template via the marker block\n- **AND** update existing AI tool configuration files within markers\n- **AND** display the message: \"Updated OpenSpec instructions\"\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-add-agents-md-config/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Extend Init Workflow\n- [x] 1.1 Add an \"AGENTS.md standard\" option to the `openspec init` tool-selection prompt, respecting the existing UI conventions.\n- [x] 1.2 Generate or refresh a root-level `AGENTS.md` file using the OpenSpec markers when that option is selected, sourcing content from the canonical template.\n\n## 2. Enhance Update Command\n- [x] 2.1 Ensure `openspec update` writes the root `AGENTS.md` from the latest template (creating it if missing) alongside `openspec/AGENTS.md`.\n- [x] 2.2 Update success messaging and logging to reflect creation vs refresh of the AGENTS standard file.\n\n## 3. Shared Template Handling\n- [x] 3.1 Refactor template utilities if necessary so both commands reuse the same content without duplication.\n- [x] 3.2 Add automated tests covering init/update flows for projects with and without an existing `AGENTS.md`, ensuring markers behave correctly.\n\n## 4. Documentation\n- [x] 4.1 Update CLI specs and user-facing docs to describe AGENTS standard support.\n- [x] 4.2 Run `openspec validate add-agents-md-config --strict` and document any notable behavior changes.\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-add-multi-agent-init/proposal.md",
    "content": "# Allow Additional AI Tool Initialization After Setup\n\n## Summary\n- Let `openspec init` configure new AI coding tools for projects that already contain an OpenSpec structure.\n- Keep the initialization flow safe by skipping structure creation and only generating files for tools the user explicitly selects.\n- Provide clear feedback so users know which tool files were added versus already present.\n\n## Motivation\nToday `openspec init` exits with an error once an `openspec/` directory exists. That protects the directory layout, but it blocks\nteams that start with one assistant (for example, Claude Code) and later want to add another such as Cursor. They have to create\nthose files by hand or rerun `init` in a clean clone, which undermines the \"easy onboarding\" promise. Letting the command extend\nan existing installation keeps the workflow consistent and avoids manual file management.\n\n## Proposal\n1. Detect an existing OpenSpec structure at the start of `openspec init` and branch into an \"extend\" mode instead of exiting.\n   - Announce that the base structure already exists and that the command will only manage AI tool configuration files.\n   - Keep the existing guard for directories or files we must not overwrite.\n2. Present the usual AI tool selection prompt even in extend mode, showing which tools are already configured.\n   - Skip disabled options that remain \"coming soon\".\n   - Mark already configured tools as such so users know whether selecting them will refresh or add files.\n3. When the user selects additional tools, generate the same initialization files that a fresh run would create (e.g., Cursor\n   workspace files) while leaving untouched tools intact apart from marker-managed sections.\n   - Do nothing when the user selects no new tools and keep the previous error messaging to avoid silently succeeding.\n4. Summarize the outcome (created, refreshed, skipped) before exiting with code 0 when work was performed.\n   - Include friendly guidance that future updates to shared content still come from `openspec update`.\n\n## Out of Scope\n- Changing how `openspec update` discovers or updates AI tool files.\n- Supporting brand-new AI tools beyond those already wired into the CLI.\n- Adding non-interactive flags for selecting multiple tools in one run (follow-up if needed).\n\n## Risks & Mitigations\n- **User confusion about extend mode** → Explicitly log what will happen before prompting and summarise results afterward.\n- **Accidental overwrites** → Continue using marker-based updates and skip files unless the user chooses that tool.\n- **Inconsistent state if init fails mid-run** → Reuse existing rollback/transaction logic so partial writes clean up.\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-add-multi-agent-init/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Safety Checks\nThe command SHALL perform safety checks to prevent overwriting existing structures and ensure proper permissions.\n\n#### Scenario: Detecting existing initialization\n- **WHEN** the `openspec/` directory already exists\n- **THEN** inform the user that OpenSpec is already initialized, skip recreating the base structure, and enter an extend mode\n- **AND** continue to the AI tool selection step so additional tools can be configured\n- **AND** display the existing-initialization error message only when the user declines to add any AI tools\n\n### Requirement: Interactive Mode\nThe command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.\n\n#### Scenario: Displaying interactive menu\n- **WHEN** run in fresh or extend mode\n- **THEN** present a looping select menu that lets users toggle tools with Enter and finish via a \"Done\" option\n- **AND** label already configured tools with \"(already configured)\" while keeping disabled options marked \"coming soon\"\n- **AND** change the prompt copy in extend mode to \"Which AI tools would you like to add or refresh?\"\n- **AND** display inline instructions clarifying that Enter toggles a tool and selecting \"Done\" confirms the list\n\n## ADDED Requirements\n### Requirement: Additional AI Tool Initialization\n`openspec init` SHALL allow users to add configuration files for new AI coding assistants after the initial setup.\n\n#### Scenario: Configuring an extra tool after initial setup\n- **GIVEN** an `openspec/` directory already exists and at least one AI tool file is present\n- **WHEN** the user runs `openspec init` and selects a different supported AI tool\n- **THEN** generate that tool's configuration files with OpenSpec markers the same way as during first-time initialization\n- **AND** leave existing tool configuration files unchanged except for managed sections that need refreshing\n- **AND** exit with code 0 and display a success summary highlighting the newly added tool files\n\n### Requirement: Success Output Enhancements\n`openspec init` SHALL summarize tool actions when initialization or extend mode completes.\n\n#### Scenario: Showing tool summary\n- **WHEN** the command completes successfully\n- **THEN** display a categorized summary of tools that were created, refreshed, or skipped (including already-configured skips)\n- **AND** personalize the \"Next steps\" header using the names of the selected tools, defaulting to a generic label when none remain\n\n### Requirement: Exit Code Adjustments\n`openspec init` SHALL treat extend mode with no selected tools as a guarded error.\n\n#### Scenario: Preventing empty extend runs\n- **WHEN** OpenSpec is already initialized and the user selects no additional tools\n- **THEN** exit with code 1 after showing the existing-initialization guidance message\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-add-multi-agent-init/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Extend Init Guard\n- [x] 1.1 Detect existing OpenSpec structures at the start of `openspec init` and enter an extend mode instead of failing.\n- [x] 1.2 Log that core scaffolding will be skipped while still protecting against missing write permissions.\n\n## 2. Update AI Tool Selection\n- [x] 2.1 Present AI tool choices even in extend mode, indicating which tools are already configured.\n- [x] 2.2 Ensure disabled \"coming soon\" tools remain non-selectable.\n\n## 3. Generate Additional Tool Files\n- [x] 3.1 Create configuration files for newly selected tools while leaving untouched tools unaffected apart from marker-managed sections.\n- [x] 3.2 Summarize created, refreshed, and skipped tools before exiting with the appropriate code.\n\n## 4. Verification\n- [x] 4.1 Add tests covering rerunning `openspec init` to add another tool and the scenario where the user declines to add anything.\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-add-slash-command-support/proposal.md",
    "content": "# Add Slash Command Support for Coding Agents\n\n## Summary\n- Enable OpenSpec to generate and update custom slash commands for supported coding agents (Claude Code and Cursor).\n- Provide three slash commands aligned with OpenSpec's workflow: proposal (start a change proposal), apply (implement), and archive.\n- Share slash command templating between agents to make future extensions simple.\n\n## Motivation\nDevelopers use different coding agents and editors. Having consistent slash commands across tools for the OpenSpec workflow reduces friction and ensures a standard way to trigger the workflow. Supporting both Claude Code and Cursor now lays a foundation for future agents that introduce slash command features.\n\n## Proposal\n1. During `openspec init`, when a user selects a supported tool, generate slash command configuration for three OpenSpec workflow stages:\n   - Claude (namespaced): `/openspec/proposal`, `/openspec/apply`, `/openspec/archive`.\n   - Cursor (flat, prefixed): `/openspec-proposal`, `/openspec-apply`, `/openspec-archive`.\n   - Semantics:\n     - Create – scaffold a change (ID, `proposal.md`, `tasks.md`, delta specs); validate strictly.\n     - Apply – implement an approved change; complete tasks; validate strictly.\n     - Archive – archive after deployment; update specs if needed.\n   - Each command file MUST embed concise, step-by-step instructions sourced from `openspec/README.md` (see Template Content section).\n2. Store slash command files per tool:\n   - Claude Code: `.claude/commands/openspec/{proposal,apply,archive}.md`\n   - Cursor: `.cursor/commands/{openspec-proposal,openspec-apply,openspec-archive}.md`\n   - Ensure nested directories are created.\n3. Command file format and metadata:\n   - Use Markdown with optional YAML frontmatter for tool metadata (name/title, description, category/tags) when supported by the tool.\n   - Place OpenSpec markers around the body only, never inside frontmatter.\n   - Keep the visible slash name, file name, and any frontmatter `name`/`id` consistently aligned (e.g., `proposal`, `openspec-proposal`).\n   - Namespacing: categorize these under “OpenSpec” and prefer unique IDs (e.g., `openspec-proposal`) to avoid collisions.\n4. Centralize templates: define command bodies once and reuse across tools; apply minimal per-tool wrappers (frontmatter, categories, filenames).\n5. During `openspec update`, refresh only existing slash command files (per-file basis) within markers; do not create missing files or new tools.\n\n## Design Ideas\n- Introduce `SlashCommandConfigurator` to manage multiple files per tool.\n  - Expose targets rather than a single `configFileName` (e.g., `getTargets(): Array<{ path: string; kind: 'slash'; id: string }>`).\n  - Provide `generateAll(projectPath, openspecDir)` for init and `updateExisting(projectPath, openspecDir)` for update.\n- Per-tool adapters add only frontmatter and pathing; bodies come from shared templates.\n- Templates live in `TemplateManager` with helpers that extract concise, authoritative snippets from `openspec/README.md`.\n- Update flow logs per-file results so users see exactly which slash files were refreshed.\n\n### Marker Placement\n- Markers MUST wrap only the Markdown body contents:\n  - Frontmatter (if present) goes first.\n  - Then `<!-- OPENSPEC:START -->` … body … `<!-- OPENSPEC:END -->`.\n  - Avoid inserting markers into the YAML block to prevent parse errors.\n\n### Idempotency and Creation Rules\n- `init`: create all three files for the chosen tool(s) once; subsequent `init` runs are no-ops for existing files.\n- `update`: refresh only files that exist; skip missing ones without creating new files.\n- Directory creation for `.claude/commands/openspec/` and `.cursor/commands/` is the configurator’s responsibility.\n\n### Command Naming & UX\n- Claude Code: use namespacing in the slash itself for readability and grouping: `/openspec/proposal`, `/openspec/apply`, `/openspec/archive`.\n- Cursor: use flat names with an `openspec-` prefix: `/openspec-proposal`, `/openspec-apply`, `/openspec-archive`. Group via `category: OpenSpec` when supported.\n- Consistency: align file names, visible slash names, and any frontmatter `id` (e.g., `id: openspec-apply`).\n- Migration: do not rename existing commands during `update`; apply new naming only on `init` (or via an explicit migrate step).\n\n## Open Questions\n- Validate exact metadata/frontmatter supported by each tool version; if unsupported, omit frontmatter and ship Markdown body only.\n- Confirm the final Cursor command file location for the targeted versions; fall back to Markdown-only if Cursor does not parse frontmatter.\n- Evaluate additional commands beyond the initial three (e.g., `/show-change`, `/validate-all`) based on user demand.\n\n## Alternatives\n- Hard-code slash command text per tool (rejected: duplicates content; increases maintenance).\n- Delay Cursor support until its config stabilizes (partial accept): gate Cursor behind a feature flag until verified in real environments.\n\n## Risks\n- Tool configuration formats may change, requiring updates to wrappers/frontmatter.\n- Incorrect paths or categories can hide commands; add path existence checks and clear logging.\n- Marker misuse (inside frontmatter) can break parsing; enforce placement rules in tests.\n\n## Future Work\n- Support additional editors/agents that expose slash command APIs.\n- Allow users to customize command names and categories during `openspec init`.\n- Provide a dedicated command to regenerate slash commands without running full `update`.\n\n## File Format Examples\nThe following examples illustrate expected structure. If a tool does not support frontmatter, omit the YAML block and keep only the markers + body.\n\n### Claude Code: `.claude/commands/openspec/proposal.md`\n```markdown\n---\nname: OpenSpec: Proposal\ndescription: Scaffold a new OpenSpec change and validate strictly.\ncategory: OpenSpec\ntags: [openspec, change]\n---\n<!-- OPENSPEC:START -->\n...command body from shared template...\n<!-- OPENSPEC:END -->\n```\n\nSlash invocation: `/openspec/proposal` (namespaced)\n\n### Cursor: `.cursor/commands/openspec-proposal.md`\n```markdown\n---\nname: /openspec-proposal\nid: openspec-proposal\ncategory: OpenSpec\ndescription: Scaffold a new OpenSpec change and validate strictly.\n---\n<!-- OPENSPEC:START -->\n...command body from shared template...\n<!-- OPENSPEC:END -->\n```\n\nSlash invocation: `/openspec-proposal` (flat, prefixed)\n\n## Template Content\nTemplates should be brief, actionable, and sourced from `openspec/README.md` to avoid duplication. Each command body includes:\n- Guardrails: ask 1–2 clarifying questions if needed; follow minimal-complexity rules; use `pnpm` for Node projects.\n- Step list tailored to the workflow stage (proposal, apply, archive), including strict validation commands.\n- Pointers to `openspec show`, `openspec list`, and troubleshooting tips when validation fails.\n\n## Testing Strategy\n- Golden snapshots for generated files per tool (frontmatter + markers + body).\n- Partial presence tests: if 1–2 files exist, `update` only refreshes those and does not create missing ones.\n- Marker placement tests: ensure markers never appear inside frontmatter; cover missing/duplicated marker recovery behavior.\n- Logging tests: `update` reports per-file updates for slash commands.\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-add-slash-command-support/specs/cli-init/spec.md",
    "content": "## ADDED Requirements\n### Requirement: Slash Command Configuration\nThe init command SHALL generate slash command files for supported editors using shared templates.\n\n#### Scenario: Generating slash commands for Claude Code\n- **WHEN** the user selects Claude Code during initialization\n- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cursor\n- **WHEN** the user selects Cursor during initialization\n- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for OpenCode\n- **WHEN** the user selects OpenCode during initialization\n- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-add-slash-command-support/specs/cli-update/spec.md",
    "content": "## ADDED Requirements\n### Requirement: Slash Command Updates\nThe update command SHALL refresh existing slash command files for configured tools without creating new ones.\n\n#### Scenario: Updating slash commands for Claude Code\n- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cursor\n- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for OpenCode\n- **WHEN** `.opencode/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Missing slash command file\n- **WHEN** a tool lacks a slash command file\n- **THEN** do not create a new file during update\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-add-slash-command-support/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Templates and Configurators\n- [x] 1.1 Create shared templates for the Proposal, Apply, and Archive commands with instructions for each workflow stage from `openspec/README.md`.\n- [x] 1.2 Implement a `SlashCommandConfigurator` base and tool-specific configurators for Claude Code and Cursor.\n\n## 2. Claude Code Integration\n- [x] 2.1 Generate `.claude/commands/openspec/{proposal,apply,archive}.md` during `openspec init` using shared templates.\n- [x] 2.2 Update existing `.claude/commands/openspec/*` files during `openspec update`.\n\n## 3. Cursor Integration\n- [x] 3.1 Generate `.cursor/commands/{openspec-proposal,openspec-apply,openspec-archive}.md` during `openspec init` using shared templates.\n- [x] 3.2 Update existing `.cursor/commands/*` files during `openspec update`.\n\n## 4. Verification\n- [x] 4.1 Add tests verifying slash command files are created and updated correctly.\n\n## 5. OpenCode Integration\n- [x] 5.1 Generate `.opencode/commands/{openspec-proposal,openspec-apply,openspec-archive}.md` during `openspec init` using shared templates.\n- [x] 5.2 Update existing `.opencode/commands/*` files during `openspec update`.\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-improve-cli-e2e-plan/proposal.md",
    "content": "## Why\nRecent cross-shell regressions for `openspec` commands revealed that our existing unit/integration tests do not exercise the packaged CLI or shell-specific behavior. The prior attempt at Vitest spawn tests stalled because it coupled e2e coverage with `pnpm pack` installs, which fail in network-restricted environments. With those findings incorporated, we now need an approved plan to realign the work.\n\n## What Changes\n- Adopt a phased strategy that first stabilizes direct spawn testing of the built CLI (`node dist/cli/index.js`) using lightweight fixtures and a shared `runCLI` helper.\n- Expand coverage once the spawn harness is stable, keeping the initial matrix focused on bash jobs for Linux/macOS and `pwsh` on Windows while exercising both the direct `node dist/cli/index.js` invocation and the bin shim with non-TTY defaults and captured diagnostics.\n- Treat packaging/install validation as an optional CI safeguard: when a runner has registry access, run a simple pnpm-based pack→install→smoke-test flow; otherwise document it as out of scope while closing remaining hardening items.\n- Close out the remaining cross-shell hardening items: ensure `.gitattributes` covers packaged assets, enforce executable bits for CLI shims during CI, and finish the pending SIGINT handling improvements.\n\n## Impact\n- Tests: add `test/cli-e2e` spawn suite, create the shared `runCLI` helper, and adjust `vitest.setup.ts` as needed.\n- Tooling: update GitHub Actions workflows with the lightweight matrix above and (optionally) a packaging install check where network is available.\n- Docs: note phase progress and any limitations inline in this proposal (or the relevant spec) so future phases have clear context.\n\n### Phase 1 Status\n- Shared `test/helpers/run-cli.ts` guarantees the CLI bundle exists before spawning and enforces non-TTY defaults for every invocation.\n- New `test/cli-e2e/basic.test.ts` covers `--help`, `--version`, a successful `validate --all --json`, and an unknown-item error path against the `tmp-init` fixture copy.\n- Legacy top-level `validate` exec tests now rely on `runCLI`, avoiding manual `execSync` usage while keeping their fixture authoring intact.\n- CI matrix groundwork is in place (bash on Linux/macOS, pwsh on Windows) so the spawn suite runs the same way the helper does across supported shells.\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-improve-cli-e2e-plan/tasks.md",
    "content": "## 1. Phase 1 – Stabilize Local Spawn Coverage\n- [x] 1.1 Add `test/helpers/run-cli.ts` that ensures the build runs once and executes `node dist/cli/index.js` with non-TTY defaults; update `vitest.setup.ts` to reuse the shared build step.\n- [x] 1.2 Seed `test/cli-e2e` using the minimal fixture set (`tmp-init` or copy) to cover help/version, a happy-path `validate`, and a representative error flow via the new helper.\n- [x] 1.3 Migrate the highest-value existing CLI exec tests (e.g., validate) onto `runCLI` and summarize Phase 1 coverage in this proposal for the next phase.\n\n## 2. Phase 2 – Expand Cross-Shell Validation\n- [x] 2.1 Exercise both entry points (`node dist/cli/index.js`, `bin/openspec.js`) in the spawn suite and add diagnostics for shell/OS context.\n- [x] 2.2 Extend GitHub Actions to run the spawn suite on bash jobs for Linux/macOS and a `pwsh` job on Windows; capture shell/OS diagnostics and note follow-ups for additional shells.\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-improve-deterministic-tests/proposal.md",
    "content": "# Change: Improve Deterministic Tests (Isolate From Repo State)\n\n## Problem\n\nSome unit tests (e.g., ChangeCommand.show/validate) read the live repository\nstate via `process.cwd()` and `openspec/changes`. This makes outcomes depend on\nwhatever directories happen to exist and the order returned by `fs.readdir`,\ncausing flaky success/failure across environments.\n\nSymptoms observed:\n- Tests sometimes select a partial or unrelated change folder.\n- Failures like missing `proposal.md` when a stray change directory is picked.\n- Environment/sandbox differences alter `readdir` ordering and worker behavior.\n\n## Goals\n\n- Make tests deterministic and hermetic.\n- Remove dependence on real repo contents and directory ordering.\n- Keep runtime behavior unchanged for end users.\n\n## Non‑Goals\n\n- Introduce heavy frameworks or test harness complexity.\n- Redesign CLI behavior or change default paths for users.\n\n## Approach\n\n1) Test-local fixture root\n- Each suite that touches filesystem discovery creates a temporary directory:\n  - `openspec/changes/sample-change/proposal.md`\n  - `openspec/changes/sample-change/specs/sample/spec.md`\n- `beforeAll`: `process.chdir(tmpRoot)`; `afterAll`: restore original cwd.\n- Use a constant `changeName = 'sample-change'`; remove reliance on\n  `readdir` order.\n\n2) Optional thin DI for commands (minimal, if needed)\n- Allow `ChangeCommand` (and similar) to accept an optional `root` path\n  (default `process.cwd()`), used for path resolution.\n- Tests pass the temp root explicitly; production code remains unchanged.\n\n3) Harden discovery helpers (safe enhancement)\n- Update `getActiveChangeIds()`/`getActiveChanges()` to include only\n  directories containing `proposal.md` (and optionally at least one\n  `specs/*/spec.md`).\n- Prevents incomplete/stray change folders from being treated as active.\n\n## Rationale\n\n- Small, focused changes eliminate flakiness without altering user workflows.\n- Temporary fixtures are a well-understood testing pattern and keep tests fast.\n- Optional constructor root param is a minimal DI surface that avoids global\n  stubbing and keeps code simple.\n\n## Risks & Mitigations\n\n- Risk: Tests forget to restore `process.cwd()`.\n  - Mitigation: Add `afterAll` guard restoring cwd; reset `process.exitCode` in\n    `afterEach` where modified.\n- Risk: Behavior divergence if DI root is misused.\n  - Mitigation: Default to `process.cwd()`; only tests pass custom roots.\n\n## Acceptance Criteria\n\n- Tests that previously depended on repo state now:\n  - Create and use a temp fixture root.\n  - Do not read real `openspec/changes` during execution.\n  - Pass consistently regardless of directory order or stray folders.\n- No change to CLI behavior for end users (paths still default to cwd).\n\n## Rollout\n\n- Phase 1: Convert the suites that hit `ChangeCommand.show/validate` to\n  isolated fixtures; verify stability locally and in CI.\n- Phase 2: Apply the same pattern to any remaining suites that touch file\n  discovery (`list`, `show`, `validate`, `diff`).\n- Phase 3 (optional): Introduce the constructor `root` param and discovery\n  hardening, if Phase 1 alone isn’t sufficient.\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-improve-deterministic-tests/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Test Isolation\n- [x] 1.1 Create temp fixture roots per suite (openspec/changes, openspec/specs)\n- [x] 1.2 Use process.chdir to temp root within tests\n- [x] 1.3 Restore original cwd and clean temp dirs after each\n\n## 2. Deterministic Discovery\n- [x] 2.1 Implement getActiveChangeIds(root?) to only include dirs with proposal.md\n- [x] 2.2 Implement getSpecIds(root?) to only include dirs with spec.md\n- [x] 2.3 Return sorted results to avoid fs.readdir ordering variance\n\n## 3. Command Integration\n- [x] 3.1 Ensure change/show/validate rely on cwd and discovery helpers\n- [x] 3.2 Keep runtime behavior unchanged for end users\n\n## 4. Validation\n- [x] 4.1 Convert affected command tests (show, spec, validate, change) to isolated fixtures\n- [x] 4.2 Verify tests pass consistently across environments\n- [x] 4.3 Confirm no reads from real repo state during tests\n\n## 5. Optional (Not Needed Now)\n- [x] 5.1 Add optional root param to discovery helpers (default process.cwd())\n\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-improve-init-onboarding/proposal.md",
    "content": "## Why\nThe current `openspec init` flow assumes a single assistant selection and stops once an OpenSpec structure already exists. That makes onboarding feel rigid: teams cannot configure multiple tools in one pass, they do not learn which files were refreshed, and the success copy always references Claude even when other assistants are involved.\n\n## What Changes\n- Allow selecting multiple assistants during `openspec init`, including refreshing existing configurations in a single run.\n- Provide richer onboarding copy that summarizes which tool files were created or refreshed and guides users on next steps for each assistant.\n- Align generated AI-instruction content and specs so CLAUDE.md and AGENTS.md share the same OpenSpec guidance.\n- Update specs and tests to cover the multi-select prompt, improved summaries, and extend-mode coordination.\n\n## Impact\n- Specs: `cli-init`\n- Code: `src/core/init.ts`, `src/core/config.ts`, `src/core/templates/*`, `src/core/configurators/*`\n- Tests: `test/core/init.test.ts`, `test/core/update.test.ts`\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-improve-init-onboarding/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: AI Tool Configuration\n\nThe command SHALL configure AI coding assistants with OpenSpec instructions based on user selection.\n\n#### Scenario: Prompting for AI tool selection\n\n- **WHEN** run interactively\n- **THEN** prompt the user with \"Which AI tools do you use?\" using a multi-select menu\n- **AND** list every available tool with a checkbox:\n  - Claude Code (creates or refreshes CLAUDE.md and slash commands)\n  - Cursor (creates or refreshes `.cursor/commands/*` slash commands)\n  - AGENTS.md standard (creates or refreshes AGENTS.md with OpenSpec markers)\n- **AND** show \"(already configured)\" beside tools whose managed files exist so users understand selections will refresh content\n- **AND** treat disabled tools as \"coming soon\" and keep them unselectable\n- **AND** allow confirming with Enter after selecting one or more tools\n\n### Requirement: AI Tool Configuration Details\n\nThe command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.\n\n#### Scenario: Configuring Claude Code\n\n- **WHEN** Claude Code is selected\n- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)\n\n#### Scenario: Creating new CLAUDE.md\n\n- **WHEN** CLAUDE.md does not exist\n- **THEN** create new file with OpenSpec content wrapped in markers:\n```markdown\n<!-- OPENSPEC:START -->\n# OpenSpec Instructions\n\nInstructions for AI coding assistants using OpenSpec for spec-driven development.\n\n## TL;DR Quick Checklist\n- Search existing work: `openspec spec list --long`, `openspec list`\n- Decide scope: new capability vs modify existing capability\n- Pick a unique `change-id`: verb-led kebab-case (`add-`, `update-`, `remove-`, `refactor-`)\n- Scaffold: `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas\n- Validate with `openspec validate [change-id] --strict`\n- Request approval before implementation\n<!-- OPENSPEC:END -->\n```\n\n#### Scenario: Updating existing CLAUDE.md\n\n- **WHEN** CLAUDE.md already exists\n- **THEN** preserve all existing content\n- **AND** insert OpenSpec content at the beginning of the file using markers\n- **AND** ensure markers don't duplicate if they already exist\n\n#### Scenario: Managing content with markers\n\n- **WHEN** using the marker system\n- **THEN** use `<!-- OPENSPEC:START -->` to mark the beginning of managed content\n- **AND** use `<!-- OPENSPEC:END -->` to mark the end of managed content\n- **AND** allow OpenSpec to update its content without affecting user customizations\n- **AND** preserve all content outside the markers intact\n\n### Requirement: Interactive Mode\n\nThe command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.\n\n#### Scenario: Displaying interactive menu\n\n- **WHEN** run\n- **THEN** prompt the user with: \"Which AI tools do you use?\"\n- **AND** show a checkbox-based multi-select menu with available tools (Claude Code, Cursor, AGENTS.md standard)\n- **AND** show disabled options as \"coming soon\" (not selectable)\n- **AND** display inline help indicating Space toggles selections and Enter confirms\n\n#### Scenario: Navigating the menu\n\n- **WHEN** the user is in the menu\n- **THEN** allow arrow keys to move between options\n- **AND** allow Spacebar to toggle the highlighted option\n- **AND** allow Enter key to confirm all current selections\n\n### Requirement: Success Output\n\nThe command SHALL provide clear, actionable next steps upon successful initialization.\n\n#### Scenario: Displaying success message\n\n- **WHEN** initialization completes successfully\n- **THEN** display a success banner followed by actionable prompts tailored to the selected tools\n- **AND** summarize which assistant files were created versus refreshed (e.g., `CLAUDE.md (created)`, `.cursor/commands/openspec-apply.md (refreshed)`)\n- **AND** include copy-pasteable onboarding prompts for each configured assistant, replacing placeholder text ([YOUR FEATURE HERE]) with real guidance to customize\n- **AND** reference AGENTS.md-compatible assistants when no tool-specific file exists (e.g., when only AGENTS.md standard is selected)\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-improve-init-onboarding/tasks.md",
    "content": "## 1. Planning & Spec Updates\n- [x] 1.1 Confirm overlap with `add-multi-agent-init` and coordinate extend-mode flow\n- [x] 1.2 Update `openspec/specs/cli-init/spec.md` to capture multi-select onboarding requirements\n\n## 2. Implementation\n- [x] 2.1 Add multi-select support to the `openspec init` prompt, including indicators for existing tool configs\n- [x] 2.2 Enhance success messaging to summarize created/refreshed assets per tool\n- [x] 2.3 Ensure shared instruction template is applied consistently (CLAUDE.md, AGENTS.md, slash commands)\n\n## 3. Quality\n- [x] 3.1 Expand unit tests for init/update flows covering multi-select and summaries\n- [x] 3.2 Perform `openspec init` smoke test in a temp directory (document output)\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-remove-diff-command/proposal.md",
    "content": "# Remove Diff Command\n\n## Problem\n\nThe `openspec diff` command adds unnecessary complexity to the OpenSpec CLI for several reasons:\n\n1. **Redundant functionality**: The `openspec show` command already provides comprehensive visualization of changes through structured JSON output and markdown rendering\n2. **Maintenance burden**: The diff command requires a separate dependency (jest-diff) and additional code complexity (~227 lines)\n3. **Limited value**: Developers can achieve better diff visualization using existing tools:\n   - Git diff for actual file changes\n   - The `show` command for structured change viewing\n   - Standard diff utilities for comparing spec files directly\n4. **Inconsistent with verb-noun pattern**: The command doesn't follow the preferred verb-first command structure that other commands are migrating to\n\n## Solution\n\nRemove the `openspec diff` command entirely and guide users to more appropriate alternatives:\n\n1. **For viewing change content**: Use `openspec show <change-name>` which provides:\n   - Structured JSON output with `--json` flag\n   - Markdown rendering for human-readable format\n   - Delta-only views with `--deltas-only` flag\n   - Full spec content visualization\n\n2. **For comparing files**: Use standard tools:\n   - `git diff` for version control comparisons\n   - System diff utilities for file-by-file comparisons\n   - IDE diff viewers for visual comparisons\n\n## Benefits\n\n- **Reduced complexity**: Removes ~227 lines of code and the jest-diff dependency\n- **Clearer user journey**: Directs users to the canonical `show` command for viewing changes\n- **Lower maintenance**: Fewer commands to maintain and test\n- **Better alignment**: Focuses on the core OpenSpec workflow without redundant features\n\n## Implementation\n\n### Files to Remove\n- `/src/core/diff.ts` - The entire diff command implementation\n- `/openspec/specs/cli-diff/spec.md` - The diff command specification\n\n### Files to Update\n- `/src/cli/index.ts` - Remove diff command registration (lines 8, 84-96)\n- `/package.json` - Remove jest-diff dependency\n- `/README.md` - Remove diff command documentation\n- `/openspec/README.md` - Remove diff command references\n- Various documentation files mentioning `openspec diff`\n\n### Migration Guide for Users\n\nUsers currently using `openspec diff` should transition to:\n\n```bash\n# Before\nopenspec diff add-feature\n\n# After - view the change proposal\nopenspec show add-feature\n\n# After - view only the deltas\nopenspec show add-feature --json --deltas-only\n\n# After - use git for file comparisons\ngit diff openspec/specs openspec/changes/add-feature/specs\n```\n\n## Risks\n\n- **User disruption**: Existing users may have workflows depending on the diff command\n  - Mitigation: Provide clear migration guide and deprecation period\n  \n- **Loss of visual diff**: The colored, unified diff format will no longer be available\n  - Mitigation: Users can use git diff or other tools for visual comparisons\n\n## Success Metrics\n\n- Successful removal with no broken dependencies\n- Documentation updated to reflect the change\n- Tests passing without the diff command\n- Reduced package size from removing jest-diff dependency"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-remove-diff-command/tasks.md",
    "content": "# Remove Diff Command - Tasks\n\n## 1. Remove Core Implementation\n- [x] Delete `/src/core/diff.ts`\n- [x] Remove DiffCommand import from `/src/cli/index.ts`\n- [x] Remove diff command registration from CLI\n\n## 2. Remove Specifications\n- [x] Delete `/openspec/specs/cli-diff/spec.md`\n- [x] Archive the spec for historical reference if needed\n\n## 3. Update Dependencies\n- [x] Remove jest-diff from package.json dependencies\n- [x] Run pnpm install to update lock file\n\n## 4. Update Documentation\n- [x] Update main README.md to remove diff command references\n- [x] Update openspec/README.md to remove diff command from command list\n- [x] Update CLAUDE.md template if it mentions diff command\n- [x] Update any example workflows that use diff command\n\n## 5. Update Related Files\n- [x] Search and update any remaining references to \"openspec diff\" in:\n  - Template files\n  - Test files (if any exist for diff command)\n  - Archive documentation\n  - Change proposals\n\n## 7. Testing\n- [x] Ensure all tests pass after removal\n- [x] Verify CLI help text no longer shows diff command\n- [x] Test that show command provides adequate replacement functionality\n\n## 8. Documentation of Alternative Workflows\n- [x] Document how to use `openspec show` for viewing changes\n- [x] Document how to use git diff for file comparisons\n- [x] Add migration guide to help text or documentation"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-sort-active-changes-by-progress/proposal.md",
    "content": "# Change: Sort Active Changes by Progress\n\n## Problem\n- The dashboard currently lists active changes in filesystem discovery order.\n- Users cannot quickly spot proposals that have not started or are nearly complete.\n- Inconsistent ordering between runs makes it harder to track progress when many changes exist.\n\n## Proposal\n1. Update the Active Changes list in the dashboard to sort by percentage of completion in ascending order so 0% items show first.\n2. When two changes share the same completion percentage, break ties deterministically by change identifier (alphabetical).\n\n## Benefits\n- Highlights work that has not started yet, enabling quicker prioritization.\n- Provides consistent ordering across machines and repeated runs.\n- Keeps the dashboard compact while communicating the most important status signal.\n\n## Risks & Mitigations\n- **Risk:** Sorting logic could regress rendering when progress data is missing.\n  - **Mitigation:** Treat missing progress as 0% so items still surface and document behavior in tests.\n- **Risk:** Additional sorting could impact performance for large change sets.\n  - **Mitigation:** The number of active changes is typically small; sorting a few entries is negligible.\n\n## Success Criteria\n- Dashboard output shows active changes ordered by ascending completion percentage with deterministic tie-breaking.\n- Unit coverage verifying the sort when percentages vary and when ties occur.\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-sort-active-changes-by-progress/specs/cli-view/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Active Changes Display\nThe dashboard SHALL show active changes with visual progress indicators.\n\n#### Scenario: Active changes ordered by completion percentage\n- **WHEN** multiple active changes are displayed with progress information\n- **THEN** list them sorted by completion percentage ascending so 0% items appear first\n- **AND** treat missing progress values as 0% for ordering\n- **AND** break ties by change identifier in ascending alphabetical order to keep output deterministic\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-sort-active-changes-by-progress/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Dashboard Sorting Logic\n- [x] 1.1 Update the Active Changes rendering to sort by completion percentage ascending.\n- [x] 1.2 Treat missing progress as 0% and break ties alphabetically by change identifier.\n\n## 2. Verification\n- [x] 2.1 Add tests that cover different completion percentages and tie cases to confirm deterministic ordering.\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-update-agent-file-name/proposal.md",
    "content": "# Update Agent Instruction File Name\n\n## Problem\nThe agent instructions live in `openspec/README.md`, which clashes with conventional project README usage and creates confusion for tooling and contributors.\n\n## Solution\nRename the agent instruction file to `openspec/AGENTS.md` and update OpenSpec tooling to use the new filename:\n- `openspec init` generates `AGENTS.md` instead of `README.md`\n- Templates and code reference `AGENTS.md`\n- Specifications and documentation are updated accordingly\n\n## Benefits\n- Clear separation from project documentation\n- Consistent naming with other agent instruction files\n- Simplifies tooling and project onboarding\n\n## Implementation\n- Rename instruction file and template\n- Update CLI commands (`init`, `update`) to read/write `AGENTS.md`\n- Adjust specs and documentation to reference the new path\n\n## Risks\n- Existing projects may still rely on `README.md`\n- Tooling may miss lingering references to the old filename\n\n## Success Metrics\n- `openspec init` creates `openspec/AGENTS.md`\n- `openspec update` refreshes `AGENTS.md`\n- All specs reference `openspec/AGENTS.md`\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-update-agent-file-name/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: Directory Creation\nThe command SHALL create the complete OpenSpec directory structure with all required directories and files.\n\n#### Scenario: Creating OpenSpec structure\n- **WHEN** `openspec init` is executed\n- **THEN** create the following directory structure:\n```\nopenspec/\n├── project.md\n├── AGENTS.md\n├── specs/\n└── changes/\n    └── archive/\n```\n\n### Requirement: File Generation\nThe command SHALL generate required template files with appropriate content for immediate use.\n\n#### Scenario: Generating template files\n- **WHEN** initializing OpenSpec\n- **THEN** generate `AGENTS.md` containing complete OpenSpec instructions for AI assistants\n- **AND** generate `project.md` with project context template\n\n### Requirement: AI Tool Configuration Details\n\nThe command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.\n\n#### Scenario: Creating new CLAUDE.md\n- **WHEN** CLAUDE.md does not exist\n- **THEN** create new file with OpenSpec content wrapped in markers including reference to `@openspec/AGENTS.md`\n\n### Requirement: Success Output\n\nThe command SHALL provide clear, actionable next steps upon successful initialization.\n\n#### Scenario: Displaying success message\n- **WHEN** initialization completes successfully\n- **THEN** include prompt: \"Please explain the OpenSpec workflow from openspec/AGENTS.md and how I should work with you on this project\"\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-update-agent-file-name/specs/cli-update/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: Update Behavior\nThe update command SHALL update OpenSpec instruction files to the latest templates in a team-friendly manner.\n\n#### Scenario: Running update command\n- **WHEN** a user runs `openspec update`\n- **THEN** replace `openspec/AGENTS.md` with the latest template\n\n### Requirement: File Handling\nThe update command SHALL handle file updates in a predictable and safe manner.\n\n#### Scenario: Updating files\n- **WHEN** updating files\n- **THEN** completely replace `openspec/AGENTS.md` with the latest template\n\n### Requirement: Core Files Always Updated\nThe update command SHALL always update the core OpenSpec files and display an ASCII-safe success message.\n\n#### Scenario: Successful update\n- **WHEN** the update completes successfully\n- **THEN** replace `openspec/AGENTS.md` with the latest template\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-update-agent-file-name/specs/openspec-conventions/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: Project Structure\nAn OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.\n\n#### Scenario: Initializing project structure\n- **WHEN** an OpenSpec project is initialized\n- **THEN** it SHALL have this structure:\n```\nopenspec/\n├── project.md              # Project-specific context\n├── AGENTS.md               # AI assistant instructions\n├── specs/                  # Current deployed capabilities\n│   └── [capability]/       # Single, focused capability\n│       ├── spec.md         # WHAT and WHY\n│       └── design.md       # HOW (optional, for established patterns)\n└── changes/                # Proposed changes\n    ├── [change-name]/      # Descriptive change identifier\n    │   ├── proposal.md     # Why, what, and impact\n    │   ├── tasks.md        # Implementation checklist\n    │   ├── design.md       # Technical decisions (optional)\n    │   └── specs/          # Complete future state\n    │       └── [capability]/\n    │           └── spec.md # Clean markdown (no diff syntax)\n    └── archive/            # Completed changes\n        └── YYYY-MM-DD-[name]/\n```\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-update-agent-file-name/tasks.md",
    "content": "# Update Agent Instruction File Name - Tasks\n\n## 1. Rename Instruction File\n- [x] Rename `openspec/README.md` to `openspec/AGENTS.md`\n- [x] Update root references to new path\n\n## 2. Update Templates\n- [x] Rename `src/core/templates/readme-template.ts` to `agents-template.ts`\n- [x] Update exported constant from `readmeTemplate` to `agentsTemplate`\n\n## 3. Adjust CLI Commands\n- [x] Modify `openspec init` to generate `AGENTS.md`\n- [x] Update `openspec update` to refresh `AGENTS.md`\n- [x] Ensure CLAUDE.md markers link to `@openspec/AGENTS.md`\n\n## 4. Update Specifications\n- [x] Modify `cli-init` spec to reference `AGENTS.md`\n- [x] Modify `cli-update` spec to reference `AGENTS.md`\n- [x] Modify `openspec-conventions` spec to include `AGENTS.md` in project structure\n\n## 5. Validation\n- [x] `pnpm test`\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-update-agent-instructions/design.md",
    "content": "# Design: Agent Instructions Update\n\n## Approach\n\n### Information Architecture\n- **Front-load critical information** - Three-stage workflow comes first\n- **Clear hierarchy** - Core Workflow → Quick Start → Commands → Details → Edge Cases\n- **50% length reduction** - Target ~285 lines from current ~575 lines\n- **Imperative mood** - \"Create proposal\" vs \"You should create a proposal\"\n- **Bullet points over paragraphs** - Scannable, concise information\n\n### Three-Stage Workflow Documentation\nThe workflow is now prominently featured as a core concept:\n1. **Creating** - Proposal generation phase\n2. **Implementing** - Code development phase with explicit steps:\n   - Read proposal.md for understanding\n   - Read design.md for technical context\n   - Read tasks.md for checklist\n   - Implement tasks sequentially\n   - Mark complete immediately after each task\n3. **Archiving** - Post-deployment finalization phase\n\nThis structure helps agents understand the lifecycle and their role at each stage. The implementation phase is particularly detailed to prevent common mistakes like skipping documentation or batching task completion.\n\n### CLI Documentation Updates\n- **Comprehensive command coverage** - All 9 primary commands documented\n- **`openspec list` prominence** - Essential for discovering changes and specs\n- **Interactive mode documentation** - How agents can use prompts effectively\n- **Complete flag documentation** - All options like --json, --type, --skip-specs\n- **Deprecation cleanup** - Remove noun-first patterns (openspec change show)\n\n### Agent-Specific Enhancements\nBased on industry best practices for coding agents (Claude Code, Cursor, etc.):\n\n**Implementation Workflow**\n- Explicit steps prevent skipping critical context\n- Reading proposal/design first ensures understanding before coding\n- Sequential task completion maintains focus\n- Immediate marking prevents losing track of progress\n- Addresses common failure mode: jumping straight to code\n\n**Spec Discovery Workflow**\n- Always check existing specs before creating new ones\n- Use `openspec list --specs` to discover current capabilities\n- Prefer modifying existing specs over creating duplicates\n- Prevents fragmentation and maintains coherent architecture\n\n**Decision Clarity**\n- Clear decision trees eliminating ambiguous conditions\n- Concrete examples for each decision branch\n- Simplified bug vs feature determination\n\n**Tool Usage Guidance**\n- Tool selection matrix (when to use Grep vs Glob vs Read)\n- Error recovery patterns for common failures\n- Verification workflows to confirm correctness\n\n**Context Management**\n- \"Before Any Task\" checklist for gathering context\n- What to read before starting any work\n- How to maintain state across interactions\n\n**Spec File Structure Documentation**\n- Complete examples with ADDED/MODIFIED/REMOVED sections\n- Critical scenario formatting (#### Scenario: headers)\n- Delta file location clarity (changes/{name}/specs/)\n- Addresses most common creation errors from retrospective\n\n**Troubleshooting and Debugging**\n- Common error messages with solutions\n- Delta detection debugging steps\n- Validation best practices\n- JSON output for inspection\n- Prevents hours of frustration from silent failures\n\n**Best Practices**\n- Be concise (one-line answers when appropriate)\n- Be specific (file.ts:42 line references)\n- Start simple (<100 lines, single-file defaults)\n- Justify complexity (require metrics/data)\n\n## Design Rationale\n\n### Why These Changes Matter\n\n**Cognitive Load Reduction**\n- Agents process instructions better with clear structure\n- Front-loading critical info reduces scanning time\n- Decision trees eliminate analysis paralysis\n\n**Industry Alignment**\n- Follows patterns proven effective in Claude Code, Cursor, GitHub Copilot\n- Addresses common failure modes (ambiguous decisions, missing context)\n- Optimizes for LLM strengths (pattern matching) vs weaknesses (calculations)\n\n**Addressing Critical Pain Points (from Retrospective)**\n- **Scenario formatting** - Biggest struggle, now explicitly documented with examples\n- **Complete spec structure** - Full examples prevent structural errors\n- **Delta detection issues** - Debugging commands help diagnose problems\n- **Silent parsing failures** - Troubleshooting section explains common issues\n\n**Practical Impact**\n- Faster agent comprehension of tasks\n- Fewer misinterpretations of requirements\n- More consistent implementation quality\n- Better error recovery when things go wrong\n- Prevents the most common errors identified in user experience\n\n## Trade-offs\n\n### What We're Removing\n- Lengthy explanations of concepts that can be inferred\n- Redundant examples that don't add clarity\n- Verbose edge case documentation (moved to reference section)\n- Deprecated command documentation\n\n### What We're Keeping\n- All critical workflow steps\n- Complete CLI command reference\n- Complexity management principles\n- Directory structure visualization\n- Quick reference summary\n\n## Implementation Notes\n\nThe CLAUDE.md template is intentionally more concise than README.md since:\n- It appears in every project root\n- Agents can reference the full README.md for details\n- It needs to load quickly in AI context windows\n- Focus is on immediate actionable guidance"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-update-agent-instructions/proposal.md",
    "content": "# Update OpenSpec Agent Instructions\n\n## Why\n\nThe current OpenSpec agent instructions need updates to follow best practices for AI assistant instructions (brevity, clarity, removing ambiguity), ensure CLI commands are current with the actual implementation, and properly document the three-stage workflow pattern that agents should follow.\n\n## What Changes\n\n### Core Structure Improvements\n- **Front-load the 3-stage workflow** as the primary mental model:\n  1. Creating a change proposal (proposal.md, spec deltas, design.md, tasks.md)\n  2. Implementing a change proposal:\n     - First read proposal.md to understand the change\n     - Read design.md if it exists for technical context\n     - Read tasks.md for the implementation checklist\n     - Complete tasks one by one\n     - Mark each task complete immediately after finishing\n  3. Archiving the change proposal (using archive command after deployment)\n- **Reduce instruction length by 50%** while maintaining all critical information\n- **Restructure with clear hierarchy**: Core Workflow → Quick Start → Commands → Details → Edge Cases\n\n### Decision Clarity Enhancements\n- **Add clear decision trees** for common scenarios (bug vs feature, proposal needed vs not)\n- **Remove ambiguous conditions** that confuse agent decision-making\n- **Add \"Before Any Task\" checklist** for context gathering\n- **Add \"Before Creating Specs\" rule** - Always check existing specs first to avoid duplicates\n\n### CLI Documentation Updates\n- **Complete command documentation** with all current functionality:\n  - `openspec init [path]` - Initialize OpenSpec in a project\n  - `openspec list` - List all active changes (default)\n  - `openspec list --specs` - List all specifications\n  - `openspec show [item]` - Display change or spec with auto-detection\n  - `openspec show` - Interactive mode for selection\n  - `openspec diff [change]` - Show spec differences for a change\n  - `openspec validate [item]` - Validate changes or specs\n  - `openspec archive [change]` - Archive completed change after deployment\n  - `openspec update [path]` - Update OpenSpec instruction files\n- **Document all flags and options**:\n  - `--json` output format for programmatic use\n  - `--type change|spec` for disambiguation\n  - `--skip-specs` for tooling-only archives\n  - `--strict` for strict validation mode\n  - `--no-interactive` to disable prompts\n- **Remove deprecated command references** (noun-first patterns like `openspec change show`)\n- **Add concrete examples** for each command variation\n- **Document debugging commands**:\n  - `openspec show [change] --json --deltas-only` for inspecting deltas\n  - `openspec validate [change] --strict` for comprehensive validation\n\n### Spec File Structure Documentation\n- **Complete spec file examples** showing proper structure:\n  ```markdown\n  ## ADDED Requirements\n  ### Requirement: Clear requirement statement\n  The system SHALL provide the functionality...\n  \n  #### Scenario: Descriptive scenario name\n  - **WHEN** condition occurs\n  - **THEN** expected outcome\n  - **AND** additional outcomes\n  ```\n- **Scenario formatting requirements** (critical - most common error):\n  - MUST use `#### Scenario:` headers (4 hashtags)\n  - NOT bullet lists or bold text\n  - Each requirement MUST have at least one scenario\n- **Delta file location** - Clear explanation:\n  - Spec files go in `changes/{name}/specs/` directory\n  - Deltas are automatically extracted from these files\n  - Use operation prefixes: ADDED, MODIFIED, REMOVED, RENAMED\n\n### Troubleshooting Section\n- **Common errors and solutions**:\n  - \"Change must have at least one delta\" → Check specs/ directory exists with .md files\n  - \"Requirement must have at least one scenario\" → Check scenario uses `#### Scenario:` format\n  - Silent scenario parsing failures → Verify exact header format\n- **Delta detection debugging**:\n  - Use `openspec show [change] --json --deltas-only` to inspect parsed deltas\n  - Check that spec files have operation prefixes (## ADDED Requirements)\n  - Verify specs/ subdirectory structure\n- **Validation best practices**:\n  - Always use `--strict` flag for comprehensive checks\n  - Use JSON output for debugging: `--json | jq '.deltas'`\n\n### Agent-Specific Improvements\n- **Implementation workflow** - Clear step-by-step process:\n  1. Read proposal.md to understand what's being built\n  2. Read design.md (if exists) for technical decisions\n  3. Read tasks.md for the implementation checklist\n  4. Implement tasks one by one in order\n  5. Mark each task complete immediately: `- [x] Task completed`\n  6. Never skip ahead or batch task completion\n- **Spec discovery workflow** - Always check existing specs before creating new ones:\n  - Use `openspec list --specs` to see all current specs\n  - Check if capability already exists before creating\n  - Prefer modifying existing specs over creating duplicates\n- **Tool selection matrix** - When to use Grep vs Glob vs Read\n- **Error recovery patterns** - How to handle common failures\n- **Context management guide** - What to read before starting tasks\n- **Verification workflows** - How to confirm changes are correct\n\n### Best Practices Section\n- **Be concise** - One-line answers when appropriate\n- **Be specific** - Use exact file paths and line numbers (file.ts:42)\n- **Start simple** - Default to <100 lines, single-file implementations\n- **Justify complexity** - Require data/metrics for any optimization\n\n## Impact\n\n- Affected specs: None (this is a tooling/documentation change)\n- Affected code: \n  - `src/core/templates/claude-template.ts` - Update CLAUDE.md template\n- Affected documentation:\n  - `openspec/README.md` - Main OpenSpec instructions\n  - CLAUDE.md files generated by `openspec init` command\n\nNote: This is a tooling/infrastructure change that doesn't require spec updates. When archiving, use `openspec archive update-agent-instructions --skip-specs`."
  },
  {
    "path": "openspec/changes/archive/2025-09-29-update-agent-instructions/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Restructure OpenSpec README.md\n- [x] 1.1 Front-load the three-stage workflow as primary content\n- [x] 1.2 Restructure with hierarchy: Core Workflow → Quick Start → Commands → Details → Edge Cases\n- [x] 1.3 Reduce total length by 50% (target: ~285 lines from current ~575)\n- [x] 1.4 Add \"Before Any Task\" context-gathering checklist\n- [x] 1.5 Add \"Before Creating Specs\" rule to check existing specs first\n\n## 2. Add Decision Clarity  \n- [x] 2.1 Create clear decision trees for \"Create Proposal?\" scenarios\n- [x] 2.2 Remove ambiguous conditions that confuse agents\n- [x] 2.3 Add concrete examples for each decision branch\n- [x] 2.4 Simplify bug vs feature determination logic\n- [x] 2.5 Add explicit Stage 2 implementation steps (read → implement → mark complete)\n\n## 3. Update CLI Documentation\n- [x] 3.1 Document `openspec list` and `openspec list --specs` commands\n- [x] 3.2 Document `openspec show` with all flags and interactive mode\n- [x] 3.3 Document `openspec diff [change]` for viewing spec differences\n- [x] 3.4 Document `openspec archive` with --skip-specs option\n- [x] 3.5 Document `openspec validate` with --strict and batch modes\n- [x] 3.6 Document `openspec init` and `openspec update` commands\n- [x] 3.7 Remove all deprecated noun-first command references\n- [x] 3.8 Add concrete usage examples for each command variation\n- [x] 3.9 Document all flags: --json, --type, --no-interactive, etc.\n- [x] 3.10 Document debugging commands: `show --json --deltas-only`\n\n## 4. Add Spec File Documentation\n- [x] 4.1 Add complete spec file structure example with ADDED/MODIFIED sections\n- [x] 4.2 Document scenario formatting requirements (#### Scenario: headers)\n- [x] 4.3 Explain delta file location (changes/{name}/specs/ directory)\n- [x] 4.4 Show how deltas are automatically extracted\n- [x] 4.5 Include warning about most common error (scenario formatting)\n\n## 5. Add Troubleshooting Section\n- [x] 5.1 Document common errors and their solutions\n- [x] 5.2 Add delta detection debugging steps\n- [x] 5.3 Include validation best practices (--strict flag)\n- [x] 5.4 Show how to use JSON output for debugging\n- [x] 5.5 Add examples of silent parsing failures\n\n## 6. Add Agent-Specific Sections\n- [x] 6.1 Add implementation workflow (read docs → implement tasks → mark complete)\n- [x] 6.2 Add spec discovery workflow (check existing before creating)\n- [x] 6.3 Create tool selection matrix (Grep vs Glob vs Read)\n- [x] 6.4 Add error recovery patterns section\n- [x] 6.5 Add context management guide\n- [x] 6.6 Add verification workflows section\n- [x] 6.7 Add best practices section (concise, specific, simple)\n\n## 7. Update CLAUDE.md Template\n- [x] 7.1 Update `src/core/templates/claude-template.ts` with streamlined content\n- [x] 7.2 Include three-stage workflow prominently\n- [x] 7.3 Add comprehensive CLI quick reference (list, show, diff, archive, etc.)\n- [x] 7.4 Add \"Before Any Task\" checklist\n- [x] 7.5 Add \"Before Creating Specs\" rule\n- [x] 7.6 Keep complexity management principles\n- [x] 7.7 Add critical scenario formatting note (#### Scenario: headers)\n- [x] 7.8 Include debugging command reference\n\n## 8. Testing and Validation\n- [x] 8.1 Test all documented CLI commands for accuracy\n- [x] 8.2 Run `openspec init` to verify CLAUDE.md generation\n- [x] 8.3 Validate instruction clarity with example scenarios\n- [x] 8.4 Ensure no critical information was lost in streamlining\n- [x] 8.5 Verify decision trees eliminate ambiguity\n- [x] 8.6 Test scenario formatting examples work correctly\n- [x] 8.7 Verify troubleshooting steps resolve common errors"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-update-markdown-parser-crlf/proposal.md",
    "content": "# Update Markdown Parser CRLF Handling\n\n## Problem\nWindows users report that `openspec validate` raises “Change must have a Why section” even when the section exists (see GitHub issue #77). The CLI currently splits markdown on `\\n` and compares headers without stripping `\\r`, so files saved with CRLF line endings keep a trailing carriage return in the header token. As a result the parser fails to detect `## Why`/`## What Changes`, triggering false validation errors and breaking the workflow on Windows-default editors.\n\n## Solution\n- Normalize markdown content inside the parser so CRLF and lone-CR inputs are treated as `\\n` before section detection, trimming any carriage returns from titles and content comparisons.\n- Reuse the normalized reader everywhere `MarkdownParser` is constructed to keep behavior consistent for validation, view, spec, and list flows.\n- Add regression coverage that reproduces the failure (unit test around `parseChange` and a CLI spawn/e2e test that writes a CRLF change then runs `openspec validate`).\n- Update the `cli-validate` spec to codify the expectation that required sections are recognized regardless of line-ending style.\n\n## Benefits\n- Restores correct validation behavior for Windows editors without requiring manual line-ending conversion.\n- Locks in the fix with targeted tests so future parser refactors keep cross-platform support.\n- Clarifies the spec so downstream work (e.g., cross-shell e2e plan) understands the non-negotiable behavior.\n\n## Risks\n- Low: parser normalization touches shared code paths that parse specs and changes; need to ensure no regressions in other command consumers (mitigated by existing parser tests plus the new CRLF fixtures).\n\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-update-markdown-parser-crlf/specs/cli-validate/spec.md",
    "content": "## ADDED Requirements\n### Requirement: Parser SHALL handle cross-platform line endings\nThe markdown parser SHALL correctly identify sections regardless of line ending format (LF, CRLF, CR).\n\n#### Scenario: Required sections parsed with CRLF line endings\n- **GIVEN** a change proposal markdown saved with CRLF line endings\n- **AND** the document contains `## Why` and `## What Changes`\n- **WHEN** running `openspec validate <change-id>`\n- **THEN** validation SHALL recognize the sections and NOT raise parsing errors\n"
  },
  {
    "path": "openspec/changes/archive/2025-09-29-update-markdown-parser-crlf/tasks.md",
    "content": "## 1. Guard the regression\n- [x] 1.1 Add a unit test that feeds a CRLF change document into `MarkdownParser.parseChange` and asserts `Why`/`What Changes` are detected.\n- [x] 1.2 Add a CLI spawn/e2e test that writes a CRLF change, runs `openspec validate`, and expects success.\n\n## 2. Normalize parsing\n- [x] 2.1 Normalize line endings when constructing `MarkdownParser` so headers and content comparisons ignore `\\r`.\n- [x] 2.2 Ensure all CLI entry points (validate, view, spec conversion) reuse the normalized parser path.\n\n## 3. Document and verify\n- [x] 3.1 Update the `cli-validate` spec with a scenario covering CRLF line endings.\n- [x] 3.2 Run the parser and CLI test suites (`pnpm test`, relevant spawn tests) to confirm the fix.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-codex-slash-command-support/proposal.md",
    "content": "## Why\n- Codex (the VS Code extension formerly known as Codeium Chat) exposes \"slash commands\" by reading Markdown prompt files from `~/.codex/prompts/`. Each file name becomes the `/command` users can run, with YAML frontmatter for metadata (`description`, `argument-hint`) and `$ARGUMENTS` to capture user input. The workflow screenshot shared by Kevin Kern (\"Codex problem analyzer\") shows the format OpenSpec should target so teams can invoke curated workflows straight from the chat palette.\n- Teams already rely on OpenSpec to manage the slash-command surface area for Claude, Cursor, OpenCode, Kilo Code, and Windsurf. Leaving Codex out forces them to manually copy/paste OpenSpec guardrails into `~/.codex/prompts/*.md`, which drifts quickly and undermines the \"single source of truth\" promise of the CLI.\n- Codex commands live outside the repository (under the user's home directory), so shipping an automated configurator that both scaffolds the prompts and keeps them refreshed via `openspec update` eliminates error-prone manual steps and keeps OpenSpec instructions synchronized across assistants.\n\n## What Changes\n- Add Codex to the `openspec init` tool picker with the same \"already configured\" detection we use for other editors, wiring an implementation that writes managed Markdown prompts directly to Codex's global directory (`~/.codex/prompts` or `$CODEX_HOME/prompts`) with OpenSpec marker blocks.\n- Produce three Codex prompt files—`openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`—whose content mirrors the shared slash-command templates while using YAML frontmatter (`description` and `argument-hint` fields) and `$ARGUMENTS` to capture all arguments as a single string (matching the GitHub Copilot pattern and official Codex specification).\n- Document Codex's global-only discovery and that OpenSpec writes prompts directly to `~/.codex/prompts` (or `$CODEX_HOME/prompts`).\n- Teach `openspec update` to refresh existing Codex prompts in-place (and only when they already exist) in the global directory, updating both frontmatter and body.\n- Document Codex support alongside other slash-command integrations and add regression coverage that exercises init/update behaviour against a temporary global prompts directory via `CODEX_HOME`.\n\n## Impact\n- Specs: `cli-init`, `cli-update`\n- Code: `src/core/config.ts`, `src/core/configurators/slash/*`, `src/core/templates/slash-command-templates.ts`, CLI tool summaries, docs\n- Tests: integration coverage for Codex prompt scaffolding and refresh logic\n- Docs: README and CHANGELOG entries announcing Codex slash-command support\n\n## Current Spec Reference\n- `specs/cli-init/spec.md`\n  - Requirements cover init UX, directory scaffolding, AI tool configuration, and the existing slash-command support for Claude Code, Cursor, and OpenCode.\n  - Our `## MODIFIED` delta in `changes/.../specs/cli-init/spec.md` copies the full \"Slash Command Configuration\" requirement (header, description, and all scenarios) before appending the new Codex scenario so archiving will retain every prior scenario.\n- `specs/cli-update/spec.md`\n  - Requirements define update preconditions, template refresh behavior, and slash-command refresh logic for Claude Code, Cursor, and OpenCode.\n  - The corresponding delta preserves the entire \"Slash Command Updates\" requirement while adding the Codex refresh scenario, ensuring the archive workflow replaces the block without losing the existing scenarios or the \"Missing slash command file\" guardrail.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-codex-slash-command-support/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: AI Tool Configuration\nThe command SHALL configure AI coding assistants with OpenSpec instructions using a marker system.\n#### Scenario: Prompting for AI tool selection\n- **WHEN** run interactively\n- **THEN** prompt the user with \"Which AI tools do you use?\" using a multi-select menu\n- **AND** list every available tool with a checkbox:\n  - Claude Code (creates or refreshes CLAUDE.md and slash commands)\n  - Cursor (creates or refreshes `.cursor/commands/*` slash commands)\n  - OpenCode (creates or refreshes `.opencode/command/openspec-*.md` slash commands)\n  - Windsurf (creates or refreshes `.windsurf/workflows/openspec-*.md` workflows)\n  - Kilo Code (creates or refreshes `.kilocode/workflows/openspec-*.md` workflows)\n  - Codex (creates or refreshes global prompts at `~/.codex/prompts/openspec-*.md`)\n  - AGENTS.md standard (creates or refreshes AGENTS.md with OpenSpec markers)\n- **AND** show \"(already configured)\" beside tools whose managed files exist so users understand selections will refresh content\n- **AND** treat disabled tools as \"coming soon\" and keep them unselectable\n- **AND** allow confirming with Enter after selecting one or more tools\n\n### Requirement: Slash Command Configuration\nThe init command SHALL generate slash command files for supported editors using shared templates.\n\n#### Scenario: Generating slash commands for Claude Code\n- **WHEN** the user selects Claude Code during initialization\n- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cursor\n- **WHEN** the user selects Cursor during initialization\n- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for OpenCode\n- **WHEN** the user selects OpenCode during initialization\n- **THEN** create `.opencode/command/openspec-proposal.md`, `.opencode/command/openspec-apply.md`, and `.opencode/command/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Windsurf\n- **WHEN** the user selects Windsurf during initialization\n- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Kilo Code\n- **WHEN** the user selects Kilo Code during initialization\n- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Codex\n- **WHEN** the user selects Codex during initialization\n- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)\n- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)\n- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-codex-slash-command-support/specs/cli-update/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Slash Command Updates\nThe update command SHALL refresh existing slash command files for configured tools without creating new ones.\n\n#### Scenario: Updating slash commands for Claude Code\n- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cursor\n- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for OpenCode\n- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Windsurf\n- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Kilo Code\n- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Codex\n- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **WHEN** a user runs `openspec update`\n- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance)\n- **AND** preserve any unmanaged content outside the OpenSpec marker block\n- **AND** skip creation when a Codex prompt file is missing\n\n#### Scenario: Missing slash command file\n- **WHEN** a tool lacks a slash command file\n- **THEN** do not create a new file during update\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-codex-slash-command-support/tasks.md",
    "content": "## 1. CLI integration\n- [x] 1.1 Add Codex to the init tool picker with display text that clarifies prompts live in the global `.codex/prompts/` directory and implement \"already configured\" detection by checking for managed Codex prompt files.\n- [x] 1.2 Implement a `CodexSlashCommandConfigurator` that writes `.codex/prompts/openspec-{proposal,apply,archive}.md`, ensuring the prompt directory exists and wrapping content in OpenSpec markers.\n// (No helper command required)\n- [x] 1.3 Register the configurator with the slash-command registry and include Codex in init/update wiring so both commands invoke the new configurator when appropriate.\n\n## 2. Prompt templates\n- [x] 2.1 Extend the shared slash-command templates (or add a Codex-specific wrapper) to inject numbered placeholders (`$1`, `$2`, …) where Codex expects user-supplied arguments.\n- [x] 2.2 Verify generated Markdown stays within Codex's formatting expectations (no front matter, heading-first layout) and matches the problem-analyzer style shown in the reference screenshot.\n\n## 3. Update support & tests\n- [x] 3.1 Update the `openspec update` flow to refresh existing Codex prompts without creating new ones when files are missing.\n- [x] 3.2 Add integration coverage that exercises init/update against a temporary global Codex prompts directory by setting `CODEX_HOME`, asserting marker preservation and idempotent updates.\n- [x] 3.3 Document Codex's global-only discovery and automatic installation in README and CHANGELOG.\n- [x] 3.3 Confirm error handling surfaces clear paths when the CLI cannot write to the Codex prompt directory (permissions, missing home directory, etc.).\n\n## 4. Documentation\n- [x] 4.1 Document Codex slash-command support in the README and changelog alongside other assistant integrations.\n- [x] 4.2 Add a release note snippet that points Codex users to the generated `/openspec-proposal`, `/openspec-apply`, and `/openspec-archive` commands.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-github-copilot-prompts/proposal.md",
    "content": "## Why\n- GitHub Copilot supports custom slash commands through markdown files in `.github/prompts/<name>.prompt.md`. Each file includes YAML frontmatter with a `description` label and uses `$ARGUMENTS` to capture user input. This format allows teams to expose curated workflows directly in Copilot's chat interface.\n- Teams already rely on OpenSpec to manage slash-command configurations for Claude Code, Cursor, OpenCode, Codex, Kilo Code, and Windsurf. Excluding GitHub Copilot forces developers to manually maintain OpenSpec prompts in `.github/prompts/`, which leads to drift and undermines OpenSpec's \"single source of truth\" promise.\n- GitHub Copilot discovers prompts from the repository's `.github/prompts/` directory, making it straightforward to version control and share across the team. Adding automated generation and refresh through `openspec init` and `openspec update` eliminates manual synchronization and keeps OpenSpec instructions consistent across all AI assistants.\n\n## What Changes\n- Add GitHub Copilot to the `openspec init` tool picker with \"already configured\" detection similar to other editors, wiring an implementation that writes managed Markdown prompt files to `.github/prompts/` with OpenSpec marker blocks.\n- Generate three GitHub Copilot prompt files—`openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md`—whose content mirrors shared slash-command templates while conforming to Copilot's frontmatter and `$ARGUMENTS` placeholder convention.\n- Document GitHub Copilot's repository-based discovery and that OpenSpec writes prompts to `.github/prompts/` with managed blocks.\n- Teach `openspec update` to refresh existing GitHub Copilot prompts in-place (only when they already exist) in the repository's `.github/prompts/` directory.\n- Document GitHub Copilot support alongside other slash-command integrations and add test coverage that exercises init/update behavior for `.github/prompts/` files.\n\n## Impact\n- Specs: `cli-init`, `cli-update`\n- Code: `src/core/configurators/slash/github-copilot.ts` (new), `src/core/configurators/slash/registry.ts`, `src/core/templates/slash-command-templates.ts`, CLI tool summaries, docs\n- Tests: integration coverage for GitHub Copilot prompt scaffolding and refresh logic\n- Docs: README and CHANGELOG entries announcing GitHub Copilot slash-command support\n\n## Current Spec Reference\n- `specs/cli-init/spec.md`\n  - Requirements cover init UX, directory scaffolding, AI tool configuration, and existing slash-command support for Claude Code, Cursor, OpenCode, Codex, Kilo Code, and Windsurf.\n  - Our `## MODIFIED` delta in `changes/.../specs/cli-init/spec.md` will copy the full \"Slash Command Configuration\" requirement (header, description, and all scenarios) before appending the new GitHub Copilot scenario so archiving retains every prior scenario.\n- `specs/cli-update/spec.md`\n  - Requirements define update preconditions, template refresh behavior, and slash-command refresh logic for existing tools.\n  - The corresponding delta preserves the entire \"Slash Command Updates\" requirement while adding the GitHub Copilot refresh scenario, ensuring the archive workflow replaces the block without losing existing scenarios or the \"Missing slash command file\" guardrail.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-github-copilot-prompts/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: Slash Command Configuration\nThe init command SHALL generate slash command files for supported editors using shared templates.\n\n#### Scenario: Generating slash commands for Claude Code\n- **WHEN** the user selects Claude Code during initialization\n- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cursor\n- **WHEN** the user selects Cursor during initialization\n- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for OpenCode\n- **WHEN** the user selects OpenCode during initialization\n- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Windsurf\n- **WHEN** the user selects Windsurf during initialization\n- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Kilo Code\n- **WHEN** the user selects Kilo Code during initialization\n- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Codex\n- **WHEN** the user selects Codex during initialization\n- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)\n- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)\n- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes\n\n#### Scenario: Generating slash commands for GitHub Copilot\n- **WHEN** the user selects GitHub Copilot during initialization\n- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`\n- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage\n- **AND** include `$ARGUMENTS` placeholder to capture user input\n- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-github-copilot-prompts/specs/cli-update/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: Slash Command Updates\nThe update command SHALL refresh existing slash command files for configured tools without creating new ones.\n\n#### Scenario: Updating slash commands for Claude Code\n- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cursor\n- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for OpenCode\n- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Windsurf\n- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Kilo Code\n- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Codex\n- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **WHEN** a user runs `openspec update`\n- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance)\n- **AND** preserve any unmanaged content outside the OpenSpec marker block\n- **AND** skip creation when a Codex prompt file is missing\n\n#### Scenario: Updating slash commands for GitHub Copilot\n- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md`\n- **THEN** refresh each file using shared templates while preserving the YAML frontmatter\n- **AND** update only the OpenSpec-managed block between markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Missing slash command file\n- **WHEN** a tool lacks a slash command file\n- **THEN** do not create a new file during update\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-github-copilot-prompts/tasks.md",
    "content": "## Implementation Tasks\n\n- [x] Create `src/core/configurators/slash/github-copilot.ts` implementing `SlashCommandConfigurator` base class\n  - Implement `getRelativePath()` to return `.github/prompts/openspec-{proposal,apply,archive}.prompt.md`\n  - Implement `getFrontmatter()` to generate YAML frontmatter with `description` field and include `$ARGUMENTS` placeholder\n  - Implement `generateAll()` to create `.github/prompts/` directory and write three prompt files with frontmatter, markers, and shared template bodies\n  - Implement `updateExisting()` to refresh only the managed block between markers while preserving frontmatter\n  - Set `toolId = \"github-copilot\"` and `isAvailable = true`\n\n- [x] Register GitHub Copilot configurator in `src/core/configurators/slash/registry.ts`\n  - Import `GitHubCopilotSlashCommandConfigurator`\n  - Add to `SLASH_COMMAND_CONFIGURATORS` array\n  - Update tool picker display name to \"GitHub Copilot\"\n\n- [x] Update `src/core/init.ts` to include GitHub Copilot in the AI tool selection prompt\n  - Add GitHub Copilot to the available tools list with detection for existing `.github/prompts/openspec-*.prompt.md` files\n  - Display \"(already configured)\" when prompt files exist\n\n- [x] Update `src/core/update.ts` to refresh GitHub Copilot prompts when they exist\n  - Call `updateExisting()` for GitHub Copilot configurator when `.github/prompts/` contains OpenSpec prompt files\n\n- [x] Add integration tests for GitHub Copilot slash command generation\n  - Test `generateAll()` creates three prompt files with correct structure (frontmatter + markers + body)\n  - Test `updateExisting()` preserves frontmatter and only updates managed blocks\n  - Test that missing prompt files are not created during update\n\n- [x] Update documentation\n  - Add GitHub Copilot to README slash-command support table\n  - Document `.github/prompts/` as the discovery location\n  - Add CHANGELOG entry for GitHub Copilot support\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-kilocode-workflows/proposal.md",
    "content": "## Why\n- Kilo Code executes \\\"slash commands\\\" by loading markdown workflows from `.kilocode/workflows/` (or the global `~/.kilocode/workflows/`) and running them when a user types `/workflow-name.md`, making project-local workflow files the analogue to the slash-command files we already ship for other tools.\\\\\n  ([Workflows | Kilo Code Docs](https://kilocode.ai/docs/features/slash-commands/workflows))\n- Those workflows are plain markdown with step-by-step instructions that can call built-in tools and MCP integrations, so reusing OpenSpec's shared proposal/apply/archive bodies keeps behaviour aligned across assistants without inventing new content.\n- OpenSpec already detects configured tools and refreshes marker-wrapped files during `init`/`update`; extending the same mechanism to `.kilocode/workflows/openspec-*.md` ensures Kilo Code stays in sync with one source of truth.\n\n## What Changes\n- Add Kilo Code to the `openspec init` tool picker with \\\"already configured\\\" detection, including wiring for extend mode so teams can refresh Kilo Code assets.\n- Implement a `KiloCodeSlashCommandConfigurator` that creates `.kilocode/workflows/openspec-{proposal,apply,archive}.md`, ensuring the workflow directory exists and wrapping shared content in OpenSpec markers (no front matter required).\n- Teach `openspec update` to refresh existing Kilo Code workflows (and only those that already exist) using the shared slash-command templates.\n- Update documentation, release notes, and integration tests so the new workflow support is covered alongside Claude, Cursor, OpenCode, and Windsurf.\n\n## Impact\n- Specs: `cli-init`, `cli-update`\n- Code: `src/core/config.ts`, `src/core/configurators/(registry|slash/*)`, `src/core/templates/slash-command-templates.ts`, CLI wiring for tool summaries\n- Tests: init/update workflow coverage, regression for marker preservation in `.kilocode/workflows/`\n- Docs: README / CHANGELOG updates advertising Kilo Code workflow support\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-kilocode-workflows/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: AI Tool Configuration\nThe command SHALL configure AI coding assistants with OpenSpec instructions using a marker system.\n#### Scenario: Prompting for AI tool selection\n- **WHEN** run interactively\n- **THEN** prompt the user with \"Which AI tools do you use?\" using a multi-select menu\n- **AND** list every available tool with a checkbox:\n  - Claude Code (creates or refreshes CLAUDE.md and slash commands)\n  - Cursor (creates or refreshes `.cursor/commands/*` slash commands)\n  - OpenCode (creates or refreshes `.opencode/command/openspec-*.md` slash commands)\n  - Windsurf (creates or refreshes `.windsurf/workflows/openspec-*.md` workflows)\n  - Kilo Code (creates or refreshes `.kilocode/workflows/openspec-*.md` workflows)\n  - AGENTS.md standard (creates or refreshes AGENTS.md with OpenSpec markers)\n- **AND** show \"(already configured)\" beside tools whose managed files exist so users understand selections will refresh content\n- **AND** treat disabled tools as \"coming soon\" and keep them unselectable\n- **AND** allow confirming with Enter after selecting one or more tools\n\n### Requirement: Slash Command Configuration\nThe init command SHALL generate slash command files for supported editors using shared templates.\n\n#### Scenario: Generating slash commands for Claude Code\n- **WHEN** the user selects Claude Code during initialization\n- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cursor\n- **WHEN** the user selects Cursor during initialization\n- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for OpenCode\n- **WHEN** the user selects OpenCode during initialization\n- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Kilo Code\n- **WHEN** the user selects Kilo Code during initialization\n- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-kilocode-workflows/specs/cli-update/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Slash Command Updates\nThe update command SHALL refresh existing slash command files for configured tools without creating new ones.\n\n#### Scenario: Updating slash commands for Claude Code\n- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cursor\n- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for OpenCode\n- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Kilo Code\n- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Missing slash command file\n- **WHEN** a tool lacks a slash command file\n- **THEN** do not create a new file during update\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-kilocode-workflows/tasks.md",
    "content": "## 1. CLI wiring\n- [x] 1.1 Add Kilo Code to the selectable AI tools in `openspec init`, including \"already configured\" detection and success summaries.\n- [x] 1.2 Register a `KiloCodeSlashCommandConfigurator` alongside other slash-command tools.\n\n## 2. Workflow generation\n- [x] 2.1 Implement the configurator so it creates `.kilocode/workflows/` (if needed) and writes `openspec-{proposal,apply,archive}.md` with OpenSpec markers.\n- [x] 2.2 Reuse the shared slash-command bodies without front matter; verify resulting files stay Markdown-only with no extra metadata.\n\n## 3. Update support\n- [x] 3.1 Ensure `openspec update` refreshes existing Kilo Code workflows while skipping ones that are absent.\n- [x] 3.2 Add regression coverage confirming marker content is replaced (not duplicated) during updates.\n\n## 4. Documentation\n- [x] 4.1 Update README / docs to note Kilo Code workflow support and path (`.kilocode/workflows/`).\n- [x] 4.2 Mention the integration in CHANGELOG or release notes if applicable.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-non-interactive-init-options/proposal.md",
    "content": "## Why\nThe current `openspec init` command requires interactive prompts, preventing automation in CI/CD pipelines and scripted setups. Adding non-interactive options will enable programmatic initialization for automated workflows while maintaining the existing interactive experience as the default.\n\n## What Changes\n- Replace the multiple flag design with a single `--tools` option that accepts `all`, `none`, or a comma-separated list of tool IDs\n- Update InitCommand to bypass interactive prompts when `--tools` is supplied and apply single-flag validation rules\n- Document the non-interactive behavior via the CLI init spec delta (scenarios for `all`, `none`, list parsing, and invalid entries)\n- Generate CLI help text dynamically from `AI_TOOLS` so supported tools stay in sync\n\n## Impact\n- Affected specs: `specs/cli-init/spec.md`\n- Affected code: `src/cli/index.ts`, `src/core/init.ts`\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-non-interactive-init-options/specs/cli-init/spec.md",
    "content": "# Delta for CLI Init Specification\n\n## ADDED Requirements\n### Requirement: Non-Interactive Mode\nThe command SHALL support non-interactive operation through command-line options for automation and CI/CD use cases.\n\n#### Scenario: Select all tools non-interactively\n- **WHEN** run with `--tools all`\n- **THEN** automatically select every available AI tool without prompting\n- **AND** proceed with initialization using the selected tools\n\n#### Scenario: Select specific tools non-interactively\n- **WHEN** run with `--tools claude,cursor`\n- **THEN** parse the comma-separated tool IDs and validate against available tools\n- **AND** proceed with initialization using only the specified valid tools\n\n#### Scenario: Skip tool configuration non-interactively\n- **WHEN** run with `--tools none`\n- **THEN** skip AI tool configuration entirely\n- **AND** only create the OpenSpec directory structure and template files\n\n#### Scenario: Invalid tool specification\n- **WHEN** run with `--tools` containing any IDs not present in the AI tool registry\n- **THEN** exit with code 1 and display available values (`all`, `none`, or the supported tool IDs)\n\n#### Scenario: Help text lists available tool IDs\n- **WHEN** displaying CLI help for `openspec init`\n- **THEN** show the `--tools` option description with the valid values derived from the AI tool registry\n\n## MODIFIED Requirements\n### Requirement: Interactive Mode\nThe command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.\n\n#### Scenario: Displaying interactive menu\n- **WHEN** run in fresh or extend mode without non-interactive options\n- **THEN** present a looping select menu that lets users toggle tools with Enter and finish via a \"Done\" option\n- **AND** label already configured tools with \"(already configured)\" while keeping disabled options marked \"coming soon\"\n- **AND** change the prompt copy in extend mode to \"Which AI tools would you like to add or refresh?\"\n- **AND** display inline instructions clarifying that Enter toggles a tool and selecting \"Done\" confirms the list\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-non-interactive-init-options/tasks.md",
    "content": "## 1. CLI Option Registration\n- [x] 1.1 Replace the multiple flag design with a single `--tools <value>` option supporting `all|none|a,b,c` and keep strict argument validation.\n- [x] 1.2 Populate the `--tools` help text dynamically from the `AI_TOOLS` registry.\n\n## 2. InitCommand Modifications\n- [x] 2.1 Accept the single tools option in the InitCommand constructor and plumb it through existing flows.\n- [x] 2.2 Update tool selection logic to shortcut prompts for `all`, `none`, and explicit lists.\n- [x] 2.3 Fail fast with exit code 1 and a helpful message when the parsed list contains unsupported tool IDs.\n\n## 3. Specification Updates\n- [x] 3.1 Capture the non-interactive scenarios (`all`, `none`, list, invalid) in the change delta without modifying `specs/cli-init/spec.md` directly.\n- [x] 3.2 Document that CLI help reflects the available tool IDs managed by `AI_TOOLS`.\n\n## 4. Testing\n- [x] 4.1 Add unit coverage for parsing `--tools` values, including invalid entries.\n- [x] 4.2 Add integration coverage ensuring non-interactive runs generate the expected files and exit codes.\n- [x] 4.3 Verify the interactive flow remains unchanged when `--tools` is omitted.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-windsurf-workflows/proposal.md",
    "content": "## Why\n- Windsurf exposes \"Workflows\" as the vehicle for slash-like automation: saved Markdown files under `.windsurf/workflows/` that Cascade discovers across the workspace (including subdirectories and up to the git root), then executes when a user types `/workflow-name`. These files can be team-authored, must stay under 12k characters, and can call other workflows, making them the natural place to publish OpenSpec guidance for Windsurf users.\\\n  ([Windsurf Workflows documentation](https://docs.windsurf.com/windsurf/cascade/workflows))\n- The Wave 12 changelog reiterates that workflows are invoked via slash commands and that Windsurf stores them in `.windsurf/workflows`, so the OpenSpec CLI just needs to generate Markdown there to participate in Windsurf's command palette.\\\n  (\"Custom Workflows\" section, [Windsurf changelog](https://windsurf.com/changelog))\n- OpenSpec already ships shared command bodies for proposal/apply/archive and uses markers so commands stay up to date. Extending the same templates to Windsurf keeps behaviour consistent with Claude, Cursor, and OpenCode without inventing new content flows.\n\n## What Changes\n- Add Windsurf to the CLI tool picker (`openspec init`) and the slash-command registry so selecting it scaffolds `.windsurf/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with marker-managed bodies.\n- Shape each Windsurf workflow with a short heading/description plus the existing OpenSpec guardrails/steps wrapped in markers, ensuring the total payload remains well below the 12,000 character limit.\n- Ensure `openspec update` refreshes existing Windsurf workflows (and only those that already exist) in-place, mirroring current behaviour for other editors.\n- Extend unit tests for init/update to cover Windsurf generation and updates, and update the README/tooling docs to advertise Windsurf support.\n\n## Impact\n- Specs: `cli-init`, `cli-update`\n- Code: `src/core/configurators/slash/*`, `src/core/templates/slash-command-templates.ts`, CLI prompts, README\n- Tests: init/update integration coverage for Windsurf workflows\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-windsurf-workflows/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: AI Tool Configuration\nThe command SHALL configure AI coding assistants with OpenSpec instructions using a marker system.\n#### Scenario: Prompting for AI tool selection\n- **WHEN** run interactively\n- **THEN** prompt the user with \"Which AI tools do you use?\" using a multi-select menu\n- **AND** list every available tool with a checkbox:\n  - Claude Code (creates or refreshes CLAUDE.md and slash commands)\n  - Cursor (creates or refreshes `.cursor/commands/*` slash commands)\n  - OpenCode (creates or refreshes `.opencode/command/openspec-*.md` slash commands)\n  - Windsurf (creates or refreshes `.windsurf/workflows/openspec-*.md` workflows)\n  - AGENTS.md standard (creates or refreshes AGENTS.md with OpenSpec markers)\n- **AND** show \"(already configured)\" beside tools whose managed files exist so users understand selections will refresh content\n- **AND** treat disabled tools as \"coming soon\" and keep them unselectable\n- **AND** allow confirming with Enter after selecting one or more tools\n\n### Requirement: Slash Command Configuration\nThe init command SHALL generate slash command files for supported editors using shared templates.\n\n#### Scenario: Generating slash commands for Claude Code\n- **WHEN** the user selects Claude Code during initialization\n- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cursor\n- **WHEN** the user selects Cursor during initialization\n- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for OpenCode\n- **WHEN** the user selects OpenCode during initialization\n- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Windsurf\n- **WHEN** the user selects Windsurf during initialization\n- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-windsurf-workflows/specs/cli-update/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Slash Command Updates\nThe update command SHALL refresh existing slash command files for configured tools without creating new ones.\n\n#### Scenario: Updating slash commands for Claude Code\n- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cursor\n- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for OpenCode\n- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Windsurf\n- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Missing slash command file\n- **WHEN** a tool lacks a slash command file\n- **THEN** do not create a new file during update\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-add-windsurf-workflows/tasks.md",
    "content": "## 1. CLI wiring\n- [x] 1.1 Add Windsurf to the selectable AI tools in `openspec init`, including \"already configured\" detection.\n- [x] 1.2 Register a `WindsurfSlashCommandConfigurator` that writes workflows to `.windsurf/workflows/` and ensures the directory exists.\n- [x] 1.3 Ensure `openspec update` pulls the Windsurf configurator when winds is selected and skips creation when files are absent.\n\n## 2. Workflow templates\n- [x] 2.1 Reuse the shared proposal/apply/archive bodies, adding Windsurf-specific headings/description before the OpenSpec markers.\n- [x] 2.2 Confirm generated Markdown (per file) stays comfortably under the 12k character ceiling noted in the Windsurf docs.\n\n## 3. Tests & safeguards\n- [x] 3.1 Extend init tests to assert creation of `.windsurf/workflows/openspec-*.md` when Windsurf is chosen.\n- [x] 3.2 Extend update tests to assert existing Windsurf workflows are refreshed and non-existent files are ignored.\n- [x] 3.3 Add regression coverage for marker preservation inside Windsurf workflow files.\n\n## 4. Documentation\n- [x] 4.1 Update README (and any user-facing docs) to list Windsurf under native slash/workflow integrations.\n- [x] 4.2 Call out Windsurf workflow support in release notes or CHANGELOG if applicable.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-enhance-validation-error-messages/proposal.md",
    "content": "## Why\nValidation errors like \"no deltas found\" or \"missing requirement text\" do not tell agents how to recover, leading to repeated failures. Making error output specific about headers, required text, and next actions will help assistants fix issues in a single pass.\n\n## What Changes\n- Extend `openspec validate` error reporting so each failure names the exact header, file, and expected structure, including concrete examples of compliant Markdown.\n- Tailor messages for the most common mistakes (missing delta sections, absent descriptive requirement text, missing scenarios) with actionable fixes and suggested debug commands.\n- Update docs/help output so the improved messaging is discoverable (e.g., `--help`, troubleshooting section).\n- Add regression coverage to lock in the richer messaging for the top validation paths.\n\n## Impact\n- Affected specs: `specs/cli-validate`\n- Affected code: `src/commands/validate.ts`, `src/core/validation`, `docs/`\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-enhance-validation-error-messages/specs/cli-validate/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Validation SHALL provide actionable remediation steps\nValidation output SHALL include specific guidance to fix each error, including expected structure, example headers, and suggested commands to verify fixes.\n\n#### Scenario: No deltas found in change\n- **WHEN** validating a change with zero parsed deltas\n- **THEN** show error \"No deltas found\" with guidance:\n  - Explain that change specs must include `## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, or `## RENAMED Requirements`\n  - Remind authors that files must live under `openspec/changes/{id}/specs/<capability>/spec.md`\n  - Include an explicit note: \"Spec delta files cannot start with titles before the operation headers\"\n  - Suggest running `openspec change show {id} --json --deltas-only` for debugging\n\n#### Scenario: Missing required sections\n- **WHEN** a required section is missing\n- **THEN** include expected header names and a minimal skeleton:\n  - For Spec: `## Purpose`, `## Requirements`\n  - For Change: `## Why`, `## What Changes`\n  - Provide an example snippet of the missing section with placeholder prose ready to copy\n  - Mention the quick-reference section in `openspec/AGENTS.md` as the authoritative template\n\n#### Scenario: Missing requirement descriptive text\n- **WHEN** a requirement header lacks descriptive text before scenarios\n- **THEN** emit an error explaining that `### Requirement:` lines must be followed by narrative text before any `#### Scenario:` headers\n  - Show compliant example: \"### Requirement: Foo\" followed by \"The system SHALL ...\"\n  - Suggest adding 1-2 sentences describing the normative behavior prior to listing scenarios\n  - Reference the pre-validation checklist in `openspec/AGENTS.md`\n\n### Requirement: Validator SHALL detect likely misformatted scenarios and warn with a fix\nThe validator SHALL recognize bulleted lines that look like scenarios (e.g., lines beginning with WHEN/THEN/AND) and emit a targeted warning with a conversion example to `#### Scenario:`.\n\n#### Scenario: Bulleted WHEN/THEN under a Requirement\n- **WHEN** bullets that start with WHEN/THEN/AND are found under a requirement without any `#### Scenario:` headers\n- **THEN** emit warning: \"Scenarios must use '#### Scenario:' headers\", and show a conversion template:\n```\n#### Scenario: Short name\n- **WHEN** ...\n- **THEN** ...\n- **AND** ...\n```\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-enhance-validation-error-messages/tasks.md",
    "content": "## 1. Messaging enhancements\n- [x] 1.1 Inventory current validation failures and map each to the desired message improvements.\n- [x] 1.2 Implement structured error builders that include file paths, normalized header names, and example fixes.\n- [x] 1.3 Ensure `openspec validate --help` and troubleshooting docs mention the richer messages and debug tips.\n\n## 2. Tests\n- [x] 2.1 Add unit tests for representative errors (no deltas, missing requirement body, missing scenarios) asserting the new wording.\n- [x] 2.2 Add integration coverage verifying the Next steps footer reflects contextual guidance.\n\n## 3. Documentation\n- [x] 3.1 Update troubleshooting sections and CLI docs with sample output from the enhanced errors.\n- [x] 3.2 Note the change in CHANGELOG or release notes if applicable.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-improve-agent-instruction-usability/proposal.md",
    "content": "## Why\nAgents fumble proposal formatting because the essential Markdown templates and formatting rules are buried mid-document. Reorganizing `openspec/AGENTS.md` with a prominent quick-reference and embedded examples will help assistants follow the process without guesswork.\n\n## What Changes\n- Restructure `openspec/AGENTS.md` so file formats and scaffold templates appear in a top-level quick-reference section before workflow prose.\n- Embed copy/paste templates for `proposal.md`, `tasks.md`, `design.md`, and spec deltas alongside inline examples within the workflow steps.\n- Add a pre-validation checklist that highlights the most common formatting pitfalls before running `openspec validate`.\n- Split content into beginner vs. advanced sections to progressively disclose complexity while keeping advanced guidance accessible.\n\n## Impact\n- Affected specs: `specs/docs-agent-instructions`\n- Affected code: `openspec/AGENTS.md`, `docs/`\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-improve-agent-instruction-usability/specs/docs-agent-instructions/spec.md",
    "content": "## ADDED Requirements\n### Requirement: Quick Reference Placement\nThe AI instructions SHALL begin with a quick-reference section that surfaces required file structures, templates, and formatting rules before any narrative guidance.\n\n#### Scenario: Loading templates at the top\n- **WHEN** `openspec/AGENTS.md` is regenerated or updated\n- **THEN** the first substantive section after the title SHALL provide copy-ready headings for `proposal.md`, `tasks.md`, spec deltas, and scenario formatting\n- **AND** link each template to the corresponding workflow step for deeper reading\n\n### Requirement: Embedded Templates and Examples\n`openspec/AGENTS.md` SHALL include complete copy/paste templates and inline examples exactly where agents make corresponding edits.\n\n#### Scenario: Providing file templates\n- **WHEN** authors reach the workflow guidance for drafting proposals and deltas\n- **THEN** provide fenced Markdown templates that match the required structure (`## Why`, `## ADDED Requirements`, `#### Scenario:` etc.)\n- **AND** accompany each template with a brief example showing correct header usage and scenario bullets\n\n### Requirement: Pre-validation Checklist\n`openspec/AGENTS.md` SHALL offer a concise pre-validation checklist that highlights common formatting mistakes before running `openspec validate`.\n\n#### Scenario: Highlighting common validation failures\n- **WHEN** a reader reaches the validation guidance\n- **THEN** present a checklist reminding them to verify requirement headers, scenario formatting, and delta sections\n- **AND** include reminders about at least `#### Scenario:` usage and descriptive requirement text before scenarios\n\n### Requirement: Progressive Disclosure of Workflow Guidance\nThe documentation SHALL separate beginner essentials from advanced topics so newcomers can focus on core steps without losing access to advanced workflows.\n\n#### Scenario: Organizing beginner and advanced sections\n- **WHEN** reorganizing `openspec/AGENTS.md`\n- **THEN** keep an introductory section limited to the minimum steps (scaffold, draft, validate, request review)\n- **AND** move advanced topics (multi-capability changes, archiving details, tooling deep dives) into clearly labeled later sections\n- **AND** provide anchor links from the quick-reference to those advanced sections\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-improve-agent-instruction-usability/tasks.md",
    "content": "## 1. Instruction redesign\n- [x] 1.1 Draft a quick-reference section that surfaces file templates and formatting rules at the top of `openspec/AGENTS.md`.\n- [x] 1.2 Reorganize the workflow narrative with inline examples and progressive disclosure for advanced topics.\n\n## 2. Templates and checklists\n- [x] 2.1 Add copy/paste templates for proposal, tasks, design, and spec delta files.\n- [x] 2.2 Insert a pre-validation checklist capturing common lint failures before running `openspec validate`.\n\n## 3. Documentation updates\n- [x] 3.1 Update supporting docs or README pointers so contributors find the redesigned instructions.\n- [x] 3.2 Confirm examples and references stay in sync with the new scaffold command guidance.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-slim-root-agents-file/proposal.md",
    "content": "## Why\nThe project root currently receives a full copy of the OpenSpec agent instructions, duplicating the content that also lives in `openspec/AGENTS.md`. When teams edit one copy but not the other, the files drift and onboarding assistants see conflicting guidance.\n\n## What Changes\n- Keep generating the complete template in `openspec/AGENTS.md` during `openspec init` and follow-up updates.\n- Replace the root-level file (`AGENTS.md` or `CLAUDE.md`, depending on tool selection) with a short hand-off that explains the project uses OpenSpec and points directly to `openspec/AGENTS.md`.\n- Add a dedicated stub template so both the init and update flows reuse the same minimal copy instructions.\n- Update CLI tests and documentation to reflect the new root-level messaging and ensure the OpenSpec marker block still protects future updates.\n\n## Impact\n- Affected specs: `cli-init`, `cli-update`\n- Affected code: `src/core/init.ts`, `src/core/update.ts`, `src/core/templates/agents-template.ts`\n- Update assets/readmes that mention the root `AGENTS.md` contents to reference the new stub message.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-slim-root-agents-file/tasks.md",
    "content": "## 1. Templates\n- [x] 1.1 Add a shared stub template that renders the root agent instructions hand-off message.\n- [x] 1.2 Ensure the stub covers both `AGENTS.md` and `CLAUDE.md` variants.\n\n## 2. Init Flow\n- [x] 2.1 Update `createInitArtifacts` to write the stub to the project root instead of the full instructions.\n- [x] 2.2 Preserve the managed block markers so future updates can overwrite the stub safely.\n\n## 3. Update Flow\n- [x] 3.1 Make the update command refresh the root stub rather than the full instructions.\n- [x] 3.2 Confirm the update log output still reflects the files that changed.\n\n## 4. Tests & Docs\n- [x] 4.1 Adjust CLI/init tests to match the new root content.\n- [x] 4.2 Document the stub message in `openspec/specs/cli-init` and `openspec/specs/cli-update` (and any relevant README snippets).\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-update-cli-init-enter-selection/proposal.md",
    "content": "## Why\n- Users frequently scroll to a tool and press Enter without toggling it, resulting in no configuration changes.\n- The current workflow deviates from common CLI expectations where Enter confirms the highlighted item.\n- Aligning behavior with user expectations reduces friction during onboarding.\n\n## What Changes\n- Update the init wizard so pressing Enter on a highlighted tool selects it before moving to the review step.\n- Adjust interactive instructions to clarify Enter selects the current tool and Space still toggles selections.\n- Refresh specs to capture the clarified behavior for the interactive menu.\n\n## Impact\n- Users who press Enter without toggling now configure the highlighted tool instead of exiting with no selections.\n- Spacebar multi-select support remains unchanged for power users.\n- Documentation better reflects how the wizard behaves.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-update-cli-init-enter-selection/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Interactive Mode\nThe command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.\n#### Scenario: Displaying interactive menu\n- **WHEN** run in fresh or extend mode\n- **THEN** present a looping select menu that lets users toggle tools with Space and review selections with Enter\n- **AND** when Enter is pressed on a highlighted selectable tool that is not already selected, automatically add it to the selection before moving to review so the highlighted tool is configured\n- **AND** label already configured tools with \"(already configured)\" while keeping disabled options marked \"coming soon\"\n- **AND** change the prompt copy in extend mode to \"Which AI tools would you like to add or refresh?\"\n- **AND** display inline instructions clarifying that Space toggles tools and Enter selects the highlighted tool before reviewing selections\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-update-cli-init-enter-selection/tasks.md",
    "content": "## 1. Implementation\n- [x] Update the tool selection wizard to auto-select the highlighted tool when Enter is pressed without prior toggles.\n- [x] Refresh inline instructions copy so Enter behavior is clear.\n- [x] Adjust or add tests if needed to cover the new selection flow.\n\n## 2. Validation\n- [x] Run `pnpm run build`.\n- [x] Run `pnpm test` (or targeted suite) if applicable.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-update-cli-init-root-agents/proposal.md",
    "content": "## Why\nOpenSpec currently creates the root-level `AGENTS.md` stub only when teams explicitly select the \"AGENTS.md standard\" tool during `openspec init`. Projects that skip that checkbox never get a managed stub, so non-native assistants (Copilot, Codeium, etc.) have no entry point and later `openspec update` runs silently create the file without any context. We need to bake the stub into initialization, clarify the tool selection experience, and keep the update workflow aligned so every teammate lands on the right instructions from day one.\n\n## What Changes\n- Update `openspec init` so the root `AGENTS.md` stub is always generated (first run and extend mode) and refreshed from a shared utility instead of being tied to a tool selection.\n- Redesign the AI tool selection wizard to split options into \"Natively supported\" (Claude, Cursor, OpenCode, …) and an informational \"Other tools\" section that explains the always-on `AGENTS.md` hand-off.\n- Adjust CLI specs, prompts, and success messaging to reflect the new categories while keeping extend-mode behaviour consistent.\n- Update automated tests and fixtures to cover the unconditional stub creation and the reworked prompt flow.\n- Refresh documentation and onboarding snippets so they no longer describe the stub as opt-in and instead call out the new grouping.\n- Ensure `openspec update` continues to reconcile both `openspec/AGENTS.md` and the root stub, documenting the expected behaviour so mismatched setups self-heal.\n\n## Impact\n- Affected specs: `cli-init`, `cli-update`\n- Affected code: `src/core/init.ts`, `src/core/config.ts`, `src/core/configurators/agents.ts`, `src/core/templates/agents-root-stub.ts`, `src/core/update.ts`, related tests under `test/core/`\n- Docs & assets: README, CHANGELOG, any setup guides that reference choosing the \"AGENTS.md standard\" option\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-update-cli-init-root-agents/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: AI Tool Configuration\nThe command SHALL configure AI coding assistants with OpenSpec instructions using a grouped selection experience so teams can enable native integrations while always provisioning guidance for other assistants.\n\n#### Scenario: Prompting for AI tool selection\n- **WHEN** run interactively\n- **THEN** present a multi-select wizard that separates options into two headings:\n  - **Natively supported providers** shows each available first-party integration (Claude Code, Cursor, OpenCode, …) with checkboxes\n  - **Other tools** explains that the root-level `AGENTS.md` stub is always generated for AGENTS-compatible assistants and cannot be deselected\n- **AND** mark already configured native tools with \"(already configured)\" to signal that choosing them will refresh managed content\n- **AND** keep disabled or unavailable providers labelled as \"coming soon\" so users know they cannot opt in yet\n- **AND** allow confirming the selection even when no native provider is chosen because the root stub remains enabled by default\n- **AND** change the base prompt copy in extend mode to \"Which natively supported AI tools would you like to add or refresh?\"\n\n### Requirement: Exit Code Adjustments\n`openspec init` SHALL treat extend mode without new native tool selections as a successful refresh.\n\n#### Scenario: Allowing empty extend runs\n- **WHEN** OpenSpec is already initialized and the user selects no additional natively supported tools\n- **THEN** complete successfully while refreshing the root `AGENTS.md` stub\n- **AND** exit with code 0\n\n## ADDED Requirements\n### Requirement: Root instruction stub\n`openspec init` SHALL always scaffold the root-level `AGENTS.md` hand-off so every teammate finds the primary OpenSpec instructions.\n\n#### Scenario: Creating root `AGENTS.md`\n- **GIVEN** the project may or may not already contain an `AGENTS.md` file\n- **WHEN** initialization completes in fresh or extend mode\n- **THEN** create or refresh `AGENTS.md` at the repository root using the managed marker block from `TemplateManager.getAgentsStandardTemplate()`\n- **AND** preserve any existing content outside the managed markers while replacing the stub text inside them\n- **AND** create the stub regardless of which native AI tools are selected\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-update-cli-init-root-agents/specs/cli-update/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Tool-Agnostic Updates\nThe update command SHALL refresh OpenSpec-managed files in a predictable manner while respecting each team's chosen tooling.\n\n#### Scenario: Updating files\n- **WHEN** updating files\n- **THEN** completely replace `openspec/AGENTS.md` with the latest template\n- **AND** create or refresh the root-level `AGENTS.md` stub using the managed marker block, even if the file was previously absent\n- **AND** update only the OpenSpec-managed sections inside existing AI tool files, leaving user-authored content untouched\n- **AND** avoid creating new native-tool configuration files (slash commands, CLAUDE.md, etc.) unless they already exist\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-update-cli-init-root-agents/tasks.md",
    "content": "## 1. Implementation\n- [x] 1.1 Refactor `openspec init` to always generate the root `AGENTS.md` stub (initial run and extend mode) via shared helper logic.\n- [x] 1.2 Rework the AI tool selection wizard to surface \"Natively supported\" vs \"Other tools\" groupings and make the stub non-optional.\n- [x] 1.3 Update CLI messaging, templates, and configurators so the new flow stays in sync across init and update commands.\n- [x] 1.4 Refresh unit/integration tests to cover the unconditional stub and the regrouped prompt layout.\n- [x] 1.5 Update documentation, README snippets, and CHANGELOG entries that mention the opt-in `AGENTS.md` experience.\n\n## 2. Validation\n- [x] 2.1 Run `pnpm test` targeting CLI init/update suites.\n- [x] 2.2 Execute `openspec validate update-cli-init-root-agents --strict`.\n- [x] 2.3 Perform a manual smoke test: run `openspec init` in a temp directory, confirm stub + grouped prompts, rerun in extend mode.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-update-release-automation/proposal.md",
    "content": "## Why\nToday’s process requires maintainers to merge the Changesets PR, cut a tag, and draft the GitHub release by hand. npm publish then runs from our existing workflow after the GitHub release is published. The human-in-the-loop steps (versioning, tagging, release notes) slow us down and risk drift between npm, tags, and changelog.\n\n## What Changes\n- Use the single `changesets/action` on pushes to `main` to either open/update the version PR or, when the release PR is merged, run our publish command automatically using repository secrets.\n- Add a `release` script that builds and runs `changeset publish` so the action handles version bumps, changelog commits, npm publish, and GitHub releases end-to-end.\n- Enable `createGithubReleases: true` so GitHub releases are created from the changeset data right after publishing.\n- Document the automated flow, required secrets, guardrails, and recovery steps (rollback, hotfixes).\n\n## Two-Phase Rollout (Two PRs)\n1) Phase 1 — Dry run (no publish)\n   - Update the existing `release-prepare.yml` to wire up `changesets/action` with `createGithubReleases: true` and a no-op `publish` command (e.g., `echo 'dry run'`).\n   - Keep `.github/workflows/release-publish.yml` intact. This avoids any publish path changes while we verify that the version PR behavior and permissions are correct.\n   - Add a repository guard (`if: github.repository == 'Fission-AI/OpenSpec'`) and a concurrency group for safety.\n\n2) Phase 2 — Enable publish and consolidate\n   - Add `\"release\": \"pnpm run build && pnpm exec changeset publish\"` to `package.json`.\n   - Change `release-prepare.yml` to use `with: publish: pnpm run release` and `env: NPM_TOKEN: \\\\${{ secrets.NPM_TOKEN }}` plus the default `GITHUB_TOKEN`.\n   - Remove `.github/workflows/release-publish.yml` to avoid double-publish. Publishing now happens when the version PR is merged.\n\n## Guardrails\n- Concurrency: `concurrency: { group: release-\\\\${{ github.ref }}, cancel-in-progress: false }` on the workflow to serialize releases.\n- Repository/branch guard: run publish logic only on upstream `main` (`if: github.repository == 'Fission-AI/OpenSpec' && github.ref == 'refs/heads/main'`).\n- Permissions: ensure `contents: write` and `pull-requests: write` for opening/updating the version PR; `packages: read` optional.\n\n## Rollback and Hotfixes\n- Rollback: revert the release PR merge (which reverts version bumps/changelog); if a tag or GitHub release was created, delete the tag and release; deprecate the npm version if necessary (`npm deprecate @fission-ai/openspec@x.y.z 'reason'`).\n- Hotfix (urgent, no pending changesets): create a changeset for the fix and merge the release PR; in emergencies, run a manual bump/publish but reconcile with Changesets by adding a follow-up changeset to align versions.\n\n## Required Secrets\n- `NPM_TOKEN` with publish rights for the `@fission-ai` scope.\n- Default `GITHUB_TOKEN` (provided by GitHub) for opening/updating the version PR and creating GitHub releases.\n\n## How the Maintainer Flow Changes\n| Step | Current process | Future process |\n| --- | --- | --- |\n| Prepare release | Merge changeset PR, then manually draft release notes and tags | Merge release PR; action updates versions and handles changelog automatically |\n| Publish npm package | Happens automatically after GitHub release | Happens automatically via `changeset publish` invoked by the action |\n| GitHub release | Draft manually and sync with changelog | Action creates GitHub releases from changeset data |\n| Docs/process | Follow manual tagging/release steps | Docs describe automated flow + recovery and hotfix paths |\n\n## Impact\n- Automation: reuse `.github/workflows/release-prepare.yml` (phase 1: dry-run, phase 2: publish) and remove `.github/workflows/release-publish.yml` in phase 2.\n- Package metadata: add `release` script to `package.json`.\n- Docs: update README or `/docs` to show the automated flow, secrets, guardrails, and recovery steps.\n\n## Acceptance Criteria\n- Phase 1: merges to `main` open/update a version PR; on merge, the action’s `publish` step is a no-op; no npm publish occurs; logs confirm intended behavior; GitHub releases creation is wired but inert due to no publish.\n- Phase 2: merges to `main` run `pnpm run release` from the action; npm package publishes successfully; GitHub release is created automatically; `.github/workflows/release-publish.yml` is removed; no duplicate publishes occur.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-14-update-release-automation/tasks.md",
    "content": "## 1. Release workflow automation\n- [x] 1.1 Add a `.github/workflows/release.yml` that runs on pushes to `main`, sets up pnpm + Node 20, installs dependencies, and invokes `changesets/action@v1` with `publish: pnpm run release`.\n- [x] 1.2 Configure the action with `createGithubReleases: true` and document required secrets (`NPM_TOKEN`, default `GITHUB_TOKEN`) plus recommended concurrency safeguards.\n- [x] 1.3 Validate the workflow using `act` or a dry-run push to confirm the action opens release PRs when changesets exist and publishes when the release PR merge lands.\n\n## 2. Package release script\n- [x] 2.1 Add a `release` script to `package.json` that builds the project and runs `changeset publish` using pnpm.\n- [x] 2.2 Ensure the script respects the existing `prepare`/`prepublishOnly` hooks to avoid duplicate builds and update documentation or scripts if adjustments are needed.\n\n## 3. Documentation and recovery steps\n- [x] 3.1 Update maintainer docs (e.g., README or `/docs`) with the end-to-end automated release flow, explicitly removing the manual tag/release steps that are no longer required and explaining how changesets drive the release PR.\n- [x] 3.2 Document fallback steps for failed publishes (rerun workflow, manual publish) and the hotfix path when a release must be cut without pending changesets.\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-archive-command-arguments/proposal.md",
    "content": "# Add Archive Command Arguments\n\n## Why\nThe `/openspec:archive` slash command currently lacks argument support, forcing the AI to infer which change to archive from conversation context or by listing all changes. This creates a safety risk where the wrong proposal could be archived if the context is ambiguous or multiple changes exist. Users expect to specify the change ID explicitly, matching the behavior of the CLI command `openspec archive <id>`.\n\n## What Changes\n- Add `$ARGUMENTS` placeholder to the OpenCode archive slash command frontmatter (matching existing pattern for proposal command)\n- Update archive command template steps to validate the specific change ID argument when provided\n- Note: Codex, GitHub Copilot, and Amazon Q already have `$ARGUMENTS` for archive; Claude/Cursor/Windsurf/Kilocode don't support arguments\n\n## Impact\n- Affected specs: `cli-update` (slash command generation logic)\n- Affected code:\n  - `src/core/configurators/slash/opencode.ts` (add `$ARGUMENTS` to archive frontmatter)\n  - `src/core/templates/slash-command-templates.ts` (archive template steps for argument validation)\n- Breaking: No - this is additive functionality that makes the command safer\n- User-facing: Yes - OpenCode users will be able to pass the change ID as an argument: `/openspec:archive <change-id>`\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-archive-command-arguments/specs/cli-update/spec.md",
    "content": "# CLI Update Specification Delta\n\n## MODIFIED Requirements\n\n### Requirement: Slash Command Updates\nThe 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.\n\n#### Scenario: Updating slash commands for OpenCode\n- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments\n\n### Requirement: Archive Command Argument Support\nThe archive slash command template SHALL support optional change ID arguments for tools that support `$ARGUMENTS` placeholder.\n\n#### Scenario: Archive command with change ID argument\n- **WHEN** a user invokes `/openspec:archive <change-id>` with a change ID\n- **THEN** the template SHALL instruct the AI to validate the provided change ID against `openspec list`\n- **AND** use the provided change ID for archiving if valid\n- **AND** fail fast if the provided change ID doesn't match an archivable change\n\n#### Scenario: Archive command without argument (backward compatibility)\n- **WHEN** a user invokes `/openspec:archive` without providing a change ID\n- **THEN** the template SHALL instruct the AI to identify the change ID from context or by running `openspec list`\n- **AND** proceed with the existing behavior (maintaining backward compatibility)\n\n#### Scenario: OpenCode archive template generation\n- **WHEN** generating the OpenCode archive slash command file\n- **THEN** include the `$ARGUMENTS` placeholder in the frontmatter\n- **AND** wrap it in a clear structure like `<ChangeId>\\n  $ARGUMENTS\\n</ChangeId>` to indicate the expected argument\n- **AND** include validation steps in the template body to check if the change ID is valid\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-archive-command-arguments/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Update OpenCode Configurator\n- [x] 1.1 Add `$ARGUMENTS` placeholder to OpenCode archive frontmatter (matching the proposal pattern)\n- [x] 1.2 Format it as `<ChangeId>\\n  $ARGUMENTS\\n</ChangeId>` or similar structure for clarity\n- [x] 1.3 Ensure `updateExisting` rewrites the archive frontmatter/body so `$ARGUMENTS` persists after `openspec update`\n\n## 2. Update Slash Command Templates\n- [x] 2.1 Modify archive steps to validate change ID argument when provided via `$ARGUMENTS`\n- [x] 2.2 Keep backward compatibility - allow inferring from context if no argument provided\n- [x] 2.3 Add step to validate the change ID exists using `openspec list` before archiving\n\n## 3. Update Documentation\n- [x] 3.1 Update AGENTS.md archive examples to show argument usage\n- [x] 3.2 Document that OpenCode now supports `/openspec:archive <change-id>`\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-cline-support/proposal.md",
    "content": "## Why\nAdd support for Cline (VS Code extension) in OpenSpec to enable developers to use Cline's AI-powered coding capabilities for spec-driven development workflows.\n\n## What Changes\n- Add Cline slash command configurator for proposal, apply, and archive operations\n- Add Cline root CLINE.md configurator for project-level instructions\n- Add Cline template exports\n- Update tool and slash command registries to include Cline\n- Add comprehensive test coverage\n- **BREAKING**: None - this is additive functionality\n\n## Impact\n- Affected specs: cli-init (new tool option)\n- Affected code: src/core/configurators/slash/cline.ts, src/core/configurators/cline.ts, registry files\n- New files: .clinerules/openspec-*.md, CLINE.md\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-cline-support/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: AI Tool Configuration Details\n\nThe command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.\n\n#### Scenario: Configuring Claude Code\n\n- **WHEN** Claude Code is selected\n- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)\n- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`\n\n#### Scenario: Configuring CodeBuddy Code\n\n- **WHEN** CodeBuddy Code is selected\n- **THEN** create or update `CODEBUDDY.md` in the project root directory (not inside openspec/)\n- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`\n\n#### Scenario: Configuring Cline\n\n- **WHEN** Cline is selected\n- **THEN** create or update `CLINE.md` in the project root directory (not inside openspec/)\n- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`\n\n#### Scenario: Creating new CLAUDE.md\n\n- **WHEN** CLAUDE.md does not exist\n- **THEN** create new file with stub instructions wrapped in markers so the full workflow stays in `openspec/AGENTS.md`:\n```markdown\n<!-- OPENSPEC:START -->\n# OpenSpec Instructions\n\nThis project uses OpenSpec to manage AI assistant workflows.\n\n- Full guidance lives in '@/openspec/AGENTS.md'.\n- Keep this managed block so 'openspec update' can refresh the instructions.\n<!-- OPENSPEC:END -->\n```\n\n### Requirement: Slash Command Configuration\nThe init command SHALL generate slash command files for supported editors using shared templates.\n\n#### Scenario: Generating slash commands for Claude Code\n- **WHEN** the user selects Claude Code during initialization\n- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for CodeBuddy Code\n- **WHEN** the user selects CodeBuddy Code during initialization\n- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cline\n- **WHEN** the user selects Cline during initialization\n- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include Cline-specific Markdown heading frontmatter\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cursor\n- **WHEN** the user selects Cursor during initialization\n- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for OpenCode\n- **WHEN** the user selects OpenCode during initialization\n- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Windsurf\n- **WHEN** the user selects Windsurf during initialization\n- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Kilo Code\n- **WHEN** the user selects Kilo Code during initialization\n- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Codex\n- **WHEN** the user selects Codex during initialization\n- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)\n- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)\n- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes\n\n#### Scenario: Generating slash commands for GitHub Copilot\n- **WHEN** the user selects GitHub Copilot during initialization\n- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`\n- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage\n- **AND** include `$ARGUMENTS` placeholder to capture user input\n- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-cline-support/tasks.md",
    "content": "## 1. Implementation\n- [x] 1.1 Create ClineSlashCommandConfigurator class in src/core/configurators/slash/cline.ts\n- [x] 1.2 Create ClineConfigurator class in src/core/configurators/cline.ts\n- [x] 1.3 Create cline-template.ts for template exports\n- [x] 1.4 Define file paths for Cline rules (.clinerules/)\n- [x] 1.5 Create Cline-specific frontmatter (Markdown heading format)\n- [x] 1.6 Register Cline in slash/registry.ts\n- [x] 1.7 Register Cline in configurators/registry.ts\n- [x] 1.8 Add Cline to AI_TOOLS in config.ts\n- [x] 1.9 Add getClineTemplate() to templates/index.ts\n- [x] 1.10 Update README with Cline documentation\n\n## 2. Testing\n- [x] 2.1 Add init tests for CLINE.md creation and updates\n- [x] 2.2 Add init tests for .clinerules/ file creation\n- [x] 2.3 Add update tests for CLINE.md updates\n- [x] 2.4 Add update tests for .clinerules/ file refreshes\n- [x] 2.5 Test integration with openspec init --tools cline\n- [x] 2.6 Verify all 225 tests pass\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-crush-support/proposal.md",
    "content": "## Why\nAdd support for Crush AI assistant in OpenSpec to enable developers to use Crush's enhanced capabilities for spec-driven development workflows.\n\n## What Changes\n- Add Crush slash command configurator for proposal, apply, and archive operations\n- Add Crush-specific AGENTS.md configuration template \n- Update tool registry to include Crush configurator\n- **BREAKING**: None - this is additive functionality\n\n## Impact\n- Affected specs: cli-init (new tool option)\n- Affected code: src/core/configurators/slash/crush.ts, registry.ts\n- New files: .crush/commands/openspec/ (proposal.md, apply.md, archive.md)"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-crush-support/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Slash Command Configuration\nThe init command SHALL generate slash command files for supported editors using shared templates.\n\n#### Scenario: Generating slash commands for Claude Code\n- **WHEN** the user selects Claude Code during initialization\n- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for CodeBuddy Code\n- **WHEN** the user selects CodeBuddy Code during initialization\n- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cline\n- **WHEN** the user selects Cline during initialization\n- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include Cline-specific Markdown heading frontmatter\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Crush\n- **WHEN** the user selects Crush during initialization\n- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include Crush-specific frontmatter with OpenSpec category and tags\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cursor\n- **WHEN** the user selects Cursor during initialization\n- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for OpenCode\n- **WHEN** the user selects OpenCode during initialization\n- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Windsurf\n- **WHEN** the user selects Windsurf during initialization\n- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Kilo Code\n- **WHEN** the user selects Kilo Code during initialization\n- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Codex\n- **WHEN** the user selects Codex during initialization\n- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)\n- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)\n- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes\n\n#### Scenario: Generating slash commands for GitHub Copilot\n- **WHEN** the user selects GitHub Copilot during initialization\n- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`\n- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage\n- **AND** include `$ARGUMENTS` placeholder to capture user input\n- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-crush-support/tasks.md",
    "content": "## 1. Implementation\n- [x] 1.1 Create CrushSlashCommandConfigurator class in src/core/configurators/slash/crush.ts\n- [x] 1.2 Define file paths for Crush commands (.crush/commands/openspec/)\n- [x] 1.3 Create Crush-specific frontmatter for proposal, apply, archive commands\n- [x] 1.4 Register Crush configurator in slash/registry.ts\n- [x] 1.5 Add Crush to available tools in cli-init command\n- [x] 1.6 Test integration with openspec init --tool crush"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-factory-slash-commands/proposal.md",
    "content": "## Why\nFactory's Droid CLI recently shipped custom slash commands that mirror other native assistant integrations. Teams using OpenSpec want the same managed workflows they already get for Cursor, Windsurf, and others so init/update can provision and refresh Factory commands without manual setup.\n\n## What Changes\n- Extend the native tool registry so Factory/Droid appears alongside other slash-command integrations during `openspec init`.\n- Add shared templates that generate the three Factory custom commands (proposal, apply, archive) and wrap them in OpenSpec markers for safe refreshes.\n- Update the init and update command flows so they create or refresh Factory command files when the tool is selected or already present.\n- Refresh CLI specs to document the Factory support and align validation expectations.\n\n## Impact\n- Affected specs: `specs/cli-init`, `specs/cli-update`\n- Affected code (expected): tool registry, slash-command template manager, init/update command helpers, documentation snippets\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-factory-slash-commands/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Slash Command Configuration\nThe init command SHALL generate slash command files for supported editors using shared templates.\n\n#### Scenario: Generating slash commands for Claude Code\n- **WHEN** the user selects Claude Code during initialization\n- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cursor\n- **WHEN** the user selects Cursor during initialization\n- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Factory Droid\n- **WHEN** the user selects Factory Droid during initialization\n- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`\n- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input\n- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands\n\n#### Scenario: Generating slash commands for OpenCode\n- **WHEN** the user selects OpenCode during initialization\n- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Windsurf\n- **WHEN** the user selects Windsurf during initialization\n- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Kilo Code\n- **WHEN** the user selects Kilo Code during initialization\n- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Codex\n- **WHEN** the user selects Codex during initialization\n- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)\n- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)\n- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes\n\n#### Scenario: Generating slash commands for GitHub Copilot\n- **WHEN** the user selects GitHub Copilot during initialization\n- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`\n- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage\n- **AND** include `$ARGUMENTS` placeholder to capture user input\n- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-factory-slash-commands/specs/cli-update/spec.md",
    "content": "## MODIFIED Requirements\n### Requirement: Slash Command Updates\nThe update command SHALL refresh existing slash command files for configured tools without creating new ones.\n\n#### Scenario: Updating slash commands for Claude Code\n- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cursor\n- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Factory Droid\n- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid\n- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched\n- **AND** skip creating missing files during update\n\n#### Scenario: Updating slash commands for OpenCode\n- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Windsurf\n- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Kilo Code\n- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Codex\n- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **WHEN** a user runs `openspec update`\n- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance)\n- **AND** preserve any unmanaged content outside the OpenSpec marker block\n- **AND** skip creation when a Codex prompt file is missing\n\n#### Scenario: Updating slash commands for GitHub Copilot\n- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md`\n- **THEN** refresh each file using shared templates while preserving the YAML frontmatter\n- **AND** update only the OpenSpec-managed block between markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Missing slash command file\n- **WHEN** a tool lacks a slash command file\n- **THEN** do not create a new file during update\n"
  },
  {
    "path": "openspec/changes/archive/2025-10-22-add-factory-slash-commands/tasks.md",
    "content": "## 1. Factory tool registration\n- [x] 1.1 Add Factory/Droid metadata to the native tool registry used by init/update (ID, display name, command paths, availability flags).\n- [x] 1.2 Surface Factory in interactive prompts and non-interactive `--tools` parsing alongside existing slash-command integrations.\n\n## 2. Slash command templates\n- [x] 2.1 Create shared templates for Factory's `openspec-proposal`, `openspec-apply`, and `openspec-archive` custom commands following Factory's CLI format.\n- [x] 2.2 Wire the templates into init/update so generation happens on create and refresh respects OpenSpec markers.\n\n## 3. Verification\n- [x] 3.1 Update or add automated coverage that ensures Factory command files are scaffolded and refreshed correctly.\n- [x] 3.2 Document the new option in any user-facing copy (help text, README snippets) if required by spec.\n"
  },
  {
    "path": "openspec/changes/archive/2025-11-06-add-shell-completions/design.md",
    "content": "# Shell Completions Design\n\n## Overview\n\nThis design establishes a plugin-based architecture for shell completions that prioritizes clean TypeScript patterns, scalability, and maintainability. The system separates concerns between shell-specific generation logic, dynamic completion data providers, and installation automation.\n\n**Scope:** This proposal implements **Zsh completion only** (with Oh My Zsh priority). The architecture is designed to support bash, fish, and PowerShell in future proposals.\n\n## Native Shell Completion Behaviors\n\n**Design Philosophy:** We integrate with each shell's native completion system rather than attempting to customize or unify behaviors. This ensures familiar UX for users and reduces maintenance complexity.\n\n**Note:** While all four shell behaviors are documented below for architectural reference, **only Zsh is implemented in this proposal**. Bash, Fish, and PowerShell are documented to guide future implementations.\n\n### Bash Completion Behavior\n\n**Interaction Pattern:**\n- **Single TAB:** Completes if only one match exists, otherwise does nothing\n- **Double TAB (TAB TAB):** Displays all possible completions as a list\n- **Type more characters + TAB:** Narrows matches and completes or shows refined list\n\n**OpenSpec Integration:**\n```bash\n# After installing: openspec completion install bash\nopenspec val<TAB>           # Completes to \"openspec validate\"\nopenspec validate <TAB><TAB>  # Shows: --all --changes --specs --strict --json [change-ids] [spec-ids]\nopenspec show add-<TAB><TAB>  # Shows all changes starting with \"add-\"\n```\n\n**Implementation:** Uses bash-completion framework with `_init_completion`, `compgen`, and `COMPREPLY` array.\n\n### Zsh Completion Behavior (with Oh My Zsh)\n\n**Interaction Pattern:**\n- **Single TAB:** Shows interactive menu with all matches immediately\n- **TAB / Arrow Keys:** Navigate through completion options\n- **Enter:** Selects highlighted option\n- **Ctrl+C / Esc:** Cancels completion menu\n\n**OpenSpec Integration:**\n```zsh\n# After installing: openspec completion install zsh\nopenspec val<TAB>    # Shows menu with \"validate\" and \"view\" highlighted\nopenspec show <TAB>  # Shows menu with all change IDs and spec IDs, categorized\n```\n\n**Implementation:** Uses Zsh completion system with `_arguments`, `_describe`, and `compadd` built-ins. Oh My Zsh provides enhanced menu styling automatically.\n\n### Fish Completion Behavior\n\n**Interaction Pattern:**\n- **As-you-type:** Gray suggestions appear automatically in real-time\n- **Right Arrow / Ctrl+F:** Accepts the suggestion\n- **TAB:** Shows menu with all matches if multiple exist\n- **TAB again:** Cycles through options or navigates menu\n- **Enter:** Accepts current selection\n\n**OpenSpec Integration:**\n```fish\n# After installing: openspec completion install fish\nopenspec val       # Gray suggestion shows \"validate\" immediately\nopenspec show a    # Real-time suggestions for changes starting with \"a\"\nopenspec <TAB>     # Shows all commands with descriptions in paged menu\n```\n\n**Implementation:** Uses Fish's declarative `complete -c` syntax. Completions are auto-loaded from `~/.config/fish/completions/`.\n\n### PowerShell Completion Behavior\n\n**Interaction Pattern:**\n- **TAB:** Cycles forward through completions one at a time (inline replacement)\n- **Shift+TAB:** Cycles backward through completions\n- **Ctrl+Space:** Shows IntelliSense-style menu (PSReadLine v2.2+)\n- **Arrow Keys:** Navigate menu if shown\n\n**OpenSpec Integration:**\n```powershell\n# After installing: openspec completion install powershell\nopenspec val<TAB>       # Cycles: validate → view → validate\nopenspec show <TAB>     # Cycles through change IDs one by one\nopenspec <Ctrl+Space>   # Shows IntelliSense menu with all commands\n```\n\n**Implementation:** Uses `Register-ArgumentCompleter` with custom script block that returns `[System.Management.Automation.CompletionResult]` objects.\n\n### Comparison Table\n\n| Shell       | Trigger         | Display Style          | Navigation           | Selection      |\n|-------------|-----------------|------------------------|----------------------|----------------|\n| Bash        | TAB TAB         | List (printed once)    | Type more + TAB      | Auto-complete  |\n| Zsh         | TAB             | Interactive menu       | TAB/Arrows           | Enter          |\n| Fish        | TAB/Auto        | Real-time + menu       | TAB/Arrows           | Enter/Right    |\n| PowerShell  | TAB             | Inline cycling         | TAB/Shift+TAB        | Stop cycling   |\n\n**Key Insight:** Each shell's completion UX reflects its design philosophy. We respect these conventions rather than forcing uniformity.\n\n## Architectural Principles\n\n### 1. Plugin-Based Generator System\n\nEach shell has unique completion syntax and conventions. Rather than creating a monolithic generator with branching logic, we use a plugin pattern where each shell implements a common interface:\n\n```typescript\ninterface CompletionGenerator {\n  generate(): string;\n  getInstallPath(): string;\n  getConfigFile(): string;\n}\n```\n\n**Benefits:**\n- New shells can be added without modifying existing generators\n- Shell-specific logic is isolated and testable\n- Type safety ensures all generators implement required methods\n- Easy to maintain and understand (single responsibility per generator)\n\n**Implementation Classes:**\n- `ZshCompletionGenerator` - Uses Zsh's `_arguments` and `_describe` functions\n- `BashCompletionGenerator` - Uses `_init_completion` and `compgen` built-ins\n- `FishCompletionGenerator` - Uses `complete -c` declarative syntax\n- `PowerShellCompletionGenerator` - Uses `Register-ArgumentCompleter` cmdlet\n\n### 2. Centralized Command Registry\n\nShell completions must stay synchronized with actual CLI commands. To avoid duplication and drift, we maintain a single source of truth:\n\n```typescript\ntype CommandDefinition = {\n  name: string;\n  description: string;\n  flags: FlagDefinition[];\n  acceptsChangeId: boolean;\n  acceptsSpecId: boolean;\n  subcommands?: CommandDefinition[];\n};\n\nconst COMMAND_REGISTRY: CommandDefinition[] = [\n  {\n    name: 'init',\n    description: 'Initialize OpenSpec in your project',\n    flags: [\n      { name: '--tools', description: 'Configure AI tools non-interactively', hasValue: true }\n    ],\n    acceptsChangeId: false,\n    acceptsSpecId: false\n  },\n  // ... all other commands\n];\n```\n\n**Benefits:**\n- All generators consume the same command definitions\n- Adding a new command automatically propagates to all shells\n- Flag changes only need to be made in one place\n- Type safety prevents typos and missing fields\n- Easier to test (mock the registry)\n\n**TypeScript Sugar:**\n- Use `const` assertions for readonly registry\n- Leverage discriminated unions for command types\n- Use `satisfies` operator to ensure registry matches interface\n\n### 3. Dynamic Completion Provider\n\nChange and spec IDs are project-specific and discovered at runtime. A dedicated provider encapsulates this logic:\n\n```typescript\nclass CompletionProvider {\n  private changeCache: { ids: string[]; timestamp: number } | null = null;\n  private specCache: { ids: string[]; timestamp: number } | null = null;\n  private readonly CACHE_TTL_MS = 2000;\n\n  async getChangeIds(): Promise<string[]> {\n    if (this.changeCache && Date.now() - this.changeCache.timestamp < this.CACHE_TTL_MS) {\n      return this.changeCache.ids;\n    }\n\n    const ids = await discoverActiveChangeIds();\n    this.changeCache = { ids, timestamp: Date.now() };\n    return ids;\n  }\n\n  async getSpecIds(): Promise<string[]> {\n    // Similar caching logic\n  }\n\n  isOpenSpecProject(): boolean {\n    // Check for openspec/ directory\n  }\n}\n```\n\n**Benefits:**\n- Caching reduces file system overhead during rapid tab completion\n- Encapsulates project detection logic\n- Easy to test with mocked file system\n- Shared across all shell generators\n\n**Design Decisions:**\n- 2-second cache TTL balances freshness with performance\n- Cache per-process (not persistent) to avoid stale data across sessions\n- Graceful degradation when outside OpenSpec projects\n\n### 4. Separate Installation Logic\n\nInstallation involves shell configuration file manipulation, which differs from generation. We separate this concern:\n\n```typescript\ninterface CompletionInstaller {\n  install(): Promise<InstallResult>;\n  uninstall(): Promise<UninstallResult>;\n  isInstalled(): Promise<boolean>;\n}\n```\n\n**Shell-Specific Installers:**\n- `ZshInstaller` - Handles both Oh My Zsh (custom completions) and standard Zsh (fpath)\n- `BashInstaller` - Detects completion directories and sources from `.bashrc`\n- `FishInstaller` - Writes to `~/.config/fish/completions/` (auto-loaded)\n- `PowerShellInstaller` - Appends to PowerShell profile\n\n**Benefits:**\n- Installation logic doesn't pollute generator code\n- Can test installation without generating completion scripts\n- Easier to handle edge cases (missing directories, permissions, already installed)\n\n### 5. Type-Safe Shell Detection\n\nWe use TypeScript's literal types and type guards for shell detection:\n\n```typescript\ntype SupportedShell = 'bash' | 'zsh' | 'fish' | 'powershell';\n\nfunction detectShell(): SupportedShell {\n  const shellPath = process.env.SHELL || '';\n  const shellName = path.basename(shellPath).toLowerCase();\n\n  // PowerShell normalization\n  if (shellName === 'pwsh' || shellName === 'powershell') {\n    return 'powershell';\n  }\n\n  const supported: SupportedShell[] = ['bash', 'zsh', 'fish', 'powershell'];\n  if (supported.includes(shellName as SupportedShell)) {\n    return shellName as SupportedShell;\n  }\n\n  throw new Error(`Shell '${shellName}' is not supported. Supported: ${supported.join(', ')}`);\n}\n```\n\n**Benefits:**\n- Compile-time type checking prevents invalid shell names\n- Easy to add new shells (add to union type)\n- Type narrowing works in switch statements\n- Clear error messages for unsupported shells\n\n### 6. Factory Pattern for Instantiation\n\nA factory function selects the appropriate generator/installer based on shell type:\n\n```typescript\nfunction createGenerator(shell: SupportedShell, provider: CompletionProvider): CompletionGenerator {\n  switch (shell) {\n    case 'bash': return new BashCompletionGenerator(COMMAND_REGISTRY, provider);\n    case 'zsh': return new ZshCompletionGenerator(COMMAND_REGISTRY, provider);\n    case 'fish': return new FishCompletionGenerator(COMMAND_REGISTRY, provider);\n    case 'powershell': return new PowerShellCompletionGenerator(COMMAND_REGISTRY, provider);\n  }\n}\n```\n\n**Benefits:**\n- Single point of instantiation\n- Type safety ensures exhaustive switch (TypeScript error if shell type missing)\n- Easy to inject dependencies (registry, provider)\n\n## Command Structure\n\n**This Proposal (Zsh-only):**\n```\nopenspec completion\n├── zsh               # Generate Zsh completion script\n├── install [shell]   # Install Zsh completion (auto-detects or explicit zsh)\n└── uninstall [shell] # Remove Zsh completion (auto-detects or explicit zsh)\n```\n\n**Future (after follow-up proposals):**\n```\nopenspec completion\n├── bash              # Generate Bash completion script (future)\n├── zsh               # Generate Zsh completion script (this proposal)\n├── fish              # Generate Fish completion script (future)\n├── powershell        # Generate PowerShell completion script (future)\n├── install [shell]   # Install completion (auto-detects or explicit shell)\n└── uninstall [shell] # Remove completion (auto-detects or explicit shell)\n```\n\n## File Organization\n\n**This Proposal (Zsh-only):**\n```\nsrc/\n├── commands/\n│   └── completion.ts              # CLI command registration (zsh, install, uninstall)\n├── core/\n│   └── completions/\n│       ├── types.ts               # Interfaces: CompletionGenerator, CommandDefinition, etc.\n│       ├── command-registry.ts    # Single source of truth for OpenSpec commands\n│       ├── completion-provider.ts # Dynamic change/spec ID discovery with caching\n│       ├── factory.ts             # Factory for instantiating Zsh generator/installer\n│       ├── generators/\n│       │   └── zsh-generator.ts   # Zsh completion script generator\n│       └── installers/\n│           └── zsh-installer.ts   # Handles Oh My Zsh + standard Zsh installation\n└── utils/\n    └── shell-detection.ts         # Shell detection (returns 'zsh' or throws)\n```\n\n**Future additions (bash, fish, powershell):**\n- `generators/bash-generator.ts`, `fish-generator.ts`, `powershell-generator.ts`\n- `installers/bash-installer.ts`, `fish-installer.ts`, `powershell-installer.ts`\n- Update `shell-detection.ts` to support additional shell types\n\n## Oh My Zsh Priority\n\nZsh implementation prioritizes Oh My Zsh because:\n1. **Popularity** - Oh My Zsh is the most popular Zsh configuration framework\n2. **Convention** - Has standard completion directory (`~/.oh-my-zsh/custom/completions/`)\n3. **Detection** - Easy to detect via `$ZSH` environment variable\n4. **Fallback** - Standard Zsh support provides compatibility when Oh My Zsh isn't installed\n\n**Installation Strategy:**\n```typescript\nif (isOhMyZshInstalled()) {\n  // Install to ~/.oh-my-zsh/custom/completions/_openspec\n  // Automatically loaded by Oh My Zsh\n} else {\n  // Install to ~/.zsh/completions/_openspec\n  // Update ~/.zshrc with fpath and compinit if needed\n}\n```\n\n## Caching Strategy\n\nDynamic completions cache results for 2 seconds to balance freshness with performance:\n\n**Why 2 seconds?**\n- Typical tab completion sessions last < 2 seconds\n- Prevents repeated file system scans during rapid tabbing\n- Short enough to feel \"live\" when changes/specs are added\n- Automatic per-process expiration (no stale data across sessions)\n\n**Implementation:**\n```typescript\nprivate changeCache: { ids: string[]; timestamp: number } | null = null;\nprivate readonly CACHE_TTL_MS = 2000;\n\nif (this.changeCache && Date.now() - this.changeCache.timestamp < this.CACHE_TTL_MS) {\n  return this.changeCache.ids; // Use cached\n}\n// Refresh cache\n```\n\n## Error Handling Philosophy\n\nCompletions should degrade gracefully rather than break workflows:\n\n1. **Unsupported shell** - Clear error with list of supported shells\n2. **Not in OpenSpec project** - Skip dynamic completions, only offer static commands\n3. **Permission errors** - Suggest alternative installation methods\n4. **Missing config directories** - Auto-create with user notification\n5. **Already installed** - Offer to reinstall/update\n6. **Not installed (during uninstall)** - Exit gracefully with informational message\n\n## Testing Strategy\n\nEach component is independently testable:\n\n1. **Unit Tests**\n   - Shell detection with mocked `$SHELL` environment variable\n   - Generator output verification (regex pattern matching)\n   - Completion provider caching behavior\n   - Command registry structure validation\n\n2. **Integration Tests**\n   - Installation to temporary test directories\n   - Configuration file modifications\n   - End-to-end command flow (generate → install → verify)\n\n3. **Manual Testing**\n   - Real shell environments (Oh My Zsh, Bash, Fish, PowerShell)\n   - Tab completion behavior in OpenSpec projects\n   - Dynamic change/spec ID suggestions\n   - Installation/uninstallation workflows\n\n## TypeScript Sugar Patterns\n\n### 1. Const Assertions for Immutable Data\n```typescript\nconst COMMAND_REGISTRY = [\n  { name: 'init', ... },\n  { name: 'list', ... }\n] as const;\n```\n\n### 2. Discriminated Unions for Command Types\n```typescript\ntype Command =\n  | { type: 'simple'; name: string }\n  | { type: 'with-subcommands'; name: string; subcommands: Command[] };\n```\n\n### 3. Template Literal Types for Strings\n```typescript\ntype ShellConfigFile = `~/.${SupportedShell}rc` | `~/.${SupportedShell}_profile`;\n```\n\n### 4. Satisfies Operator for Type Validation\n```typescript\nconst config = {\n  shell: 'zsh',\n  path: '~/.zshrc'\n} satisfies ShellConfig;\n```\n\n### 5. Optional Chaining and Nullish Coalescing\n```typescript\nconst path = process.env.ZSH ?? `${os.homedir()}/.oh-my-zsh`;\n```\n\n### 6. Async/Await with Promise.all for Parallel Operations\n```typescript\nconst [changes, specs] = await Promise.all([\n  provider.getChangeIds(),\n  provider.getSpecIds()\n]);\n```\n\n## Scalability Considerations\n\n### Adding a New Shell\n\n1. Define shell in `SupportedShell` union type\n2. Create generator class implementing `CompletionGenerator`\n3. Create installer class implementing `CompletionInstaller`\n4. Add cases to factory functions\n5. Add command registration in CLI\n6. Write tests\n\n**TypeScript will enforce** that all switch statements are updated (exhaustiveness checking).\n\n### Adding a New Command\n\n1. Add to `COMMAND_REGISTRY` with appropriate metadata\n2. All generators automatically include it\n3. Update tests to verify new command appears\n\n### Changing Completion Behavior\n\nDynamic completion logic is centralized in `CompletionProvider`, making behavior changes trivial without touching shell-specific code.\n\n## Trade-offs and Decisions\n\n### Decision: Separate Generators vs. Template Engine\n\n**Chosen:** Separate generator classes per shell\n\n**Alternative:** Template engine with shell-specific templates\n\n**Rationale:**\n- Shell completion syntax is fundamentally different (not just text substitution)\n- Type safety is better with classes than templates\n- Logic complexity (caching, dynamic completions) doesn't fit template paradigm\n- Easier to debug and test dedicated classes\n\n### Decision: 2-Second Cache TTL\n\n**Chosen:** 2-second cache\n\n**Alternatives:** No cache (slow), longer cache (stale), persistent cache (complex)\n\n**Rationale:**\n- Balances performance with freshness\n- Matches typical user interaction patterns\n- Simple implementation (no invalidation complexity)\n- Automatic cleanup on process exit\n\n### Decision: Oh My Zsh Detection\n\n**Chosen:** Check `$ZSH` env var first, then `~/.oh-my-zsh/` directory\n\n**Rationale:**\n- `$ZSH` is set by Oh My Zsh initialization (reliable)\n- Directory check is fallback for non-interactive scenarios\n- Standard Zsh serves as ultimate fallback\n\n### Decision: Installation Automation vs. Manual Instructions\n\n**Chosen:** Automated installation with install/uninstall commands\n\n**Alternative:** Generate script and provide manual installation instructions\n\n**Rationale:**\n- Better user experience (one command vs. multiple manual steps)\n- Reduces errors from manual configuration\n- Aligns with user expectations for modern CLI tools\n- Still supports manual workflow via script generation to stdout\n\n## Future Enhancements\n\n1. **Contextual Flag Completion** - Suggest only valid flags for current command\n2. **Fuzzy Matching** - Allow partial matching for change/spec IDs\n3. **Rich Descriptions** - Include \"why\" section in completion suggestions (shell-dependent)\n4. **Completion Stats** - Track completion usage for analytics\n5. **Custom Completion Hooks** - Allow projects to extend completions\n6. **MCP Integration** - Provide completions via Model Context Protocol\n\n## References\n\n- [Bash Programmable Completion](https://www.gnu.org/software/bash/manual/html_node/Programmable-Completion.html)\n- [Zsh Completion System](https://zsh.sourceforge.io/Doc/Release/Completion-System.html)\n- [Fish Completions](https://fishshell.com/docs/current/completions.html)\n- [PowerShell Argument Completers](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/register-argumentcompleter)\n- [Oh My Zsh Custom Completions](https://github.com/ohmyzsh/ohmyzsh/wiki/Customization#adding-custom-completions)\n"
  },
  {
    "path": "openspec/changes/archive/2025-11-06-add-shell-completions/proposal.md",
    "content": "# Add Shell Completions\n\n## Why\n\nOpenSpec CLI commands lack shell completion, forcing users to remember all commands, subcommands, flags, and change/spec IDs manually. This creates friction during daily use and slows developer workflows. Shell completions are a standard expectation for modern CLI tools and significantly improve user experience through:\n- Faster command discovery via tab completion\n- Reduced cognitive load by removing memorization requirements\n- Fewer typos through validated suggestions\n- Professional polish expected of production-grade tools\n\n## What Changes\n\nThis change adds shell completion support for the OpenSpec CLI, starting with **Zsh (including Oh My Zsh)** and establishing a scalable architecture for future shells (bash, fish, PowerShell). The implementation provides:\n\n1. **New `openspec completion` command** with Zsh generation and installation/uninstallation capabilities\n2. **Native Zsh integration** that respects standard Zsh tab completion behavior (single-TAB menu navigation)\n3. **Dynamic completion providers** that discover active changes and specs from the current project\n4. **Plugin-based architecture** using TypeScript interfaces for easy extension to additional shells in future proposals\n5. **Installation automation** for Oh My Zsh (priority) and standard Zsh configurations\n6. **Context-aware suggestions** that only activate within OpenSpec-enabled projects\n\nThe architecture emphasizes clean TypeScript patterns, composable generators, separation of concerns between shell-specific logic and shared completion data providers, and integration with native shell completion systems. Other shells (bash, fish, PowerShell) are architecturally documented but not implemented in this proposal—they will be added in follow-up changes.\n\n## Deltas\n\n### Delta: New CLI completion specification\n- **Spec:** cli-completion\n- **Operation:** ADDED\n- **Description:** Defines requirements for the new `openspec completion` command including generation, installation, and shell-specific behaviors for Oh My Zsh, bash, fish, and PowerShell."
  },
  {
    "path": "openspec/changes/archive/2025-11-06-add-shell-completions/specs/cli-completion/spec.md",
    "content": "# CLI Completion Specification\n\n## Purpose\n\nThe `openspec completion` command SHALL provide shell completion functionality for all OpenSpec CLI commands, flags, and dynamic values (change IDs, spec IDs), with support for Zsh (including Oh My Zsh) and a scalable architecture ready for future shells (bash, fish, PowerShell). The completion system SHALL integrate with Zsh's native completion behavior rather than attempting to customize the user experience.\n\n## ADDED Requirements\n\n### Requirement: Native Shell Behavior Integration\n\nThe completion system SHALL respect and integrate with Zsh's native completion patterns and user interaction model.\n\n#### Scenario: Zsh native completion\n\n- **WHEN** generating Zsh completion scripts\n- **THEN** use Zsh completion system with `_arguments`, `_describe`, and `compadd`\n- **AND** completions SHALL trigger on single TAB (standard Zsh behavior)\n- **AND** display as an interactive menu that users navigate with TAB/arrow keys\n- **AND** support Oh My Zsh's enhanced menu styling automatically\n\n#### Scenario: No custom UX patterns\n\n- **WHEN** implementing Zsh completion\n- **THEN** do NOT attempt to customize completion trigger behavior\n- **AND** do NOT override Zsh-specific navigation patterns\n- **AND** ensure completions feel native to experienced Zsh users\n\n### Requirement: Command Structure\n\nThe completion command SHALL follow a subcommand pattern for generating and managing completion scripts.\n\n#### Scenario: Available subcommands\n\n- **WHEN** user executes `openspec completion --help`\n- **THEN** display available subcommands:\n  - `zsh` - Generate Zsh completion script\n  - `install [shell]` - Install completion for Zsh (auto-detects or requires explicit shell)\n  - `uninstall [shell]` - Remove completion for Zsh (auto-detects or requires explicit shell)\n\n### Requirement: Shell Detection\n\nThe completion system SHALL automatically detect the user's current shell environment.\n\n#### Scenario: Detecting Zsh from environment\n\n- **WHEN** no shell is explicitly specified\n- **THEN** read the `$SHELL` environment variable\n- **AND** extract the shell name from the path (e.g., `/bin/zsh` → `zsh`)\n- **AND** validate the shell is `zsh`\n- **AND** throw an error if the shell is not `zsh`, with message indicating only Zsh is currently supported\n\n#### Scenario: Non-Zsh shell detection\n\n- **WHEN** shell path indicates bash, fish, powershell, or other non-Zsh shell\n- **THEN** throw error: \"Shell '<name>' is not supported yet. Currently supported: zsh\"\n\n### Requirement: Completion Generation\n\nThe completion command SHALL generate Zsh completion scripts on demand.\n\n#### Scenario: Generating Zsh completion\n\n- **WHEN** user executes `openspec completion zsh`\n- **THEN** output a complete Zsh completion script to stdout\n- **AND** include completions for all commands: init, list, show, validate, archive, view, update, change, spec, completion\n- **AND** include all command-specific flags and options\n- **AND** use Zsh's `_arguments` and `_describe` built-in functions\n- **AND** support dynamic completion for change and spec IDs\n\n### Requirement: Dynamic Completions\n\nThe completion system SHALL provide context-aware dynamic completions for project-specific values.\n\n#### Scenario: Completing change IDs\n\n- **WHEN** completing arguments for commands that accept change names (show, validate, archive)\n- **THEN** discover active changes from `openspec/changes/` directory\n- **AND** exclude archived changes in `openspec/changes/archive/`\n- **AND** return change IDs as completion suggestions\n- **AND** only provide suggestions when inside an OpenSpec-enabled project\n\n#### Scenario: Completing spec IDs\n\n- **WHEN** completing arguments for commands that accept spec names (show, validate)\n- **THEN** discover specs from `openspec/specs/` directory\n- **AND** return spec IDs as completion suggestions\n- **AND** only provide suggestions when inside an OpenSpec-enabled project\n\n#### Scenario: Completion caching\n\n- **WHEN** dynamic completions are requested\n- **THEN** cache discovered change and spec IDs for 2 seconds\n- **AND** reuse cached values for subsequent requests within cache window\n- **AND** automatically refresh cache after expiration\n\n#### Scenario: Project detection\n\n- **WHEN** user requests completions outside an OpenSpec project\n- **THEN** skip dynamic change/spec ID completions\n- **AND** only suggest static commands and flags\n\n### Requirement: Installation Automation\n\nThe completion command SHALL automatically install completion scripts into shell configuration files.\n\n#### Scenario: Installing for Oh My Zsh\n\n- **WHEN** user executes `openspec completion install zsh`\n- **THEN** detect if Oh My Zsh is installed by checking for `$ZSH` environment variable or `~/.oh-my-zsh/` directory\n- **AND** create custom completions directory at `~/.oh-my-zsh/custom/completions/` if it doesn't exist\n- **AND** write completion script to `~/.oh-my-zsh/custom/completions/_openspec`\n- **AND** ensure `~/.oh-my-zsh/custom/completions` is in `$fpath` by updating `~/.zshrc` if needed\n- **AND** display success message with instruction to run `exec zsh` or restart terminal\n\n#### Scenario: Installing for standard Zsh\n\n- **WHEN** user executes `openspec completion install zsh` and Oh My Zsh is not detected\n- **THEN** create completions directory at `~/.zsh/completions/` if it doesn't exist\n- **AND** write completion script to `~/.zsh/completions/_openspec`\n- **AND** add `fpath=(~/.zsh/completions $fpath)` to `~/.zshrc` if not already present\n- **AND** add `autoload -Uz compinit && compinit` to `~/.zshrc` if not already present\n- **AND** display success message with instruction to run `exec zsh` or restart terminal\n\n#### Scenario: Auto-detecting Zsh for installation\n\n- **WHEN** user executes `openspec completion install` without specifying a shell\n- **THEN** detect current shell using shell detection logic\n- **AND** install completion if detected shell is Zsh\n- **AND** throw error if detected shell is not Zsh\n- **AND** display which shell was detected\n\n#### Scenario: Already installed\n\n- **WHEN** completion is already installed for the target shell\n- **THEN** display message indicating completion is already installed\n- **AND** offer to reinstall/update by overwriting existing files\n- **AND** exit with code 0\n\n### Requirement: Uninstallation\n\nThe completion command SHALL remove installed completion scripts and configuration.\n\n#### Scenario: Uninstalling Oh My Zsh completion\n\n- **WHEN** user executes `openspec completion uninstall zsh`\n- **THEN** remove `~/.oh-my-zsh/custom/completions/_openspec` if Oh My Zsh is detected\n- **AND** remove `~/.zsh/completions/_openspec` if standard Zsh setup is detected\n- **AND** optionally remove fpath modifications from `~/.zshrc` (with confirmation)\n- **AND** display success message\n\n#### Scenario: Auto-detecting Zsh for uninstallation\n\n- **WHEN** user executes `openspec completion uninstall` without specifying a shell\n- **THEN** detect current shell and uninstall completion if shell is Zsh\n- **AND** throw error if detected shell is not Zsh\n\n#### Scenario: Not installed\n\n- **WHEN** attempting to uninstall completion that isn't installed\n- **THEN** display message indicating completion is not installed\n- **AND** exit with code 0\n\n### Requirement: Architecture Patterns\n\nThe completion implementation SHALL follow clean architecture principles with TypeScript best practices.\n\n#### Scenario: Shell-specific generators\n\n- **WHEN** implementing completion generators\n- **THEN** create `ZshCompletionGenerator` class for Zsh\n- **AND** implement a common `CompletionGenerator` interface with methods:\n  - `generate(): string` - Returns complete shell script\n  - `getInstallPath(): string` - Returns target installation path\n  - `getConfigFile(): string` - Returns shell configuration file path\n- **AND** design interface to be extensible for future shells (bash, fish, powershell)\n\n#### Scenario: Dynamic completion providers\n\n- **WHEN** implementing dynamic completions\n- **THEN** create a `CompletionProvider` class that encapsulates project discovery logic\n- **AND** implement methods:\n  - `getChangeIds(): Promise<string[]>` - Discovers active change IDs\n  - `getSpecIds(): Promise<string[]>` - Discovers spec IDs\n  - `isOpenSpecProject(): boolean` - Checks if current directory is OpenSpec-enabled\n- **AND** implement caching with 2-second TTL using class properties\n\n#### Scenario: Command registry\n\n- **WHEN** defining completable commands\n- **THEN** create a centralized `CommandDefinition` type with properties:\n  - `name: string` - Command name\n  - `description: string` - Help text\n  - `flags: FlagDefinition[]` - Available flags\n  - `acceptsChangeId: boolean` - Whether command takes change ID argument\n  - `acceptsSpecId: boolean` - Whether command takes spec ID argument\n  - `subcommands?: CommandDefinition[]` - Nested subcommands\n- **AND** export a `COMMAND_REGISTRY` constant with all command definitions\n- **AND** generators consume this registry to ensure consistency\n\n#### Scenario: Type-safe shell detection\n\n- **WHEN** implementing shell detection\n- **THEN** define a `SupportedShell` type as literal type: `'zsh'`\n- **AND** implement `detectShell()` function that returns 'zsh' or throws error\n- **AND** design type to be extensible (e.g., future: `'bash' | 'zsh' | 'fish' | 'powershell'`)\n\n### Requirement: Error Handling\n\nThe completion command SHALL provide clear error messages for common failure scenarios.\n\n#### Scenario: Unsupported shell\n\n- **WHEN** user requests completion for unsupported shell (bash, fish, powershell, etc.)\n- **THEN** display error message: \"Shell '<name>' is not supported yet. Currently supported: zsh\"\n- **AND** exit with code 1\n\n#### Scenario: Permission errors during installation\n\n- **WHEN** installation fails due to file permission issues\n- **THEN** display clear error message indicating permission problem\n- **AND** suggest using appropriate permissions or alternative installation method\n- **AND** exit with code 1\n\n#### Scenario: Missing shell configuration directory\n\n- **WHEN** expected shell configuration directory doesn't exist\n- **THEN** create the directory automatically (with user notification)\n- **AND** proceed with installation\n\n#### Scenario: Shell not detected\n\n- **WHEN** `openspec completion install` cannot detect current shell or detects non-Zsh shell\n- **THEN** display error: \"Could not detect Zsh. Please specify explicitly: openspec completion install zsh\"\n- **AND** exit with code 1\n\n### Requirement: Output Format\n\nThe completion command SHALL provide machine-parseable and human-readable output.\n\n#### Scenario: Script generation output\n\n- **WHEN** generating completion script to stdout\n- **THEN** output only the completion script content (no extra messages)\n- **AND** allow redirection to files: `openspec completion zsh > /path/to/_openspec`\n\n#### Scenario: Installation success output\n\n- **WHEN** installation completes successfully\n- **THEN** display formatted success message with:\n  - Checkmark indicator\n  - Installation location\n  - Next steps (shell reload instructions)\n- **AND** use colors when terminal supports it (unless `--no-color` is set)\n\n#### Scenario: Verbose installation output\n\n- **WHEN** user provides `--verbose` flag during installation\n- **THEN** display detailed steps:\n  - Shell detection result\n  - Target file paths\n  - Configuration modifications\n  - File creation confirmations\n\n### Requirement: Testing Support\n\nThe completion implementation SHALL be testable with unit and integration tests.\n\n#### Scenario: Mock shell environment\n\n- **WHEN** writing tests for shell detection\n- **THEN** allow overriding `$SHELL` environment variable\n- **AND** use dependency injection for file system operations\n\n#### Scenario: Generator output verification\n\n- **WHEN** testing completion generators\n- **THEN** verify generated scripts contain expected patterns\n- **AND** test that command registry is properly consumed\n- **AND** ensure dynamic completion placeholders are present\n\n#### Scenario: Installation simulation\n\n- **WHEN** testing installation logic\n- **THEN** use temporary test directories instead of actual home directories\n- **AND** verify file creation without modifying real shell configurations\n- **AND** test path resolution logic independently\n\n## Not in Scope\n\nThe following shells are **architecturally documented but not implemented** in this proposal. They will be added in future proposals:\n\n- **Bash completion** - Will use bash-completion framework with `_init_completion`, `compgen`, and `COMPREPLY`\n- **Fish completion** - Will use Fish's declarative `complete -c` syntax\n- **PowerShell completion** - Will use `Register-ArgumentCompleter` with completion result objects\n\nThe plugin-based architecture (CompletionGenerator interface, command registry, dynamic providers) is designed to make adding these shells straightforward in follow-up changes.\n\n## Why\n\nShell completions are essential for professional CLI tools and significantly improve developer experience by reducing friction, errors, and cognitive load during daily workflows.\n"
  },
  {
    "path": "openspec/changes/archive/2025-11-06-add-shell-completions/tasks.md",
    "content": "# Implementation Tasks\n\n## Phase 1: Foundation & Architecture\n\n- [x] Create `src/utils/shell-detection.ts` with `SupportedShell` type and `detectShell()` function\n- [x] Create `src/core/completions/types.ts` with interfaces: `CompletionGenerator`, `CommandDefinition`, `FlagDefinition`\n- [x] Create `src/core/completions/command-registry.ts` with `COMMAND_REGISTRY` constant defining all OpenSpec commands, flags, and metadata\n- [x] Create `src/core/completions/completion-provider.ts` with `CompletionProvider` class for dynamic change/spec ID discovery with 2-second caching\n- [x] Write tests for shell detection (`test/utils/shell-detection.test.ts`)\n- [x] Write tests for completion provider (`test/core/completions/completion-provider.test.ts`)\n\n## Phase 2: Zsh Completion (Oh My Zsh Priority)\n\n- [x] Create `src/core/completions/generators/zsh-generator.ts` implementing `CompletionGenerator` interface\n- [x] Implement Zsh script generation using `_arguments` and `_describe` patterns\n- [x] Add dynamic completion logic for change/spec IDs using completion provider\n- [x] Test Zsh generator output (`test/core/completions/generators/zsh-generator.test.ts`)\n- [x] Create `src/core/completions/installers/zsh-installer.ts` with Oh My Zsh and standard Zsh support\n- [x] Implement Oh My Zsh detection (`$ZSH` env var or `~/.oh-my-zsh/` directory)\n- [x] Implement installation to `~/.oh-my-zsh/custom/completions/_openspec` for Oh My Zsh\n- [x] Implement fallback installation to `~/.zsh/completions/_openspec` with `fpath` updates\n- [x] Test Zsh installer logic with mocked file system (`test/core/completions/installers/zsh-installer.test.ts`)\n\n## Phase 3: CLI Command Implementation\n\n- [x] Create `src/commands/completion.ts` with `CompletionCommand` class\n- [x] Register `completion` command in `src/cli/index.ts` with subcommands: generate, install, uninstall\n- [x] Implement `generateSubcommand()` that outputs Zsh script to stdout\n- [x] Implement `installSubcommand(shell?: 'zsh')` with auto-detection for Zsh-only\n- [x] Implement `uninstallSubcommand(shell?: 'zsh')` for removing Zsh completions\n- [x] Add `--verbose` flag support for detailed installation output\n- [x] Add error handling with clear messages: \"Shell '<name>' is not supported yet. Currently supported: zsh\"\n- [x] Test completion command integration (`test/commands/completion.test.ts`)\n\n## Phase 4: Integration & Polish\n\n- [x] Create factory pattern in `src/core/completions/factory.ts` to instantiate Zsh generator/installer (extensible for future shells)\n- [x] Add `completion` command to command registry for self-referential completion\n- [x] Implement dynamic completion helper functions in Zsh generator (`_openspec_complete_changes`, `_openspec_complete_specs`, `_openspec_complete_items`)\n- [x] Add 'shell' positional type for completion command arguments\n- [x] Test completion generation with dynamic helpers\n- [x] Test completion install/uninstall flow\n- [x] Verify all tests pass (97 completion tests, 340 total tests)\n- [x] Implement auto-install via npm postinstall script\n- [x] Add safety checks (CI detection, opt-out flag)\n- [x] Handle Oh My Zsh vs standard Zsh installation paths\n- [x] Add test script for postinstall validation\n- [x] Document auto-install behavior and opt-out in README\n- [ ] Manually test Zsh completion in Oh My Zsh environment (install, test tab completion, uninstall)\n- [ ] Manually test Zsh completion in standard Zsh environment\n- [ ] Test dynamic change/spec ID completion in real OpenSpec projects\n- [ ] Verify completion cache behavior (2-second TTL)\n- [ ] Test behavior outside OpenSpec projects (should skip dynamic completions)\n- [x] Update `openspec --help` output to include completion command (automatically done via Commander)\n\n## Phase 5: Edge Cases & Error Handling\n\n- [ ] Test and handle permission errors during installation\n- [ ] Test and handle missing shell configuration directories (auto-create with notification)\n- [ ] Test \"already installed\" detection and reinstall flow\n- [ ] Test \"not installed\" detection during uninstall\n- [ ] Verify `--no-color` flag is respected in completion command output\n- [ ] Test shell detection failure scenarios with helpful error messages\n- [ ] Ensure graceful handling when `$SHELL` is unset or invalid\n- [ ] Test non-Zsh shells get clear \"not supported yet\" error messages\n- [ ] Test generator output can be redirected to files without corruption\n\n## Dependencies\n\n- Phase 2 depends on Phase 1 (foundation must exist first)\n- Phase 3 depends on Phase 2 (CLI needs Zsh generator working)\n- Phase 4 depends on Phase 3 (integration requires CLI + Zsh implementation)\n- Phase 5 depends on Phase 4 (edge case testing after core functionality works)\n\n## Future Work (Not in This Proposal)\n\n- **Bash completions** - Create bash-generator.ts and bash-installer.ts in follow-up proposal\n- **Fish completions** - Create fish-generator.ts and fish-installer.ts in follow-up proposal\n- **PowerShell completions** - Create powershell-generator.ts and powershell-installer.ts in follow-up proposal\n\nThe architecture is designed to make adding these shells straightforward by implementing the `CompletionGenerator` interface.\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-20-add-global-config-dir/design.md",
    "content": "## Context\n\nOpenSpec needs a standard location for user-level configuration that works across platforms and follows established conventions. This will serve as the foundation for settings, feature flags, and future artifacts like workflows or templates.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Provide a single, well-defined location for global config\n- Follow XDG Base Directory Specification (widely adopted by CLI tools)\n- Support cross-platform usage (Unix, macOS, Windows)\n- Keep implementation minimal - just the foundation\n- Enable future expansion (cache, state, workflows)\n\n**Non-Goals:**\n- Project-local config override (not in scope)\n- Config file migration tooling\n- Config validation CLI commands\n- Multiple config profiles\n\n## Decisions\n\n### Path Resolution Strategy\n\n**Decision:** Use XDG Base Directory Specification with platform fallbacks.\n\n```\nUnix/macOS: $XDG_CONFIG_HOME/openspec/ or ~/.config/openspec/\nWindows:    %APPDATA%/openspec/\n```\n\n**Rationale:**\n- XDG is the de facto standard for CLI tools (used by gh, bat, ripgrep, etc.)\n- Environment variable override allows user customization\n- Windows uses its native convention (%APPDATA%) for better integration\n\n**Alternatives considered:**\n- `~/.openspec/` - Simple but clutters home directory\n- `~/Library/Application Support/` on macOS - Overkill for a CLI tool\n\n### Config File Format\n\n**Decision:** JSON (`config.json`)\n\n**Rationale:**\n- Native Node.js support (no dependencies)\n- Human-readable and editable\n- Type-safe with TypeScript\n- Matches project.md's \"minimal dependencies\" principle\n\n**Alternatives considered:**\n- YAML - Requires dependency, more error-prone to edit\n- TOML - Less common in Node.js ecosystem\n- Environment variables only - Too limited for structured settings\n\n### Config Schema\n\n**Decision:** Flat structure with typed fields, start minimal.\n\n```typescript\ninterface GlobalConfig {\n  featureFlags?: Record<string, boolean>;\n}\n```\n\n**Rationale:**\n- `featureFlags` enables controlled rollout of new features\n- Optional fields with defaults avoid breaking changes\n- Flat structure is easy to understand and extend\n\n### Loading Strategy\n\n**Decision:** Read from disk on each call, no caching.\n\n```typescript\nexport function getGlobalConfig(): GlobalConfig {\n  return loadConfigFromDisk();\n}\n```\n\n**Rationale:**\n- CLI commands are short-lived; caching adds complexity without benefit\n- Reading a small JSON file is ~1ms; negligible overhead\n- Always returns fresh data; no cache invalidation concerns\n- Simpler implementation\n\n### Directory Creation\n\n**Decision:** Create directory only when saving, not when reading.\n\n**Rationale:**\n- Don't create empty directories on read operations\n- Users who never save config won't have unnecessary directories\n- Aligns with principle of least surprise\n\n## Risks / Trade-offs\n\n| Risk | Mitigation |\n|------|------------|\n| Config file corruption | Return defaults on parse error, log warning |\n| Permissions issues | Check write permissions before save, clear error message |\n| Future schema changes | Use optional fields, add version field if needed later |\n\n## Open Questions\n\nNone - this proposal is intentionally minimal.\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-20-add-global-config-dir/proposal.md",
    "content": "## Why\n\nOpenSpec currently has no mechanism for user-level global settings or feature flags. As the CLI grows, we need a standard location to store user preferences, experimental features, and other configuration that persists across projects. Following XDG Base Directory Specification provides a well-understood, cross-platform approach.\n\n## What Changes\n\n- Add new `src/core/global-config.ts` module with:\n  - Path resolution following XDG Base Directory spec (`$XDG_CONFIG_HOME/openspec/` or fallback)\n  - Cross-platform support (Unix, macOS, Windows)\n  - Lazy config loading with sensible defaults\n  - TypeScript types for config shape\n- Export a global config directory path getter for future use (workflows, templates, cache)\n- Initial config schema supports 1-2 settings/feature flags only\n\n## Impact\n\n- Affected specs: New `global-config` capability (no existing specs modified)\n- Affected code:\n  - New `src/core/global-config.ts`\n  - Update `src/core/index.ts` to export new module\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-20-add-global-config-dir/specs/global-config/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Global Config Directory Path\n\nThe system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.\n\n#### Scenario: Unix/macOS with XDG_CONFIG_HOME set\n- **WHEN** `$XDG_CONFIG_HOME` environment variable is set to `/custom/config`\n- **THEN** `getGlobalConfigDir()` returns `/custom/config/openspec`\n\n#### Scenario: Unix/macOS without XDG_CONFIG_HOME\n- **WHEN** `$XDG_CONFIG_HOME` environment variable is not set\n- **AND** the platform is Unix or macOS\n- **THEN** `getGlobalConfigDir()` returns `~/.config/openspec` (expanded to absolute path)\n\n#### Scenario: Windows platform\n- **WHEN** the platform is Windows\n- **AND** `%APPDATA%` is set to `C:\\Users\\User\\AppData\\Roaming`\n- **THEN** `getGlobalConfigDir()` returns `C:\\Users\\User\\AppData\\Roaming\\openspec`\n\n### Requirement: Global Config Loading\n\nThe system SHALL load global configuration from the config directory with sensible defaults when the config file does not exist or cannot be parsed.\n\n#### Scenario: Config file exists and is valid\n- **WHEN** `config.json` exists in the global config directory\n- **AND** the file contains valid JSON matching the config schema\n- **THEN** `getGlobalConfig()` returns the parsed configuration\n\n#### Scenario: Config file does not exist\n- **WHEN** `config.json` does not exist in the global config directory\n- **THEN** `getGlobalConfig()` returns the default configuration\n- **AND** no directory or file is created\n\n#### Scenario: Config file is invalid JSON\n- **WHEN** `config.json` exists but contains invalid JSON\n- **THEN** `getGlobalConfig()` returns the default configuration\n- **AND** a warning is logged to stderr\n\n### Requirement: Global Config Saving\n\nThe system SHALL save global configuration to the config directory, creating the directory if it does not exist.\n\n#### Scenario: Save config to new directory\n- **WHEN** `saveGlobalConfig(config)` is called\n- **AND** the global config directory does not exist\n- **THEN** the directory is created\n- **AND** `config.json` is written with the provided configuration\n\n#### Scenario: Save config to existing directory\n- **WHEN** `saveGlobalConfig(config)` is called\n- **AND** the global config directory already exists\n- **THEN** `config.json` is written (overwriting if exists)\n\n### Requirement: Default Configuration\n\nThe system SHALL provide a default configuration that is used when no config file exists.\n\n#### Scenario: Default config structure\n- **WHEN** no config file exists\n- **THEN** the default configuration includes an empty `featureFlags` object\n\n### Requirement: Config Schema Evolution\n\nThe system SHALL merge loaded configuration with default values to ensure new config fields are available even when loading older config files.\n\n#### Scenario: Config file missing new fields\n- **WHEN** `config.json` exists with `{ \"featureFlags\": {} }`\n- **AND** the current schema includes a new field `defaultAiTool`\n- **THEN** `getGlobalConfig()` returns `{ featureFlags: {}, defaultAiTool: <default> }`\n- **AND** the loaded values take precedence over defaults for fields that exist in both\n\n#### Scenario: Config file has extra unknown fields\n- **WHEN** `config.json` contains fields not in the current schema\n- **THEN** the unknown fields are preserved in the returned configuration\n- **AND** no error or warning is raised\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-20-add-global-config-dir/tasks.md",
    "content": "## 1. Core Implementation\n\n- [x] 1.1 Create `src/core/global-config.ts` with path resolution\n  - Implement `getGlobalConfigDir()` following XDG spec\n  - Support `$XDG_CONFIG_HOME` environment variable override\n  - Platform-specific fallbacks (Unix: `~/.config/`, Windows: `%APPDATA%`)\n- [x] 1.2 Define TypeScript interfaces for config shape\n  - `GlobalConfig` interface with optional fields\n  - Start minimal: just `featureFlags?: Record<string, boolean>`\n- [x] 1.3 Implement config loading with defaults\n  - `getGlobalConfig()` - reads config.json if exists, merges with defaults\n  - No directory/file creation on read (lazy initialization)\n- [x] 1.4 Implement config saving\n  - `saveGlobalConfig(config)` - writes config.json, creates directory if needed\n\n## 2. Integration\n\n- [x] 2.1 Export new module from `src/core/index.ts`\n- [x] 2.2 Add constants for config file name and directory name\n\n## 3. Testing\n\n- [x] 3.1 Manual testing of path resolution on current platform\n- [x] 3.2 Test with/without `$XDG_CONFIG_HOME` set\n- [x] 3.3 Test config load when file doesn't exist (should return defaults)\n- [x] 3.4 Unit tests in `test/core/global-config.test.ts` (18 tests)\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-21-add-config-command/design.md",
    "content": "## Context\n\nThe `global-config` spec defines how OpenSpec reads/writes `config.json`, but users currently must edit it by hand. This command provides a CLI interface to that config.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Provide a discoverable CLI for config management\n- Support scripting with machine-readable output\n- Validate config changes with zod schema\n- Handle nested keys gracefully\n\n**Non-Goals:**\n- Project-local config (reserved for future via `--scope` flag)\n- Complex queries (JSONPath, filtering)\n- Config file format migration\n\n## Decisions\n\n### Key Naming: camelCase with Dot Notation\n\n**Decision:** Keys use camelCase matching the JSON structure, with dot notation for nesting.\n\n**Rationale:**\n- Matches the actual JSON keys (no translation layer)\n- Dot notation is intuitive and widely used (lodash, jq, kubectl)\n- Avoids complexity of supporting multiple casing styles\n\n**Examples:**\n```bash\nopenspec config get featureFlags              # Returns object\nopenspec config get featureFlags.experimental # Returns nested value\nopenspec config set featureFlags.newFlag true\n```\n\n### Type Coercion: Auto-detect with `--string` Override\n\n**Decision:** Parse values automatically; provide `--string` flag to force string storage.\n\n**Rationale:**\n- Most intuitive for common cases (`true`, `false`, `123`)\n- Explicit override for edge cases (storing literal string \"true\")\n- Follows npm/yarn config patterns\n\n**Coercion rules:**\n| Input | Stored As |\n|-------|-----------|\n| `true`, `false` | boolean |\n| Numeric string (`123`, `3.14`) | number |\n| Everything else | string |\n| Any value with `--string` | string |\n\n### Output Format: Raw by Default\n\n**Decision:** `get` prints raw value only. `list` prints YAML-like format by default, JSON with `--json`.\n\n**Rationale:**\n- Raw output enables piping: `VAR=$(openspec config get key)`\n- YAML-like is human-readable for inspection\n- JSON for automation/scripting\n\n### Schema Validation: Zod with Unknown Field Passthrough\n\n**Decision:** Use zod for validation but preserve unknown fields per `global-config` spec.\n\n**Rationale:**\n- Type safety for known fields\n- Forward compatibility (old CLI doesn't break new config)\n- Follows existing `global-config` spec requirement\n\n### Reserved Flag: `--scope`\n\n**Decision:** Reserve `--scope global|project` but only implement `global` initially.\n\n**Rationale:**\n- Avoids breaking change if project-local config is added later\n- Clear error message if someone tries `--scope project`\n\n## Risks / Trade-offs\n\n| Risk | Mitigation |\n|------|------------|\n| Dot notation conflicts with keys containing dots | Rare in practice; document limitation |\n| Type coercion surprises | `--string` escape hatch; document rules |\n| $EDITOR not set | Check and provide helpful error message |\n\n## Open Questions\n\nNone - design is straightforward.\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-21-add-config-command/proposal.md",
    "content": "## Why\n\nUsers need a way to view and modify their global OpenSpec settings without manually editing JSON files. The `global-config` spec provides the foundation, but there's no user-facing interface to interact with the config. A dedicated `openspec config` command provides discoverability and ease of use.\n\n## What Changes\n\nAdd `openspec config` subcommand with the following operations:\n\n```bash\nopenspec config path                          # Show config file location\nopenspec config list [--json]                 # Show all current settings\nopenspec config get <key>                     # Get a specific value (raw, scriptable)\nopenspec config set <key> <value> [--string]  # Set a value (auto-coerce types)\nopenspec config unset <key>                   # Remove a key (revert to default)\nopenspec config reset --all [-y]              # Reset everything to defaults\nopenspec config edit                          # Open config in $EDITOR\n```\n\n**Key design decisions:**\n- **Key naming**: Use camelCase to match JSON structure (e.g., `featureFlags.someFlag`)\n- **Nested keys**: Support dot notation for nested access\n- **Type coercion**: Auto-detect types by default; `--string` flag forces string storage\n- **Scriptable output**: `get` prints raw value only (no labels) for easy piping\n- **Zod validation**: Use zod for config schema validation and type safety\n- **Future-proofing**: Reserve `--scope global|project` flag for potential project-local config\n\n**Example usage:**\n```bash\n$ openspec config path\n/Users/me/.config/openspec/config.json\n\n$ openspec config list\nfeatureFlags: {}\n\n$ openspec config set featureFlags.enableTelemetry false\nSet featureFlags.enableTelemetry = false\n\n$ openspec config get featureFlags.enableTelemetry\nfalse\n\n$ openspec config list --json\n{\n  \"featureFlags\": {}\n}\n\n$ openspec config unset featureFlags.enableTelemetry\nUnset featureFlags.enableTelemetry (reverted to default)\n\n$ openspec config edit\n# Opens $EDITOR with config.json\n```\n\n## Impact\n\n- Affected specs: New `cli-config` capability\n- Affected code:\n  - New `src/commands/config.ts`\n  - New `src/core/config-schema.ts` (zod schema)\n  - Update CLI entry point to register config command\n- Dependencies: Requires `global-config` spec (already implemented)\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-21-add-config-command/specs/cli-config/spec.md",
    "content": "# cli-config Specification\n\n## Purpose\n\nProvide a CLI interface for viewing and modifying global OpenSpec configuration. Enables users to manage settings without manually editing JSON files, with support for scripting and automation.\n\n## ADDED Requirements\n\n### Requirement: Command Structure\n\nThe config command SHALL provide subcommands for all configuration operations.\n\n#### Scenario: Available subcommands\n\n- **WHEN** user executes `openspec config --help`\n- **THEN** display available subcommands:\n  - `path` - Show config file location\n  - `list` - Show all current settings\n  - `get <key>` - Get a specific value\n  - `set <key> <value>` - Set a value\n  - `unset <key>` - Remove a key (revert to default)\n  - `reset` - Reset configuration to defaults\n  - `edit` - Open config in editor\n\n### Requirement: Config Path\n\nThe config command SHALL display the config file location.\n\n#### Scenario: Show config path\n\n- **WHEN** user executes `openspec config path`\n- **THEN** print the absolute path to the config file\n- **AND** exit with code 0\n\n### Requirement: Config List\n\nThe config command SHALL display all current configuration values.\n\n#### Scenario: List config in human-readable format\n\n- **WHEN** user executes `openspec config list`\n- **THEN** display all config values in YAML-like format\n- **AND** show nested objects with indentation\n\n#### Scenario: List config as JSON\n\n- **WHEN** user executes `openspec config list --json`\n- **THEN** output the complete config as valid JSON\n- **AND** output only JSON (no additional text)\n\n### Requirement: Config Get\n\nThe config command SHALL retrieve specific configuration values.\n\n#### Scenario: Get top-level key\n\n- **WHEN** user executes `openspec config get <key>` with a valid top-level key\n- **THEN** print the raw value only (no labels or formatting)\n- **AND** exit with code 0\n\n#### Scenario: Get nested key with dot notation\n\n- **WHEN** user executes `openspec config get featureFlags.someFlag`\n- **THEN** traverse the nested structure using dot notation\n- **AND** print the value at that path\n\n#### Scenario: Get non-existent key\n\n- **WHEN** user executes `openspec config get <key>` with a key that does not exist\n- **THEN** print nothing (empty output)\n- **AND** exit with code 1\n\n#### Scenario: Get object value\n\n- **WHEN** user executes `openspec config get <key>` where the value is an object\n- **THEN** print the object as JSON\n\n### Requirement: Config Set\n\nThe config command SHALL set configuration values with automatic type coercion.\n\n#### Scenario: Set string value\n\n- **WHEN** user executes `openspec config set <key> <value>`\n- **AND** value does not match boolean or number patterns\n- **THEN** store value as a string\n- **AND** display confirmation message\n\n#### Scenario: Set boolean value\n\n- **WHEN** user executes `openspec config set <key> true` or `openspec config set <key> false`\n- **THEN** store value as boolean (not string)\n- **AND** display confirmation message\n\n#### Scenario: Set numeric value\n\n- **WHEN** user executes `openspec config set <key> <value>`\n- **AND** value is a valid number (integer or float)\n- **THEN** store value as number (not string)\n\n#### Scenario: Force string with --string flag\n\n- **WHEN** user executes `openspec config set <key> <value> --string`\n- **THEN** store value as string regardless of content\n- **AND** this allows storing literal \"true\" or \"123\" as strings\n\n#### Scenario: Set nested key\n\n- **WHEN** user executes `openspec config set featureFlags.newFlag true`\n- **THEN** create intermediate objects if they don't exist\n- **AND** set the value at the nested path\n\n### Requirement: Config Unset\n\nThe config command SHALL remove configuration overrides.\n\n#### Scenario: Unset existing key\n\n- **WHEN** user executes `openspec config unset <key>`\n- **AND** the key exists in the config\n- **THEN** remove the key from the config file\n- **AND** the value reverts to its default\n- **AND** display confirmation message\n\n#### Scenario: Unset non-existent key\n\n- **WHEN** user executes `openspec config unset <key>`\n- **AND** the key does not exist in the config\n- **THEN** display message indicating key was not set\n- **AND** exit with code 0\n\n### Requirement: Config Reset\n\nThe config command SHALL reset configuration to defaults.\n\n#### Scenario: Reset all with confirmation\n\n- **WHEN** user executes `openspec config reset --all`\n- **THEN** prompt for confirmation before proceeding\n- **AND** if confirmed, delete the config file or reset to defaults\n- **AND** display confirmation message\n\n#### Scenario: Reset all with -y flag\n\n- **WHEN** user executes `openspec config reset --all -y`\n- **THEN** reset without prompting for confirmation\n\n#### Scenario: Reset without --all flag\n\n- **WHEN** user executes `openspec config reset` without `--all`\n- **THEN** display error indicating `--all` is required\n- **AND** exit with code 1\n\n### Requirement: Config Edit\n\nThe config command SHALL open the config file in the user's editor.\n\n#### Scenario: Open editor successfully\n\n- **WHEN** user executes `openspec config edit`\n- **AND** `$EDITOR` or `$VISUAL` environment variable is set\n- **THEN** open the config file in that editor\n- **AND** create the config file with defaults if it doesn't exist\n- **AND** wait for the editor to close before returning\n\n#### Scenario: No editor configured\n\n- **WHEN** user executes `openspec config edit`\n- **AND** neither `$EDITOR` nor `$VISUAL` is set\n- **THEN** display error message suggesting to set `$EDITOR`\n- **AND** exit with code 1\n\n### Requirement: Key Naming Convention\n\nThe config command SHALL use camelCase keys matching the JSON structure.\n\n#### Scenario: Keys match JSON structure\n\n- **WHEN** accessing configuration keys via CLI\n- **THEN** use camelCase matching the actual JSON property names\n- **AND** support dot notation for nested access (e.g., `featureFlags.someFlag`)\n\n### Requirement: Schema Validation\n\nThe config command SHALL validate configuration writes against the config schema using zod, while allowing unknown fields for forward compatibility.\n\n#### Scenario: Unknown key accepted\n\n- **WHEN** user executes `openspec config set someFutureKey 123`\n- **THEN** the value is saved successfully\n- **AND** exit with code 0\n\n#### Scenario: Invalid feature flag value rejected\n\n- **WHEN** user executes `openspec config set featureFlags.someFlag notABoolean`\n- **THEN** display a descriptive error message\n- **AND** do not modify the config file\n- **AND** exit with code 1\n\n### Requirement: Reserved Scope Flag\n\nThe config command SHALL reserve the `--scope` flag for future extensibility.\n\n#### Scenario: Scope flag defaults to global\n\n- **WHEN** user executes any config command without `--scope`\n- **THEN** operate on global configuration (default behavior)\n\n#### Scenario: Project scope not yet implemented\n\n- **WHEN** user executes `openspec config --scope project <subcommand>`\n- **THEN** display error message: \"Project-local config is not yet implemented\"\n- **AND** exit with code 1\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-21-add-config-command/tasks.md",
    "content": "## 1. Core Infrastructure\n\n- [x] 1.1 Create zod schema for global config in `src/core/config-schema.ts`\n- [x] 1.2 Add utility functions for dot-notation key access (get/set nested values)\n- [x] 1.3 Add type coercion logic (auto-detect boolean/number/string)\n\n## 2. Config Command Implementation\n\n- [x] 2.1 Create `src/commands/config.ts` with Commander.js subcommands\n- [x] 2.2 Implement `config path` subcommand\n- [x] 2.3 Implement `config list` subcommand with `--json` flag\n- [x] 2.4 Implement `config get <key>` subcommand (raw output)\n- [x] 2.5 Implement `config set <key> <value>` with `--string` flag\n- [x] 2.6 Implement `config unset <key>` subcommand\n- [x] 2.7 Implement `config reset --all` with `-y` confirmation flag\n- [x] 2.8 Implement `config edit` subcommand (spawn $EDITOR)\n\n## 3. Integration\n\n- [x] 3.1 Register config command in CLI entry point\n- [x] 3.2 Update shell completion registry to include config subcommands\n\n## 4. Testing\n\n- [x] 4.1 Manual testing of all subcommands\n- [x] 4.2 Verify zod validation rejects invalid keys/values\n- [x] 4.3 Test nested key access with dot notation\n- [x] 4.4 Test type coercion edge cases (true/false, numbers, strings)\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-23-extend-shell-completions/proposal.md",
    "content": "# Change Proposal: Extend Shell Completions\n\n## Why\n\nZsh completions provide an excellent developer experience, but many developers use bash, fish, or PowerShell. Extending completion support to these shells removes friction for the majority of developers who don't use Zsh.\n\n## What Changes\n\nThis change adds bash, fish, and PowerShell completion support following the same architectural patterns, documentation methodology, and testing rigor established for Zsh completions.\n\n## Deltas\n\n- **Spec:** `cli-completion`\n  - **Operation:** MODIFIED\n  - **Description:** Extend completion generation, installation, and testing requirements to support bash, fish, and PowerShell while maintaining the existing Zsh implementation and architectural patterns"
  },
  {
    "path": "openspec/changes/archive/2025-12-23-extend-shell-completions/specs/cli-completion/spec.md",
    "content": "# cli-completion Spec Delta\n\n## MODIFIED Requirements\n\n### Requirement: Native Shell Behavior Integration\n\nThe completion system SHALL respect and integrate with each supported shell's native completion patterns and user interaction model.\n\n#### Scenario: Zsh native completion\n\n- **WHEN** generating Zsh completion scripts\n- **THEN** use Zsh completion system with `_arguments`, `_describe`, and `compadd`\n- **AND** completions SHALL trigger on single TAB (standard Zsh behavior)\n- **AND** display as an interactive menu that users navigate with TAB/arrow keys\n- **AND** support Oh My Zsh's enhanced menu styling automatically\n\n#### Scenario: Bash native completion\n\n- **WHEN** generating Bash completion scripts\n- **THEN** use Bash completion with `complete` builtin and `COMPREPLY` array\n- **AND** completions SHALL trigger on double TAB (standard Bash behavior)\n- **AND** display as space-separated list or column format\n- **AND** support both bash-completion v1 and v2 patterns\n\n#### Scenario: Fish native completion\n\n- **WHEN** generating Fish completion scripts\n- **THEN** use Fish's `complete` command with conditions\n- **AND** completions SHALL trigger on single TAB with auto-suggestion preview\n- **AND** display with Fish's native coloring and description alignment\n- **AND** leverage Fish's built-in caching automatically\n\n#### Scenario: PowerShell native completion\n\n- **WHEN** generating PowerShell completion scripts\n- **THEN** use `Register-ArgumentCompleter` with scriptblock\n- **AND** completions SHALL trigger on TAB with cycling behavior\n- **AND** display with PowerShell's native completion UI\n- **AND** support both Windows PowerShell 5.1 and PowerShell Core 7+\n\n#### Scenario: No custom UX patterns\n\n- **WHEN** implementing completion for any shell\n- **THEN** do NOT attempt to customize completion trigger behavior\n- **AND** do NOT override shell-specific navigation patterns\n- **AND** ensure completions feel native to experienced users of that shell\n\n### Requirement: Shell Detection\n\nThe completion system SHALL automatically detect the user's current shell environment.\n\n#### Scenario: Detecting Zsh from environment\n\n- **WHEN** no shell is explicitly specified\n- **THEN** read the `$SHELL` environment variable\n- **AND** extract the shell name from the path (e.g., `/bin/zsh` → `zsh`)\n- **AND** validate the shell is one of: `zsh`, `bash`, `fish`, `powershell`\n- **AND** throw an error if the shell is not supported\n\n#### Scenario: Detecting Bash from environment\n\n- **WHEN** `$SHELL` contains `bash` in the path\n- **THEN** detect shell as `bash`\n- **AND** proceed with bash-specific completion logic\n\n#### Scenario: Detecting Fish from environment\n\n- **WHEN** `$SHELL` contains `fish` in the path\n- **THEN** detect shell as `fish`\n- **AND** proceed with fish-specific completion logic\n\n#### Scenario: Detecting PowerShell from environment\n\n- **WHEN** `$PSModulePath` environment variable is present\n- **THEN** detect shell as `powershell`\n- **AND** proceed with PowerShell-specific completion logic\n\n#### Scenario: Unsupported shell detection\n\n- **WHEN** shell path indicates an unsupported shell\n- **THEN** throw error: \"Shell '<name>' is not supported. Supported shells: zsh, bash, fish, powershell\"\n\n### Requirement: Completion Generation\n\nThe completion command SHALL generate completion scripts for all supported shells on demand.\n\n#### Scenario: Generating Zsh completion\n\n- **WHEN** user executes `openspec completion generate zsh`\n- **THEN** output a complete Zsh completion script to stdout\n- **AND** include completions for all commands: init, list, show, validate, archive, view, update, change, spec, completion\n- **AND** include all command-specific flags and options\n- **AND** use Zsh's `_arguments` and `_describe` built-in functions\n- **AND** support dynamic completion for change and spec IDs\n\n#### Scenario: Generating Bash completion\n\n- **WHEN** user executes `openspec completion generate bash`\n- **THEN** output a complete Bash completion script to stdout\n- **AND** include completions for all commands and subcommands\n- **AND** use `complete -F` with custom completion function\n- **AND** populate `COMPREPLY` with appropriate suggestions\n- **AND** support dynamic completion for change and spec IDs via `openspec __complete`\n\n#### Scenario: Generating Fish completion\n\n- **WHEN** user executes `openspec completion generate fish`\n- **THEN** output a complete Fish completion script to stdout\n- **AND** use `complete -c openspec` with conditions\n- **AND** include command-specific completions with `--condition` predicates\n- **AND** support dynamic completion for change and spec IDs via `openspec __complete`\n- **AND** include descriptions for each completion option\n\n#### Scenario: Generating PowerShell completion\n\n- **WHEN** user executes `openspec completion generate powershell`\n- **THEN** output a complete PowerShell completion script to stdout\n- **AND** use `Register-ArgumentCompleter -CommandName openspec`\n- **AND** implement scriptblock that handles command context\n- **AND** support dynamic completion for change and spec IDs via `openspec __complete`\n- **AND** return `[System.Management.Automation.CompletionResult]` objects\n\n### Requirement: Installation Automation\n\nThe completion command SHALL automatically install completion scripts into shell configuration files for all supported shells.\n\n#### Scenario: Installing for Oh My Zsh\n\n- **WHEN** user executes `openspec completion install zsh`\n- **THEN** detect if Oh My Zsh is installed by checking for `$ZSH` environment variable or `~/.oh-my-zsh/` directory\n- **AND** create custom completions directory at `~/.oh-my-zsh/custom/completions/` if it doesn't exist\n- **AND** write completion script to `~/.oh-my-zsh/custom/completions/_openspec`\n- **AND** ensure `~/.oh-my-zsh/custom/completions` is in `$fpath` by updating `~/.zshrc` if needed\n- **AND** display success message with instruction to run `exec zsh` or restart terminal\n\n#### Scenario: Installing for standard Zsh\n\n- **WHEN** user executes `openspec completion install zsh` and Oh My Zsh is not detected\n- **THEN** create completions directory at `~/.zsh/completions/` if it doesn't exist\n- **AND** write completion script to `~/.zsh/completions/_openspec`\n- **AND** add `fpath=(~/.zsh/completions $fpath)` to `~/.zshrc` if not already present\n- **AND** add `autoload -Uz compinit && compinit` to `~/.zshrc` if not already present\n- **AND** display success message with instruction to run `exec zsh` or restart terminal\n\n#### Scenario: Installing for Bash with bash-completion\n\n- **WHEN** user executes `openspec completion install bash`\n- **THEN** detect if bash-completion is installed by checking for `/usr/share/bash-completion` or `/etc/bash_completion.d`\n- **AND** if bash-completion is available, write to `/etc/bash_completion.d/openspec` (with sudo) or `~/.local/share/bash-completion/completions/openspec`\n- **AND** if bash-completion is not available, write to `~/.bash_completion.d/openspec` and source it from `~/.bashrc`\n- **AND** add sourcing line to `~/.bashrc` using marker-based updates if needed\n- **AND** display success message with instruction to run `exec bash` or restart terminal\n\n#### Scenario: Installing for Fish\n\n- **WHEN** user executes `openspec completion install fish`\n- **THEN** create Fish completions directory at `~/.config/fish/completions/` if it doesn't exist\n- **AND** write completion script to `~/.config/fish/completions/openspec.fish`\n- **AND** Fish automatically loads completions from this directory (no config file modification needed)\n- **AND** display success message indicating completions are immediately available\n\n#### Scenario: Installing for PowerShell\n\n- **WHEN** user executes `openspec completion install powershell`\n- **THEN** detect PowerShell profile location via `$PROFILE` environment variable or default paths\n- **AND** create profile directory if it doesn't exist\n- **AND** add completion script import to profile using marker-based updates\n- **AND** write completion script to PowerShell modules directory or alongside profile\n- **AND** display success message with instruction to restart PowerShell or run `. $PROFILE`\n\n#### Scenario: Auto-detecting shell for installation\n\n- **WHEN** user executes `openspec completion install` without specifying a shell\n- **THEN** detect current shell using shell detection logic\n- **AND** install completion for the detected shell (zsh, bash, fish, or powershell)\n- **AND** display which shell was detected\n\n#### Scenario: Already installed\n\n- **WHEN** completion is already installed for the target shell\n- **THEN** display message indicating completion is already installed\n- **AND** offer to reinstall/update by overwriting existing files\n- **AND** exit with code 0\n\n### Requirement: Uninstallation\n\nThe completion command SHALL remove installed completion scripts and configuration for all supported shells.\n\n#### Scenario: Uninstalling Zsh completion\n\n- **WHEN** user executes `openspec completion uninstall zsh`\n- **THEN** prompt for confirmation before proceeding (unless `--yes` flag provided)\n- **AND** if user declines, cancel uninstall and display \"Uninstall cancelled.\"\n- **AND** if user confirms, remove `~/.oh-my-zsh/custom/completions/_openspec` if Oh My Zsh is detected\n- **AND** remove `~/.zsh/completions/_openspec` if standard Zsh setup is detected\n- **AND** remove fpath modifications from `~/.zshrc` using marker-based removal\n- **AND** display success message\n\n#### Scenario: Uninstalling Bash completion\n\n- **WHEN** user executes `openspec completion uninstall bash`\n- **THEN** prompt for confirmation (unless `--yes` flag provided)\n- **AND** if user confirms, remove completion file from bash-completion directory or `~/.bash_completion.d/`\n- **AND** remove sourcing lines from `~/.bashrc` using marker-based removal\n- **AND** display success message\n\n#### Scenario: Uninstalling Fish completion\n\n- **WHEN** user executes `openspec completion uninstall fish`\n- **THEN** prompt for confirmation (unless `--yes` flag provided)\n- **AND** if user confirms, remove `~/.config/fish/completions/openspec.fish`\n- **AND** display success message (no config file modification needed)\n\n#### Scenario: Uninstalling PowerShell completion\n\n- **WHEN** user executes `openspec completion uninstall powershell`\n- **THEN** prompt for confirmation (unless `--yes` flag provided)\n- **AND** if user confirms, remove completion import from PowerShell profile using marker-based removal\n- **AND** remove completion script file\n- **AND** display success message\n\n#### Scenario: Auto-detecting shell for uninstallation\n\n- **WHEN** user executes `openspec completion uninstall` without specifying a shell\n- **THEN** detect current shell and uninstall completion for that shell\n\n#### Scenario: Not installed\n\n- **WHEN** attempting to uninstall completion that isn't installed\n- **THEN** display error message indicating completion is not installed\n- **AND** exit with code 1\n\n### Requirement: Architecture Patterns\n\nThe completion implementation SHALL follow clean architecture principles with TypeScript best practices, supporting multiple shells through a plugin-based pattern.\n\n#### Scenario: Shell-specific generators\n\n- **WHEN** implementing completion generators\n- **THEN** create generator classes for each shell: `ZshGenerator`, `BashGenerator`, `FishGenerator`, `PowerShellGenerator`\n- **AND** implement a common `CompletionGenerator` interface with method:\n  - `generate(commands: CommandDefinition[]): string` - Returns complete shell script\n- **AND** each generator handles shell-specific syntax, escaping, and patterns\n- **AND** all generators consume the same `CommandDefinition[]` from the command registry\n\n#### Scenario: Shell-specific installers\n\n- **WHEN** implementing completion installers\n- **THEN** create installer classes for each shell: `ZshInstaller`, `BashInstaller`, `FishInstaller`, `PowerShellInstaller`\n- **AND** implement a common `CompletionInstaller` interface with methods:\n  - `install(script: string): Promise<InstallationResult>` - Installs completion script\n  - `uninstall(): Promise<{ success: boolean; message: string }>` - Removes completion\n- **AND** each installer handles shell-specific paths, config files, and installation patterns\n\n#### Scenario: Factory pattern for shell selection\n\n- **WHEN** selecting shell-specific implementation\n- **THEN** use `CompletionFactory` class with static methods:\n  - `createGenerator(shell: SupportedShell): CompletionGenerator`\n  - `createInstaller(shell: SupportedShell): CompletionInstaller`\n- **AND** factory uses switch statements with TypeScript exhaustiveness checking\n- **AND** adding new shell requires updating `SupportedShell` type and factory cases\n\n#### Scenario: Dynamic completion providers\n\n- **WHEN** implementing dynamic completions\n- **THEN** create a `CompletionProvider` class that encapsulates project discovery logic\n- **AND** implement methods:\n  - `getChangeIds(): Promise<string[]>` - Discovers active change IDs\n  - `getSpecIds(): Promise<string[]>` - Discovers spec IDs\n  - `isOpenSpecProject(): boolean` - Checks if current directory is OpenSpec-enabled\n- **AND** implement caching with 2-second TTL using class properties\n\n#### Scenario: Command registry\n\n- **WHEN** defining completable commands\n- **THEN** create a centralized `CommandDefinition` type with properties:\n  - `name: string` - Command name\n  - `description: string` - Help text\n  - `flags: FlagDefinition[]` - Available flags\n  - `acceptsPositional: boolean` - Whether command takes positional arguments\n  - `positionalType: string` - Type of positional (change-id, spec-id, path, shell)\n  - `subcommands?: CommandDefinition[]` - Nested subcommands\n- **AND** export a `COMMAND_REGISTRY` constant with all command definitions\n- **AND** all generators consume this registry to ensure consistency across shells\n\n#### Scenario: Type-safe shell detection\n\n- **WHEN** implementing shell detection\n- **THEN** define a `SupportedShell` type as literal type: `'zsh' | 'bash' | 'fish' | 'powershell'`\n- **AND** implement `detectShell()` function in `src/utils/shell-detection.ts`\n- **AND** return detected shell or throw error with supported shells list\n\n### Requirement: Testing Support\n\nThe completion implementation SHALL be testable with unit and integration tests for all supported shells.\n\n#### Scenario: Mock shell environment\n\n- **WHEN** writing tests for shell detection\n- **THEN** allow overriding `$SHELL` and `$PSModulePath` environment variables\n- **AND** use dependency injection for file system operations\n- **AND** test detection for all four shells independently\n\n#### Scenario: Generator output verification\n\n- **WHEN** testing completion generators\n- **THEN** create test suite for each shell generator (zsh, bash, fish, powershell)\n- **AND** verify generated scripts contain expected patterns for that shell\n- **AND** test that command registry is properly consumed\n- **AND** ensure dynamic completion placeholders are present\n- **AND** verify shell-specific syntax and escaping\n\n#### Scenario: Installer simulation\n\n- **WHEN** testing installation logic\n- **THEN** create test suite for each shell installer\n- **AND** use temporary test directories instead of actual home directories\n- **AND** verify file creation without modifying real shell configurations\n- **AND** test path resolution logic independently\n- **AND** mock file system operations to avoid side effects\n\n#### Scenario: Cross-shell consistency\n\n- **WHEN** testing completion behavior\n- **THEN** verify all shells support the same commands and flags\n- **AND** verify dynamic completions work consistently across shells\n- **AND** ensure error messages are consistent across shells"
  },
  {
    "path": "openspec/changes/archive/2025-12-23-extend-shell-completions/tasks.md",
    "content": "# Implementation Tasks\n\n## Phase 1: Foundation and Bash Support\n\n- [x] Update `SupportedShell` type in `src/utils/shell-detection.ts` to include `'bash' | 'fish' | 'powershell'`\n- [x] Extend shell detection logic to recognize bash, fish, and PowerShell from environment variables\n- [x] Create `src/core/completions/generators/bash-generator.ts` implementing `CompletionGenerator` interface\n- [x] Create `src/core/completions/installers/bash-installer.ts` implementing `CompletionInstaller` interface\n- [x] Update `CompletionFactory.createGenerator()` to support bash\n- [x] Update `CompletionFactory.createInstaller()` to support bash\n- [x] Create test file `test/core/completions/generators/bash-generator.test.ts` mirroring zsh test structure\n- [x] Create test file `test/core/completions/installers/bash-installer.test.ts` mirroring zsh test structure\n- [x] Verify bash completions work manually: `openspec completion install bash && exec bash`\n\n## Phase 2: Fish Support\n\n- [x] Create `src/core/completions/generators/fish-generator.ts` implementing `CompletionGenerator` interface\n- [x] Create `src/core/completions/installers/fish-installer.ts` implementing `CompletionInstaller` interface\n- [x] Update `CompletionFactory.createGenerator()` to support fish\n- [x] Update `CompletionFactory.createInstaller()` to support fish\n- [x] Create test file `test/core/completions/generators/fish-generator.test.ts`\n- [x] Create test file `test/core/completions/installers/fish-installer.test.ts`\n- [x] Verify fish completions work manually: `openspec completion install fish`\n\n## Phase 3: PowerShell Support\n\n- [x] Create `src/core/completions/generators/powershell-generator.ts` implementing `CompletionGenerator` interface\n- [x] Create `src/core/completions/installers/powershell-installer.ts` implementing `CompletionInstaller` interface\n- [x] Update `CompletionFactory.createGenerator()` to support powershell\n- [x] Update `CompletionFactory.createInstaller()` to support powershell\n- [x] Create test file `test/core/completions/generators/powershell-generator.test.ts`\n- [x] Create test file `test/core/completions/installers/powershell-installer.test.ts`\n- [x] Verify PowerShell completions work manually on Windows or macOS PowerShell\n\n## Phase 4: Documentation and Testing\n\n- [x] Update `CLAUDE.md` or relevant documentation to mention all four supported shells\n- [x] Add cross-shell consistency test verifying all shells support same commands\n- [x] Run `pnpm test` to ensure all tests pass\n- [x] Run `pnpm run build` to verify TypeScript compilation\n- [x] Test all shells on different platforms (Linux for bash/fish/zsh, Windows/macOS for PowerShell)\n\n## Phase 5: Validation and Cleanup\n\n- [x] Run `openspec validate extend-shell-completions --strict` and resolve all issues\n- [x] Update error messages to list all four supported shells\n- [x] Verify `openspec completion --help` documentation is current\n- [x] Test auto-detection works for all shells\n- [x] Ensure uninstall works cleanly for all shells"
  },
  {
    "path": "openspec/changes/archive/2025-12-24-add-artifact-graph-core/design.md",
    "content": "## Context\n\nThis implements \"Slice 1: What's Ready?\" from the artifact POC analysis. The core insight is using the filesystem as a database - artifact completion is detected by file existence, making the system stateless and version-control friendly.\n\nThis module will coexist with the current OpenSpec system as a parallel capability, potentially enabling future migration or integration.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Pure dependency graph logic with no side effects\n- Stateless state detection (rescan filesystem each query)\n- Support glob patterns for multi-file artifacts (e.g., `specs/*.md`)\n- Load artifact definitions from YAML schemas\n- Calculate topological build order\n- Determine \"ready\" artifacts based on dependency completion\n\n**Non-Goals:**\n- CLI commands (Slice 4)\n- Multi-change management (Slice 2)\n- Template resolution and enrichment (Slice 3)\n- Agent integration or Claude commands\n- Replacing existing OpenSpec functionality\n\n## Decisions\n\n### Decision: Filesystem as Database\nUse file existence for state detection rather than a separate state file.\n\n**Rationale:**\n- Stateless - no state corruption possible\n- Git-friendly - state derived from committed files\n- Simple - no sync issues between state file and actual files\n\n**Alternatives considered:**\n- JSON/SQLite state file: More complex, sync issues, not git-friendly\n- Git metadata: Too coupled to git, complex implementation\n\n### Decision: Kahn's Algorithm for Topological Sort\nUse Kahn's algorithm for computing build order.\n\n**Rationale:**\n- Well-understood, O(V+E) complexity\n- Naturally detects cycles during execution\n- Produces a stable, deterministic order\n\n### Decision: Glob Pattern Support\nSupport glob patterns like `specs/*.md` in artifact `generates` field.\n\n**Rationale:**\n- Allows multiple files to satisfy a single artifact requirement\n- Common pattern for spec directories with multiple files\n- Uses standard glob syntax\n\n### Decision: Immutable Completed Set\nRepresent completion state as an immutable Set of completed artifact IDs.\n\n**Rationale:**\n- Functional style, easier to reason about\n- State derived fresh each query, no mutation needed\n- Clear separation between graph structure and runtime state\n- Filesystem can only detect binary existence (complete vs not complete)\n\n**Note:** `inProgress` and `failed` states are deferred to future slices. They would require external state tracking (e.g., a status file) since file existence alone cannot distinguish these states.\n\n### Decision: Zod for Schema Validation\nUse Zod for validating YAML schema structure and deriving TypeScript types.\n\n**Rationale:**\n- Already a project dependency (v4.0.17) used in `src/core/schemas/`\n- Type inference via `z.infer<>` - single source of truth for types\n- Runtime validation with detailed error messages\n- Consistent with existing project patterns (`base.schema.ts`, `config-schema.ts`)\n\n**Alternatives considered:**\n- Manual validation: More code, error-prone, no type inference\n- JSON Schema: Would require additional dependency, less TypeScript integration\n- io-ts: Not already in project, steeper learning curve\n\n### Decision: Two-Level Schema Resolution\nSchemas resolve from global user data directory, falling back to package built-ins.\n\n**Resolution order:**\n1. `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml` - Global user override\n2. `<package>/schemas/<name>.yaml` - Built-in defaults\n\n**Rationale:**\n- Follows XDG Base Directory Specification (schemas are data, not config)\n- Mirrors existing `getGlobalConfigDir()` pattern in `src/core/global-paths.ts`\n- Built-ins baked into package, never auto-copied\n- Users customize by creating files in global data dir\n- Simple - no project-level overrides (can add later if needed)\n\n**XDG compliance:**\n- Uses `XDG_DATA_HOME` env var when set (all platforms)\n- Unix/macOS fallback: `~/.local/share/openspec/`\n- Windows fallback: `%LOCALAPPDATA%/openspec/`\n\n**Alternatives considered:**\n- Project-level overrides: Added complexity, not needed initially\n- Auto-copy to user space: Creates drift, harder to update defaults\n- Config directory (`XDG_CONFIG_HOME`): Schemas are workflow definitions (data), not user preferences (config)\n\n### Decision: Template Field Parsed But Not Resolved\nThe `template` field is required in schema YAML for completeness, but template resolution is deferred to Slice 3.\n\n**Rationale:**\n- Slice 1 focuses on \"What's Ready?\" - dependency and completion queries only\n- Template paths are validated syntactically (non-empty string) but not resolved\n- Keeps Slice 1 focused and independently testable\n\n### Decision: Cycle Error Format\nCycle errors list all artifact IDs in the cycle for easy debugging.\n\n**Format:** `\"Cyclic dependency detected: A → B → C → A\"`\n\n**Rationale:**\n- Shows the full cycle path, not just that a cycle exists\n- Actionable - developer can see exactly which artifacts to fix\n- Consistent with Kahn's algorithm which naturally identifies cycle participants\n\n## Data Structures\n\n**Zod Schemas (source of truth):**\n\n```typescript\nimport { z } from 'zod';\n\n// Artifact definition schema\nexport const ArtifactSchema = z.object({\n  id: z.string().min(1, 'Artifact ID is required'),\n  generates: z.string().min(1),      // e.g., \"proposal.md\" or \"specs/*.md\"\n  description: z.string(),\n  template: z.string(),              // path to template file\n  requires: z.array(z.string()).default([]),\n});\n\n// Full schema YAML structure\nexport const SchemaYamlSchema = z.object({\n  name: z.string().min(1, 'Schema name is required'),\n  version: z.number().int().positive(),\n  description: z.string().optional(),\n  artifacts: z.array(ArtifactSchema).min(1, 'At least one artifact required'),\n});\n\n// Derived TypeScript types\nexport type Artifact = z.infer<typeof ArtifactSchema>;\nexport type SchemaYaml = z.infer<typeof SchemaYamlSchema>;\n```\n\n**Runtime State (not Zod - internal only):**\n\n```typescript\n// Slice 1: Simple completion tracking via filesystem\ntype CompletedSet = Set<string>;\n\n// Return type for blocked query\ninterface BlockedArtifacts {\n  [artifactId: string]: string[];  // artifact → list of unmet dependencies\n}\n\ninterface ArtifactGraphResult {\n  completed: string[];\n  ready: string[];\n  blocked: BlockedArtifacts;\n  buildOrder: string[];\n}\n```\n\n## File Structure\n\n```\nsrc/core/artifact-graph/\n├── index.ts           # Public exports\n├── types.ts           # Zod schemas and type definitions\n├── graph.ts           # ArtifactGraph class\n├── state.ts           # State detection logic\n├── resolver.ts        # Schema resolution (global → built-in)\n└── schemas/           # Built-in schema definitions (package level)\n    ├── spec-driven.yaml   # Default: proposal → specs → design → tasks\n    └── tdd.yaml           # Alternative: tests → implementation → docs\n```\n\n**Schema Resolution Paths:**\n- Global user override: `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml`\n- Package built-in: `src/core/artifact-graph/schemas/<name>.yaml` (bundled with package)\n\n## Risks / Trade-offs\n\n| Risk | Mitigation |\n|------|------------|\n| Glob pattern edge cases | Use well-tested glob library (fast-glob or similar) |\n| Cycle detection | Kahn's algorithm naturally fails on cycles; provide clear error |\n| Schema evolution | Version field in schema, validate on load |\n\n## Open Questions\n\nNone - all questions resolved in Decisions section.\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-24-add-artifact-graph-core/proposal.md",
    "content": "## Why\n\nThe current OpenSpec system relies on conventions and AI inference for artifact ordering. A formal artifact graph with dependency awareness would enable deterministic \"what's ready?\" queries, making the system more predictable and enabling future features like automated pipeline execution.\n\n## What Changes\n\n- Add `ArtifactGraph` class to model artifacts as a DAG with dependency relationships\n- Add `ArtifactState` type to track completion status (completed, in_progress, failed)\n- Add filesystem-based state detection using file existence and glob patterns\n- Add schema YAML parser to load artifact definitions\n- Implement topological sort (Kahn's algorithm) for build order calculation\n- Add `getNextArtifacts()` to find artifacts ready for creation\n\n## Impact\n\n- Affected specs: New `artifact-graph` capability\n- Affected code: `src/core/artifact-graph/` (new directory)\n- No changes to existing functionality - this is a parallel module\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-24-add-artifact-graph-core/specs/artifact-graph/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Schema Loading\nThe system SHALL load artifact graph definitions from YAML schema files.\n\n#### Scenario: Valid schema loaded\n- **WHEN** a valid schema YAML file is provided\n- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies\n\n#### Scenario: Invalid schema rejected\n- **WHEN** a schema YAML file is missing required fields\n- **THEN** the system throws an error with a descriptive message\n\n#### Scenario: Cyclic dependencies detected\n- **WHEN** a schema contains cyclic artifact dependencies\n- **THEN** the system throws an error listing the artifact IDs in the cycle\n\n#### Scenario: Invalid dependency reference\n- **WHEN** an artifact's `requires` array references a non-existent artifact ID\n- **THEN** the system throws an error identifying the invalid reference\n\n#### Scenario: Duplicate artifact IDs rejected\n- **WHEN** a schema contains multiple artifacts with the same ID\n- **THEN** the system throws an error identifying the duplicate\n\n### Requirement: Build Order Calculation\nThe system SHALL compute a valid topological build order for artifacts.\n\n#### Scenario: Linear dependency chain\n- **WHEN** artifacts form a linear chain (A → B → C)\n- **THEN** getBuildOrder() returns [A, B, C]\n\n#### Scenario: Diamond dependency\n- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)\n- **THEN** getBuildOrder() returns A before B and C, and D last\n\n#### Scenario: Independent artifacts\n- **WHEN** artifacts have no dependencies\n- **THEN** getBuildOrder() returns them in a stable order\n\n### Requirement: State Detection\nThe system SHALL detect artifact completion state by scanning the filesystem.\n\n#### Scenario: Simple file exists\n- **WHEN** an artifact generates \"proposal.md\" and the file exists\n- **THEN** the artifact is marked as completed\n\n#### Scenario: Simple file missing\n- **WHEN** an artifact generates \"proposal.md\" and the file does not exist\n- **THEN** the artifact is not marked as completed\n\n#### Scenario: Glob pattern with files\n- **WHEN** an artifact generates \"specs/*.md\" and the specs/ directory contains .md files\n- **THEN** the artifact is marked as completed\n\n#### Scenario: Glob pattern empty\n- **WHEN** an artifact generates \"specs/*.md\" and the specs/ directory is empty or missing\n- **THEN** the artifact is not marked as completed\n\n#### Scenario: Missing change directory\n- **WHEN** the change directory does not exist\n- **THEN** all artifacts are marked as not completed (empty state)\n\n### Requirement: Ready Artifact Query\nThe system SHALL identify which artifacts are ready to be created based on dependency completion.\n\n#### Scenario: Root artifacts ready initially\n- **WHEN** no artifacts are completed\n- **THEN** getNextArtifacts() returns artifacts with no dependencies\n\n#### Scenario: Dependent artifact becomes ready\n- **WHEN** an artifact's dependencies are all completed\n- **THEN** getNextArtifacts() includes that artifact\n\n#### Scenario: Blocked artifacts excluded\n- **WHEN** an artifact has uncompleted dependencies\n- **THEN** getNextArtifacts() does not include that artifact\n\n### Requirement: Completion Check\nThe system SHALL determine when all artifacts in a graph are complete.\n\n#### Scenario: All complete\n- **WHEN** all artifacts in the graph are in the completed set\n- **THEN** isComplete() returns true\n\n#### Scenario: Partially complete\n- **WHEN** some artifacts in the graph are not completed\n- **THEN** isComplete() returns false\n\n### Requirement: Blocked Query\nThe system SHALL identify which artifacts are blocked and return all their unmet dependencies.\n\n#### Scenario: Artifact blocked by single dependency\n- **WHEN** artifact B requires artifact A and A is not complete\n- **THEN** getBlocked() returns `{ B: ['A'] }`\n\n#### Scenario: Artifact blocked by multiple dependencies\n- **WHEN** artifact C requires A and B, and only A is complete\n- **THEN** getBlocked() returns `{ C: ['B'] }`\n\n#### Scenario: Artifact blocked by all dependencies\n- **WHEN** artifact C requires A and B, and neither is complete\n- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-24-add-artifact-graph-core/tasks.md",
    "content": "## 1. Type Definitions\n- [x] 1.1 Create `src/core/artifact-graph/types.ts` with Zod schemas (`ArtifactSchema`, `SchemaYamlSchema`) and inferred types via `z.infer<>`\n- [x] 1.2 Define `CompletedSet` (Set<string>), `BlockedArtifacts`, and `ArtifactGraphResult` types for runtime state\n\n## 2. Schema Parser\n- [x] 2.1 Create `src/core/artifact-graph/schema.ts` with YAML loading and Zod validation via `.safeParse()`\n- [x] 2.2 Implement dependency reference validation (ensure `requires` references valid artifact IDs)\n- [x] 2.3 Implement duplicate artifact ID detection\n- [x] 2.4 Add cycle detection during schema load (error format: \"Cyclic dependency detected: A → B → C → A\")\n\n## 3. Artifact Graph Core\n- [x] 3.1 Create `src/core/artifact-graph/graph.ts` with ArtifactGraph class\n- [x] 3.2 Implement `fromYaml(path)` - load graph from schema file\n- [x] 3.3 Implement `getBuildOrder()` - topological sort via Kahn's algorithm\n- [x] 3.4 Implement `getArtifact(id)` - retrieve single artifact definition\n- [x] 3.5 Implement `getAllArtifacts()` - list all artifacts\n\n## 4. State Detection\n- [x] 4.1 Create `src/core/artifact-graph/state.ts` with state detection logic\n- [x] 4.2 Implement file existence checking for simple paths\n- [x] 4.3 Implement glob pattern matching for multi-file artifacts\n- [x] 4.4 Implement `detectCompleted(graph, changeDir)` - scan filesystem and return CompletedSet\n- [x] 4.5 Handle missing changeDir gracefully (return empty CompletedSet)\n\n## 5. Ready Calculation\n- [x] 5.1 Implement `getNextArtifacts(graph, completed)` - find artifacts with all deps completed\n- [x] 5.2 Implement `isComplete(graph, completed)` - check if all artifacts done\n- [x] 5.3 Implement `getBlocked(graph, completed)` - return BlockedArtifacts map (artifact → unmet deps)\n\n## 6. Schema Resolution\n- [x] 6.1 Create `src/core/artifact-graph/resolver.ts` with schema resolution logic\n- [x] 6.2 Add `getGlobalDataDir()` to `src/core/global-config.ts` (XDG_DATA_HOME with platform fallbacks)\n- [x] 6.3 Implement `resolveSchema(name)` - global (`${XDG_DATA_HOME}/openspec/schemas/`) → built-in fallback\n\n## 7. Built-in Schemas\n- [x] 7.1 Create `src/core/artifact-graph/schemas/spec-driven.yaml` (default: proposal → specs → design → tasks)\n- [x] 7.2 Create `src/core/artifact-graph/schemas/tdd.yaml` (alternative: tests → implementation → docs)\n\n## 8. Integration\n- [x] 8.1 Create `src/core/artifact-graph/index.ts` with public exports\n\n## 9. Testing\n- [x] 9.1 Test: Parse valid schema YAML returns correct artifact graph\n- [x] 9.2 Test: Parse invalid schema (missing fields) throws descriptive error\n- [x] 9.3 Test: Duplicate artifact IDs throws error\n- [x] 9.4 Test: Invalid `requires` reference throws error identifying the invalid ID\n- [x] 9.5 Test: Cycle in schema throws error listing cycle path (e.g., \"A → B → C → A\")\n- [x] 9.6 Test: Compute build order returns correct topological ordering (linear chain)\n- [x] 9.7 Test: Compute build order handles diamond dependencies correctly\n- [x] 9.8 Test: Independent artifacts return in stable order\n- [x] 9.9 Test: Empty/missing changeDir returns empty CompletedSet\n- [x] 9.10 Test: File existence marks artifact as completed\n- [x] 9.11 Test: Glob pattern specs/*.md detected as complete when files exist\n- [x] 9.12 Test: Glob pattern with empty directory not marked complete\n- [x] 9.13 Test: getNextArtifacts returns only root artifacts when nothing completed\n- [x] 9.14 Test: getNextArtifacts includes artifact when all deps completed\n- [x] 9.15 Test: getBlocked returns artifact with all unmet dependencies listed\n- [x] 9.16 Test: isComplete() returns true when all artifacts completed\n- [x] 9.17 Test: isComplete() returns false when some artifacts incomplete\n- [x] 9.18 Test: Schema resolution finds global override before built-in\n- [x] 9.19 Test: Schema resolution falls back to built-in when no global\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-25-add-change-manager/design.md",
    "content": "## Context\n\nThis is Slice 2 of the artifact tracker POC. The goal is to provide utilities for creating change directories programmatically.\n\n**Current state:** No programmatic way to create changes. Users must manually create directories.\n\n**Proposed state:** Utility functions for change creation with name validation.\n\n## Goals / Non-Goals\n\n### Goals\n- **Add** `createChange()` function to create change directories\n- **Add** `validateChangeName()` function for kebab-case validation\n- **Enable** automation (Claude commands, scripts) to create changes\n\n### Non-Goals\n- Refactor existing CLI commands (they work fine)\n- Create abstraction layers or manager classes\n- Change how `ListCommand` or `ChangeCommand` work\n\n## Decisions\n\n### Decision 1: Simple Utility Functions\n\n**Choice**: Add functions to `src/utils/change-utils.ts` - no class.\n\n```typescript\n// src/utils/change-utils.ts\n\nexport function validateChangeName(name: string): { valid: boolean; error?: string }\n\nexport async function createChange(\n  projectRoot: string,\n  name: string\n): Promise<void>\n```\n\n**Why**:\n- Simple, no abstraction overhead\n- Easy to test\n- Easy to import where needed\n- Matches existing utility patterns in `src/utils/`\n\n**Alternatives considered**:\n- ChangeManager class: Rejected - over-engineered for 2 functions\n- Add to existing command: Rejected - mixes CLI with reusable logic\n\n### Decision 2: Kebab-Case Validation Pattern\n\n**Choice**: Validate names with `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`\n\nValid: `add-auth`, `refactor-db`, `add-feature-2`, `refactor`\nInvalid: `Add-Auth`, `add auth`, `add_auth`, `-add-auth`, `add-auth-`, `add--auth`\n\n**Why**:\n- Filesystem-safe (no special characters)\n- URL-safe (for future web UI)\n- Consistent with existing change naming in repo\n\n## File Changes\n\n### New Files\n- `src/utils/change-utils.ts` - Utility functions\n- `src/utils/change-utils.test.ts` - Unit tests\n\n### Modified Files\n- None\n\n## Risks / Trade-offs\n\n| Risk | Mitigation |\n|------|------------|\n| Function might not cover all use cases | Start simple, extend if needed |\n| Naming conflicts with future work | Using clear, specific function names |\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-25-add-change-manager/proposal.md",
    "content": "## Why\n\nThere's no programmatic way to create a new change directory. Users must manually:\n1. Create `openspec/changes/<name>/` directory\n2. Create a `proposal.md` file\n3. Hope they got the naming right\n\nThis is error-prone and blocks automation (e.g., Claude commands, scripts).\n\n**This proposal adds:**\n1. `createChange(projectRoot, name)` - Create change directories programmatically\n2. `validateChangeName(name)` - Enforce kebab-case naming conventions\n\n## What Changes\n\n### New Utilities\n\n| Function | Description |\n|----------|-------------|\n| `createChange(projectRoot, name)` | Creates `openspec/changes/<name>/` directory |\n| `validateChangeName(name)` | Returns `{ valid: boolean; error?: string }` |\n\n### Name Validation Rules\n\nPattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`\n\n| Valid | Invalid |\n|-------|---------|\n| `add-auth` | `Add-Auth` (uppercase) |\n| `refactor-db` | `add auth` (spaces) |\n| `add-feature-2` | `add_auth` (underscores) |\n| `refactor` | `-add-auth` (leading hyphen) |\n\n### Location\n\nNew file: `src/utils/change-utils.ts`\n\nSimple utility functions - no class, no abstraction layer.\n\n## Impact\n\n- **Affected specs**: None\n- **Affected code**: None (new utilities only)\n- **New files**: `src/utils/change-utils.ts`\n- **Breaking changes**: None\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-25-add-change-manager/specs/change-creation/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Change Creation\nThe system SHALL provide a function to create new change directories programmatically.\n\n#### Scenario: Create change\n- **WHEN** `createChange(projectRoot, 'add-auth')` is called\n- **THEN** the system creates `openspec/changes/add-auth/` directory\n\n#### Scenario: Duplicate change rejected\n- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists\n- **THEN** the system throws an error indicating the change already exists\n\n#### Scenario: Creates parent directories if needed\n- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist\n- **THEN** the system creates the full path including parent directories\n\n#### Scenario: Invalid change name rejected\n- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name\n- **THEN** the system throws a validation error\n\n### Requirement: Change Name Validation\nThe system SHALL validate change names follow kebab-case conventions.\n\n#### Scenario: Valid kebab-case name accepted\n- **WHEN** a change name like `add-user-auth` is validated\n- **THEN** validation returns `{ valid: true }`\n\n#### Scenario: Numeric suffixes accepted\n- **WHEN** a change name like `add-feature-2` is validated\n- **THEN** validation returns `{ valid: true }`\n\n#### Scenario: Single word accepted\n- **WHEN** a change name like `refactor` is validated\n- **THEN** validation returns `{ valid: true }`\n\n#### Scenario: Uppercase characters rejected\n- **WHEN** a change name like `Add-Auth` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Spaces rejected\n- **WHEN** a change name like `add auth` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Underscores rejected\n- **WHEN** a change name like `add_auth` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Special characters rejected\n- **WHEN** a change name like `add-auth!` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Leading hyphen rejected\n- **WHEN** a change name like `-add-auth` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Trailing hyphen rejected\n- **WHEN** a change name like `add-auth-` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Consecutive hyphens rejected\n- **WHEN** a change name like `add--auth` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-25-add-change-manager/tasks.md",
    "content": "## Phase 1: Implement Name Validation\n\n- [x] 1.1 Create `src/utils/change-utils.ts`\n- [x] 1.2 Implement `validateChangeName()` with kebab-case pattern\n- [x] 1.3 Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`\n- [x] 1.4 Return `{ valid: boolean; error?: string }`\n- [x] 1.5 Add test: valid names accepted (`add-auth`, `refactor`, `add-feature-2`)\n- [x] 1.6 Add test: uppercase rejected\n- [x] 1.7 Add test: spaces rejected\n- [x] 1.8 Add test: underscores rejected\n- [x] 1.9 Add test: special characters rejected\n- [x] 1.10 Add test: leading/trailing hyphens rejected\n- [x] 1.11 Add test: consecutive hyphens rejected\n\n## Phase 2: Implement Change Creation\n\n- [x] 2.1 Implement `createChange(projectRoot, name)`\n- [x] 2.2 Validate name before creating\n- [x] 2.3 Create parent directories if needed (`openspec/changes/`)\n- [x] 2.4 Throw if change already exists\n- [x] 2.5 Add test: creates directory\n- [x] 2.6 Add test: duplicate change throws error\n- [x] 2.7 Add test: invalid name throws validation error\n- [x] 2.8 Add test: creates parent directories if needed\n\n## Phase 3: Integration\n\n- [x] 3.1 Export functions from `src/utils/index.ts`\n- [x] 3.2 Add JSDoc comments\n- [x] 3.3 Run all tests to verify no regressions\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/design.md",
    "content": "## Context\n\nSlice 4 of the artifact workflow POC. The core functionality (ArtifactGraph, InstructionLoader, change-utils) is complete. This slice adds CLI commands to expose the artifact workflow to users.\n\n**Key constraint**: This is experimental. Commands must be isolated for easy removal if the feature doesn't work out.\n\n## Goals / Non-Goals\n\n- **Goals:**\n  - Expose artifact workflow status and instructions via CLI\n  - Provide fluid UX with top-level verb commands\n  - Support both human-readable and JSON output\n  - Enable agents to programmatically query workflow state\n  - Keep implementation isolated for easy removal\n\n- **Non-Goals:**\n  - Interactive artifact creation wizards (future work)\n  - Schema management commands (deferred)\n  - Auto-detection of active change (CLI is deterministic, agents infer)\n\n## Decisions\n\n### Command Structure: Top-Level Verbs\n\nCommands are top-level for maximum fluidity:\n\n```\nopenspec status --change <id>\nopenspec next --change <id>\nopenspec instructions <artifact> --change <id>\nopenspec templates [--schema <name>]\nopenspec new change <name>\n```\n\n**Rationale:**\n- Most fluid UX - fewest keystrokes\n- Commands are unique enough to avoid conflicts\n- Simple mental model for users\n\n**Trade-off accepted:** Slight namespace pollution, but commands are distinct and can be removed cleanly.\n\n### Experimental Isolation\n\nAll artifact workflow commands are implemented in a single file:\n\n```\nsrc/commands/artifact-workflow.ts\n```\n\n**To remove the feature:**\n1. Delete `src/commands/artifact-workflow.ts`\n2. Remove ~5 lines from `src/cli/index.ts`\n\nNo other files touched, no risk to stable functionality.\n\n### Deterministic CLI with Explicit `--change`\n\nAll change-specific commands require `--change <id>`:\n\n```bash\nopenspec status --change add-auth   # explicit, works\nopenspec status                      # error: missing --change\n```\n\n**Rationale:**\n- CLI is pure, testable, no hidden state\n- Agents infer change from conversation and pass explicitly\n- No config file tracking \"active change\"\n- Consistent with POC design philosophy\n\n### New Change Command Structure\n\nCreating changes uses explicit subcommand:\n\n```bash\nopenspec new change add-feature\n```\n\n**Rationale:**\n- `openspec new <name>` is ambiguous (new what?)\n- `openspec new change <name>` is clear and extensible\n- Can add `openspec new spec <name>` later if needed\n\n### Output Formats\n\n- **Default**: Human-readable text with visual indicators\n  - Status: `[x]` done, `[ ]` ready, `[-]` blocked\n  - Colors: green (done), yellow (ready), red (blocked)\n- **JSON** (`--json`): Machine-readable for scripts and agents\n\n### Error Handling\n\n- Missing `--change`: Error listing available changes\n- Unknown change: Error with suggestion\n- Unknown artifact: Error listing valid artifacts\n- Missing schema: Error with schema resolution details\n\n## Risks / Trade-offs\n\n| Risk | Mitigation |\n|------|------------|\n| Top-level commands pollute namespace | Commands are distinct; isolated for easy removal |\n| `status` confused with git | Context (`--change`) makes it clear |\n| Feature doesn't work out | Single file deletion removes everything |\n\n## Implementation Notes\n\n- All commands in `src/commands/artifact-workflow.ts`\n- Imports from `src/core/artifact-graph/` for all operations\n- Uses `getActiveChangeIds()` from `item-discovery.ts` for change listing\n- Follows existing CLI patterns (ora spinners, commander.js options)\n- Help text marks commands as \"Experimental\"\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/proposal.md",
    "content": "## Why\n\nThe ArtifactGraph (Slice 1) and InstructionLoader (Slice 3) provide programmatic APIs for artifact-based workflow management. Users currently have no CLI interface to:\n- See artifact completion status for a change\n- Discover what artifacts are ready to create\n- Get enriched instructions for creating artifacts\n- Create new changes with proper validation\n\nThis proposal adds CLI commands that expose the artifact workflow functionality to users and agents.\n\n## What Changes\n\n- **NEW**: `openspec status --change <id>` shows artifact completion state\n- **NEW**: `openspec next --change <id>` shows artifacts ready to create\n- **NEW**: `openspec instructions <artifact> --change <id>` outputs enriched template\n- **NEW**: `openspec templates [--schema <name>]` shows resolved template paths\n- **NEW**: `openspec new change <name>` creates a new change directory\n\nAll commands are top-level for fluid UX. They integrate with existing core modules:\n- Uses `loadChangeContext()`, `formatChangeStatus()`, `generateInstructions()` from instruction-loader\n- Uses `ArtifactGraph`, `detectCompleted()` from artifact-graph\n- Uses `createChange()`, `validateChangeName()` from change-utils\n\n**Experimental isolation**: All commands are implemented in a single file (`src/commands/artifact-workflow.ts`) for easy removal if the feature doesn't work out. Help text marks them as experimental.\n\n## Impact\n\n- Affected specs: NEW `cli-artifact-workflow` capability\n- Affected code:\n  - `src/cli/index.ts` - register new commands\n  - `src/commands/artifact-workflow.ts` - new command implementations\n- No changes to existing commands or specs\n- Builds on completed Slice 1, 2, and 3 implementations\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/specs/cli-artifact-workflow/spec.md",
    "content": "# cli-artifact-workflow Specification\n\n## Purpose\nCLI commands for artifact workflow operations, exposing the artifact graph and instruction loader functionality to users and agents. Commands are top-level for fluid UX and implemented in isolation for easy removal.\n\n## ADDED Requirements\n\n### Requirement: Status Command\nThe system SHALL display artifact completion status for a change.\n\n#### Scenario: Show status with all states\n- **WHEN** user runs `openspec status --change <id>`\n- **THEN** the system displays each artifact with status indicator:\n  - `[x]` for completed artifacts\n  - `[ ]` for ready artifacts\n  - `[-]` for blocked artifacts (with missing dependencies listed)\n\n#### Scenario: Status shows completion summary\n- **WHEN** user runs `openspec status --change <id>`\n- **THEN** output includes completion percentage and count (e.g., \"2/4 artifacts complete\")\n\n#### Scenario: Status JSON output\n- **WHEN** user runs `openspec status --change <id> --json`\n- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array\n\n#### Scenario: Missing change parameter\n- **WHEN** user runs `openspec status` without `--change`\n- **THEN** the system displays an error with list of available changes\n\n#### Scenario: Unknown change\n- **WHEN** user runs `openspec status --change unknown-id`\n- **THEN** the system displays an error indicating the change does not exist\n\n### Requirement: Next Command\nThe system SHALL show which artifacts are ready to be created.\n\n#### Scenario: Show ready artifacts\n- **WHEN** user runs `openspec next --change <id>`\n- **THEN** the system lists artifacts whose dependencies are all satisfied\n\n#### Scenario: No artifacts ready\n- **WHEN** all artifacts are either completed or blocked\n- **THEN** the system indicates no artifacts are ready (with explanation)\n\n#### Scenario: All artifacts complete\n- **WHEN** all artifacts in the change are completed\n- **THEN** the system indicates the change is complete\n\n#### Scenario: Next JSON output\n- **WHEN** user runs `openspec next --change <id> --json`\n- **THEN** the system outputs JSON array of ready artifact IDs\n\n### Requirement: Instructions Command\nThe system SHALL output enriched instructions for creating an artifact.\n\n#### Scenario: Show enriched instructions\n- **WHEN** user runs `openspec instructions <artifact> --change <id>`\n- **THEN** the system outputs:\n  - Artifact metadata (ID, output path, description)\n  - Template content\n  - Dependency status (done/missing)\n  - Unlocked artifacts (what becomes available after completion)\n\n#### Scenario: Instructions JSON output\n- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`\n- **THEN** the system outputs JSON matching ArtifactInstructions interface\n\n#### Scenario: Unknown artifact\n- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`\n- **THEN** the system displays an error listing valid artifact IDs for the schema\n\n#### Scenario: Artifact with unmet dependencies\n- **WHEN** user requests instructions for a blocked artifact\n- **THEN** the system displays instructions with a warning about missing dependencies\n\n### Requirement: Templates Command\nThe system SHALL show resolved template paths for all artifacts in a schema.\n\n#### Scenario: List template paths with default schema\n- **WHEN** user runs `openspec templates`\n- **THEN** the system displays each artifact with its resolved template path using the default schema\n\n#### Scenario: List template paths with custom schema\n- **WHEN** user runs `openspec templates --schema tdd`\n- **THEN** the system displays template paths for the specified schema\n\n#### Scenario: Templates JSON output\n- **WHEN** user runs `openspec templates --json`\n- **THEN** the system outputs JSON mapping artifact IDs to template paths\n\n#### Scenario: Template resolution source\n- **WHEN** displaying template paths\n- **THEN** the system indicates whether each template is from user override or package built-in\n\n### Requirement: New Change Command\nThe system SHALL create new change directories with validation.\n\n#### Scenario: Create valid change\n- **WHEN** user runs `openspec new change add-feature`\n- **THEN** the system creates `openspec/changes/add-feature/` directory\n\n#### Scenario: Invalid change name\n- **WHEN** user runs `openspec new change \"Add Feature\"` with invalid name\n- **THEN** the system displays validation error with guidance\n\n#### Scenario: Duplicate change name\n- **WHEN** user runs `openspec new change existing-change` for an existing change\n- **THEN** the system displays an error indicating the change already exists\n\n#### Scenario: Create with description\n- **WHEN** user runs `openspec new change add-feature --description \"Add new feature\"`\n- **THEN** the system creates the change directory with description in README.md\n\n### Requirement: Schema Selection\nThe system SHALL support custom schema selection for workflow commands.\n\n#### Scenario: Default schema\n- **WHEN** user runs workflow commands without `--schema`\n- **THEN** the system uses the \"spec-driven\" schema\n\n#### Scenario: Custom schema\n- **WHEN** user runs `openspec status --change <id> --schema tdd`\n- **THEN** the system uses the specified schema for artifact graph\n\n#### Scenario: Unknown schema\n- **WHEN** user specifies an unknown schema\n- **THEN** the system displays an error listing available schemas\n\n### Requirement: Output Formatting\nThe system SHALL provide consistent output formatting.\n\n#### Scenario: Color output\n- **WHEN** terminal supports colors\n- **THEN** status indicators use colors: green (done), yellow (ready), red (blocked)\n\n#### Scenario: No color output\n- **WHEN** `--no-color` flag is used or NO_COLOR environment variable is set\n- **THEN** output uses text-only indicators without ANSI colors\n\n#### Scenario: Progress indication\n- **WHEN** loading change state takes time\n- **THEN** the system displays a spinner during loading\n\n### Requirement: Experimental Isolation\nThe system SHALL implement artifact workflow commands in isolation for easy removal.\n\n#### Scenario: Single file implementation\n- **WHEN** artifact workflow feature is implemented\n- **THEN** all commands are in `src/commands/artifact-workflow.ts`\n\n#### Scenario: Help text marking\n- **WHEN** user runs `--help` on any artifact workflow command\n- **THEN** help text indicates the command is experimental\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-add-artifact-workflow-cli/tasks.md",
    "content": "## 1. Core Command Implementation\n\n- [x] 1.1 Create `src/commands/artifact-workflow.ts` with all commands\n- [x] 1.2 Implement `status` command with text output\n- [x] 1.3 Implement `next` command with text output\n- [x] 1.4 Implement `instructions` command with text output\n- [x] 1.5 Implement `templates` command with text output\n- [x] 1.6 Implement `new change` subcommand using createChange()\n\n## 2. CLI Registration\n\n- [x] 2.1 Register `status` command in `src/cli/index.ts`\n- [x] 2.2 Register `next` command in `src/cli/index.ts`\n- [x] 2.3 Register `instructions` command in `src/cli/index.ts`\n- [x] 2.4 Register `templates` command in `src/cli/index.ts`\n- [x] 2.5 Register `new` command group with `change` subcommand\n\n## 3. Output Formatting\n\n- [x] 3.1 Add `--json` flag support to all commands\n- [x] 3.2 Add color-coded status indicators (done/ready/blocked)\n- [x] 3.3 Add progress spinner for loading operations\n- [x] 3.4 Support `--no-color` flag\n\n## 4. Error Handling\n\n- [x] 4.1 Handle missing `--change` parameter with helpful error\n- [x] 4.2 Handle unknown change names with list of available changes\n- [x] 4.3 Handle unknown artifact names with valid options\n- [x] 4.4 Handle schema resolution errors\n\n## 5. Options and Flags\n\n- [x] 5.1 Add `--schema` option for custom schema selection\n- [x] 5.2 Add `--description` option to `new change` command\n- [x] 5.3 Ensure options follow existing CLI patterns\n\n## 6. Testing\n\n- [x] 6.1 Add smoke tests for each command\n- [x] 6.2 Test error cases (missing change, unknown artifact)\n- [x] 6.3 Test JSON output format\n- [x] 6.4 Test with different schemas\n\n## 7. Documentation\n\n- [x] 7.1 Add help text for all commands marked as \"Experimental\"\n- [ ] 7.2 Update AGENTS.md with new commands (post-archive)\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-add-instruction-loader/design.md",
    "content": "## Context\n\nThis is Slice 3 of the artifact-graph POC. We have:\n- `ArtifactGraph` class with graph operations (Slice 1)\n- `detectCompleted()` for filesystem-based state detection (Slice 1)\n- `resolveSchema()` for XDG schema resolution (Slice 1)\n- `createChange()` and `validateChangeName()` utilities (Slice 2)\n\nAfter `restructure-schema-directories` is implemented, schemas will be self-contained directories:\n```\nschemas/<name>/\n├── schema.yaml\n└── templates/\n    └── *.md\n```\n\nThis proposal adds template loading and instruction enrichment on top of that structure.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Load templates from schema directories\n- Enrich templates with change-specific context (dependency status)\n- Format change status for CLI output\n\n**Non-Goals:**\n- Template authoring UI\n- Dynamic template compilation/execution\n- Caching (keep it stateless like the rest)\n\n## Decisions\n\n### 1. Pure functions over classes\n\nFollow the pattern in `resolver.ts` and `state.ts`. Use a simple `ChangeContext` interface with pure functions:\n\n```typescript\ninterface ChangeContext {\n  changeName: string;\n  changeDir: string;\n  schemaName: string;\n  graph: ArtifactGraph;\n  completed: CompletedSet;\n}\n\nfunction loadChangeContext(projectRoot: string, changeName: string, schemaName?: string): ChangeContext\nfunction loadTemplate(schemaName: string, templatePath: string): string\nfunction getInstructions(artifactId: string, context: ChangeContext): string\nfunction formatStatus(context: ChangeContext): string\n```\n\n**Why:** Matches existing codebase patterns. Easier to test. No hidden state.\n\n### 2. Template resolution from schema directory\n\nTemplates are loaded from the schema's `templates/` subdirectory:\n\n```typescript\nfunction loadTemplate(schemaName: string, templatePath: string): string {\n  const schemaDir = getSchemaDir(schemaName);  // From resolver.ts\n  const fullPath = path.join(schemaDir, 'templates', templatePath);\n  return fs.readFileSync(fullPath, 'utf-8');\n}\n```\n\nResolution is handled by `getSchemaDir()` which already checks user override → package built-in.\n\n**Why:** Leverages existing schema resolution. Templates are co-located with schemas.\n\n### 3. Template path from artifact definition\n\nThe artifact's `template` field is a path relative to the schema's `templates/` directory:\n\n```yaml\nartifacts:\n  - id: proposal\n    template: \"proposal.md\"  # → schemas/<schema>/templates/proposal.md\n```\n\n**Why:** Explicit, simple, no magic.\n\n### 4. Minimal context injection\n\nTemplates are markdown. Injection prepends a header section with context:\n\n```markdown\n---\nchange: add-auth\nartifact: proposal\nschema: spec-driven\noutput: openspec/changes/add-auth/proposal.md\n---\n\n## Dependencies\n- [x] (none - this is a root artifact)\n\n## Next Steps\nAfter creating this artifact, you can work on: design, specs\n\n---\n\n[original template content...]\n```\n\n**Why:** Simple string concatenation. No template engine dependency. Clear separation.\n\n### 5. Status output format\n\n```markdown\n## Change: add-auth (spec-driven)\n\n| Artifact | Status | Output |\n|----------|--------|--------|\n| proposal | done | proposal.md |\n| specs | ready | specs/*.md |\n| design | blocked (needs: proposal) | design.md |\n| tasks | blocked (needs: specs, design) | tasks.md |\n```\n\n**Why:** Markdown table is readable in terminal and docs. Matches CLI output style.\n\n## File Structure\n\n```\nsrc/core/artifact-graph/\n├── index.ts              # Add new exports\n├── template.ts           # NEW: Template loading\n├── context.ts            # NEW: ChangeContext loading\n└── instructions.ts       # NEW: Enrichment and formatting\n```\n\n## Risks / Trade-offs\n\n**Dependency on restructure-schema-directories:**\n- This proposal requires the schema restructure to be done first\n- Mitigation: Clear dependency documented, implement in order\n\n**No template engine:**\n- Pro: Zero dependencies, simple code\n- Con: Limited expressiveness\n- Mitigation: Current use case only needs static templates + header injection\n\n## Migration Plan\n\nN/A - new capability, no existing code to migrate.\n\n## Open Questions\n\nNone.\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-add-instruction-loader/proposal.md",
    "content": "## Why\n\nSlice 1 (artifact-graph) provides graph operations and state detection. Slice 2 (change-utils) provides change creation. We now need the ability to load templates for artifacts and enrich them with change-specific context so users/agents know what to create next.\n\n## What Changes\n\n- Add template resolution from schema directories (uses structure from `restructure-schema-directories`)\n- Add instruction enrichment that injects change context into templates\n- Add status formatting for CLI output\n- New `instruction-loader` capability\n\n## Dependencies\n\n- Requires `restructure-schema-directories` to be implemented first (schemas as directories with co-located templates)\n\n## Impact\n\n- Affected specs: New `instruction-loader` spec\n- Affected code: `src/core/artifact-graph/` (new files)\n- Builds on: `artifact-graph` (Slice 1), uses `ArtifactGraph`, `detectCompleted`, `resolveSchema`\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-add-instruction-loader/specs/instruction-loader/spec.md",
    "content": "# instruction-loader Specification\n\n## Purpose\nLoad templates from schema directories and enrich them with change-specific context for guiding artifact creation.\n\n## ADDED Requirements\n\n### Requirement: Template Loading\nThe system SHALL load templates from schema directories.\n\n#### Scenario: Load template from schema directory\n- **WHEN** `loadTemplate(schemaName, templatePath)` is called\n- **THEN** the system loads the template from `schemas/<schemaName>/templates/<templatePath>`\n\n#### Scenario: Template file not found\n- **WHEN** a template file does not exist in the schema's templates directory\n- **THEN** the system throws an error with the template path\n\n### Requirement: Change Context Loading\nThe system SHALL load change context combining graph and completion state.\n\n#### Scenario: Load context for existing change\n- **WHEN** `loadChangeContext(projectRoot, changeName)` is called for an existing change\n- **THEN** the system returns a context with graph, completed set, schema name, and change info\n\n#### Scenario: Load context with custom schema\n- **WHEN** `loadChangeContext(projectRoot, changeName, schemaName)` is called\n- **THEN** the system uses the specified schema instead of default\n\n#### Scenario: Load context for non-existent change directory\n- **WHEN** `loadChangeContext` is called for a non-existent change directory\n- **THEN** the system returns context with empty completed set\n\n### Requirement: Template Enrichment\nThe system SHALL enrich templates with change-specific context.\n\n#### Scenario: Include artifact metadata\n- **WHEN** instructions are generated for an artifact\n- **THEN** the output includes change name, artifact ID, schema name, and output path\n\n#### Scenario: Include dependency status\n- **WHEN** an artifact has dependencies\n- **THEN** the output shows each dependency with completion status (done/missing)\n\n#### Scenario: Include unlocked artifacts\n- **WHEN** instructions are generated\n- **THEN** the output includes which artifacts become available after this one\n\n#### Scenario: Root artifact indicator\n- **WHEN** an artifact has no dependencies\n- **THEN** the dependency section indicates this is a root artifact\n\n### Requirement: Status Formatting\nThe system SHALL format change status as readable output.\n\n#### Scenario: All artifacts completed\n- **WHEN** all artifacts are completed\n- **THEN** status shows all artifacts as \"done\"\n\n#### Scenario: Mixed completion status\n- **WHEN** some artifacts are completed\n- **THEN** status shows completed as \"done\", ready as \"ready\", blocked as \"blocked\"\n\n#### Scenario: Blocked artifact details\n- **WHEN** an artifact is blocked\n- **THEN** status shows which dependencies are missing\n\n#### Scenario: Include output paths\n- **WHEN** status is formatted\n- **THEN** each artifact shows its output path pattern\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-add-instruction-loader/tasks.md",
    "content": "# Tasks\n\n## Implementation Tasks\n\n- [x] Create `instruction-loader` spec in `openspec/specs/instruction-loader/spec.md`\n- [x] Implement `loadTemplate` function to load templates from schema directories\n- [x] Implement `loadChangeContext` function to combine graph and completion state\n- [x] Implement `generateInstructions` function to enrich templates with change context\n- [x] Implement `formatChangeStatus` function for readable status output\n- [x] Export new functions from `src/core/artifact-graph/index.ts`\n- [x] Add comprehensive tests in `test/core/artifact-graph/instruction-loader.test.ts`\n- [x] Verify build passes\n- [x] Verify all tests pass\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-restructure-schema-directories/design.md",
    "content": "## Context\n\nBuilt-in schemas are currently embedded as TypeScript objects:\n\n```typescript\n// src/core/artifact-graph/builtin-schemas.ts\nexport const SPEC_DRIVEN_SCHEMA: SchemaYaml = {\n  name: 'spec-driven',\n  version: 1,\n  artifacts: [...]\n};\n```\n\nThis doesn't support templates co-located with schemas. The instruction loader (Slice 3) needs templates, and the cleanest approach is self-contained schema directories.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Schemas as self-contained directories (schema.yaml + templates/)\n- User overrides via XDG data directory\n- Simple 2-level resolution (user → package)\n- Templates co-located with their schema\n\n**Non-Goals:**\n- Shared template fallback (intentionally avoiding complexity)\n- Runtime schema compilation\n- Schema inheritance\n\n## Decisions\n\n### 1. Directory structure\n\nEach schema is a directory containing `schema.yaml` and `templates/`:\n\n```\n<package>/schemas/\n├── spec-driven/\n│   ├── schema.yaml\n│   └── templates/\n│       ├── proposal.md\n│       ├── design.md\n│       ├── spec.md\n│       └── tasks.md\n└── tdd/\n    ├── schema.yaml\n    └── templates/\n        ├── spec.md\n        ├── test.md\n        ├── implementation.md\n        └── docs.md\n```\n\n**Why:** Self-contained like Helm charts. No cross-schema dependencies. Each schema owns its templates.\n\n### 2. Resolution order (2 levels)\n\n```\n1. ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml   # User override\n2. <package>/schemas/<name>/schema.yaml                    # Built-in\n3. Error (not found)\n```\n\n**Why:** Simple mental model. User can override entire schema directory or just parts.\n\n### 3. Template path in schema.yaml\n\nThe `template` field is relative to the schema's `templates/` directory:\n\n```yaml\n# schemas/spec-driven/schema.yaml\nartifacts:\n  - id: proposal\n    template: \"proposal.md\"  # → schemas/spec-driven/templates/proposal.md\n```\n\n**Why:** Paths are relative to the schema, not a global templates directory.\n\n### 4. Resolve package directory via import.meta.url\n\n```typescript\nfunction getPackageSchemasDir(): string {\n  const currentFile = fileURLToPath(import.meta.url);\n  // Navigate from src/core/artifact-graph/ to package root\n  return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas');\n}\n```\n\n**Why:** Works in ESM. No hardcoded paths.\n\n### 5. Keep schema.yaml format unchanged\n\nThe YAML format stays the same - only the storage location changes:\n\n```yaml\nname: spec-driven\nversion: 1\ndescription: Specification-driven development\nartifacts:\n  - id: proposal\n    generates: \"proposal.md\"\n    template: \"proposal.md\"\n    requires: []\n```\n\n**Why:** No breaking changes to schema format. Just moving from TS to YAML files.\n\n## Migration\n\n1. Create `schemas/` directory at package root\n2. Convert `SPEC_DRIVEN_SCHEMA` to `schemas/spec-driven/schema.yaml`\n3. Convert `TDD_SCHEMA` to `schemas/tdd/schema.yaml`\n4. Update `resolveSchema()` to load from directories\n5. Remove `builtin-schemas.ts`\n6. Update `listSchemas()` to scan directories\n\n## Risks / Trade-offs\n\n**File I/O at runtime:**\n- Previously schemas were in-memory objects\n- Now requires reading YAML files\n- Mitigation: Schemas are small, loaded once per operation\n\n**Package distribution:**\n- Must ensure `schemas/` directory is included in npm package\n- Add to `files` in package.json\n\n## Open Questions\n\nNone.\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-restructure-schema-directories/proposal.md",
    "content": "## Why\n\nCurrently, built-in schemas are embedded as TypeScript objects in `builtin-schemas.ts`. This works for schemas but doesn't support co-located templates. To enable self-contained schema packages (schema + templates together), we need to restructure schemas as directories.\n\n## What Changes\n\n- **BREAKING (internal):** Move built-in schemas from embedded TS objects to actual directory structure\n- Schemas become directories containing `schema.yaml` + `templates/`\n- Update `resolveSchema()` to load from directory structure\n- Remove `builtin-schemas.ts` (replaced by file-based schemas)\n- Update resolution to check user dir → package dir\n\n## Impact\n\n- Affected specs: `artifact-graph` (schema resolution changes)\n- Affected code:\n  - Remove `src/core/artifact-graph/builtin-schemas.ts`\n  - Update `src/core/artifact-graph/resolver.ts`\n  - Add `schemas/` directory at package root\n- No external API changes (resolution still returns `SchemaYaml`)\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-restructure-schema-directories/specs/artifact-graph/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: Schema Loading\nThe system SHALL load artifact graph definitions from YAML schema files within schema directories.\n\n#### Scenario: Valid schema loaded\n- **WHEN** a schema directory contains a valid `schema.yaml` file\n- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies\n\n#### Scenario: Invalid schema rejected\n- **WHEN** a schema YAML file is missing required fields\n- **THEN** the system throws an error with a descriptive message\n\n#### Scenario: Cyclic dependencies detected\n- **WHEN** a schema contains cyclic artifact dependencies\n- **THEN** the system throws an error listing the artifact IDs in the cycle\n\n#### Scenario: Invalid dependency reference\n- **WHEN** an artifact's `requires` array references a non-existent artifact ID\n- **THEN** the system throws an error identifying the invalid reference\n\n#### Scenario: Duplicate artifact IDs rejected\n- **WHEN** a schema contains multiple artifacts with the same ID\n- **THEN** the system throws an error identifying the duplicate\n\n#### Scenario: Schema directory not found\n- **WHEN** resolving a schema name that has no corresponding directory\n- **THEN** the system throws an error listing available schemas\n\n## ADDED Requirements\n\n### Requirement: Schema Directory Structure\nThe system SHALL support self-contained schema directories with co-located templates.\n\n#### Scenario: Schema with templates\n- **WHEN** a schema directory contains `schema.yaml` and `templates/` subdirectory\n- **THEN** artifacts can reference templates relative to the schema's templates directory\n\n#### Scenario: User schema override\n- **WHEN** a schema directory exists at `${XDG_DATA_HOME}/openspec/schemas/<name>/`\n- **THEN** the system uses that directory instead of the built-in\n\n#### Scenario: Built-in schema fallback\n- **WHEN** no user override exists for a schema\n- **THEN** the system uses the package built-in schema directory\n\n#### Scenario: List available schemas\n- **WHEN** listing schemas\n- **THEN** the system returns schema names from both user and package directories\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-28-restructure-schema-directories/tasks.md",
    "content": "## 1. Create Schema Directories\n\n- [ ] 1.1 Create `schemas/` directory at package root\n- [ ] 1.2 Create `schemas/spec-driven/schema.yaml` from `SPEC_DRIVEN_SCHEMA`\n- [ ] 1.3 Create `schemas/spec-driven/templates/` with placeholder templates\n- [ ] 1.4 Create `schemas/tdd/schema.yaml` from `TDD_SCHEMA`\n- [ ] 1.5 Create `schemas/tdd/templates/` with placeholder templates\n\n## 2. Update Schema Resolution\n\n- [ ] 2.1 Add `getPackageSchemasDir()` function using `import.meta.url`\n- [ ] 2.2 Add `getSchemaDir(name)` to resolve schema directory path\n- [ ] 2.3 Update `resolveSchema()` to load from directory structure\n- [ ] 2.4 Update `listSchemas()` to scan directories instead of object keys\n- [ ] 2.5 Add tests for user override resolution\n- [ ] 2.6 Add tests for built-in fallback\n\n## 3. Cleanup\n\n- [ ] 3.1 Remove `builtin-schemas.ts`\n- [ ] 3.2 Update `index.ts` exports (remove `BUILTIN_SCHEMAS`, `SPEC_DRIVEN_SCHEMA`, `TDD_SCHEMA`)\n- [ ] 3.3 Update any code that imports removed exports\n\n## 4. Package Distribution\n\n- [ ] 4.1 Add `schemas/` to `files` array in `package.json`\n- [ ] 4.2 Verify schemas are included in built package\n\n## 5. Fix Template Paths\n\n- [ ] 5.1 Update `template` field in schema.yaml files (remove `templates/` prefix)\n- [ ] 5.2 Ensure template paths are relative to schema's templates directory\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-29-unify-change-state-model/design.md",
    "content": "# Design: Unify Change State Model\n\n## Overview\n\nThis change fixes two bugs with minimal disruption to the existing system:\n\n1. **View bug**: Empty changes incorrectly shown as \"Completed\"\n2. **Artifact workflow bug**: Commands fail on scaffolded changes\n\n## Key Design Decision: Two Systems, Two Purposes\n\nThe task-based and artifact-based systems serve **different purposes** and should coexist:\n\n| System | Purpose | Used By |\n|--------|---------|---------|\n| **Task Progress** | Track implementation work | `openspec view`, `openspec list` |\n| **Artifact Progress** | Track planning/spec work | `openspec status`, `openspec next` |\n\nWe do NOT merge these systems. Instead, we fix each to work correctly in its domain.\n\n## Change 1: Fix View Command\n\n### Current Logic (Buggy)\n\n```typescript\n// view.ts line 90\nif (progress.total === 0 || progress.completed === progress.total) {\n  completed.push({ name: entry.name });\n}\n```\n\nProblem: `total === 0` means \"no tasks defined yet\", not \"all tasks done\".\n\n### New Logic\n\n```typescript\nif (progress.total === 0) {\n  draft.push({ name: entry.name });\n} else if (progress.completed === progress.total) {\n  completed.push({ name: entry.name });\n} else {\n  active.push({ name: entry.name, progress });\n}\n```\n\n### View Output Change\n\n**Before:**\n```\nCompleted Changes\n─────────────────\n  ✓ add-feature        (all tasks done - correct)\n  ✓ test-workflow      (no tasks - WRONG)\n```\n\n**After:**\n```\nDraft Changes\n─────────────────\n  ○ test-workflow      (no tasks yet)\n\nActive Changes\n─────────────────\n  ◉ add-scaffold       [████░░░░] 3/7 tasks\n\nCompleted Changes\n─────────────────\n  ✓ add-feature        (all tasks done)\n```\n\n## Change 2: Fix Artifact Workflow Discovery\n\n### Current Logic (Buggy)\n\n```typescript\n// artifact-workflow.ts - validateChangeExists()\nconst activeChanges = await getActiveChangeIds(projectRoot);\nif (!activeChanges.includes(changeName)) {\n  throw new Error(`Change '${changeName}' not found...`);\n}\n```\n\nProblem: `getActiveChangeIds()` requires `proposal.md`, but artifact workflow should work on empty directories to help create the first artifact.\n\n### New Logic\n\n```typescript\nasync function validateChangeExists(changeName: string, projectRoot: string): Promise<string> {\n  const changePath = path.join(projectRoot, 'openspec', 'changes', changeName);\n\n  // Check directory existence directly, not proposal.md\n  if (!fs.existsSync(changePath) || !fs.statSync(changePath).isDirectory()) {\n    // List available changes for helpful error message\n    const entries = await fs.promises.readdir(\n      path.join(projectRoot, 'openspec', 'changes'),\n      { withFileTypes: true }\n    );\n    const available = entries\n      .filter(e => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))\n      .map(e => e.name);\n\n    if (available.length === 0) {\n      throw new Error('No changes found. Create one with: openspec new change <name>');\n    }\n    throw new Error(`Change '${changeName}' not found. Available:\\n  ${available.join('\\n  ')}`);\n  }\n\n  return changeName;\n}\n```\n\n### Behavior Change\n\n```bash\n# Before\n$ openspec new change foo\n$ openspec status --change foo\nError: Change 'foo' not found.\n\n# After\n$ openspec new change foo\n$ openspec status --change foo\nChange: foo\nProgress: 0/4 artifacts complete\n\n[ ] proposal\n[-] specs (blocked by: proposal)\n[-] design (blocked by: proposal)\n[-] tasks (blocked by: specs, design)\n```\n\n## What Stays the Same\n\n1. **`getActiveChangeIds()`** - Still requires `proposal.md` (used by validate, show)\n2. **`getArchivedChangeIds()`** - Unchanged\n3. **Active/Completed semantics** - Still based on task checkboxes\n4. **Validation** - Still requires `proposal.md` to have something to validate\n\n## File Changes\n\n| File | Change |\n|------|--------|\n| `src/core/view.ts` | Add draft category, fix completion logic |\n| `src/commands/artifact-workflow.ts` | Update `validateChangeExists()` to use directory existence |\n| `test/commands/artifact-workflow.test.ts` | Add tests for scaffolded changes |\n\n## Testing Strategy\n\n1. **Unit test**: `validateChangeExists()` with scaffolded change\n2. **View test**: Verify three categories render correctly\n3. **Manual test**: Full workflow from `new change` → `status` → `view`\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-29-unify-change-state-model/proposal.md",
    "content": "# Proposal: Unify Change State Model\n\n## Problem Statement\n\nTwo bugs create inconsistent behavior when working with changes:\n\n### Bug 1: Empty changes shown as \"Completed\" in view\n\n```typescript\n// view.ts line 90\nif (progress.total === 0 || progress.completed === progress.total) {\n  completed.push({ name: entry.name });  // BUG: total === 0 ≠ completed\n}\n```\n\nResult: `openspec new change foo && openspec view` shows `foo` as \"Completed\" when it has no content.\n\n### Bug 2: Artifact workflow commands can't find scaffolded changes\n\n```typescript\n// item-discovery.ts - getActiveChangeIds()\nconst proposalPath = path.join(changesPath, entry.name, 'proposal.md');\nawait fs.access(proposalPath);  // Only returns changes WITH proposal.md\n```\n\nResult: `openspec status --change foo` says \"not found\" even though the directory exists.\n\n## Root Cause\n\nThe system conflates two different concepts:\n\n| Concept | Question | Source of Truth |\n|---------|----------|-----------------|\n| **Planning Progress** | Are all spec documents created? | File existence (ArtifactGraph) |\n| **Implementation Progress** | Is the coding work done? | Task checkboxes (tasks.md) |\n\n## Proposed Solution\n\n### Fix 1: Add \"Draft\" state to view command\n\nKeep Active/Completed with their existing meanings, but fix the bug:\n\n| State | Criteria | Meaning |\n|-------|----------|---------|\n| **Draft** | No tasks.md OR `tasks.total === 0` | Still planning |\n| **Active** | `tasks.total > 0` AND `completed < total` | Implementing |\n| **Completed** | `tasks.total > 0` AND `completed === total` | Done |\n\n### Fix 2: Artifact workflow uses directory existence\n\nUpdate `validateChangeExists()` to check if the directory exists, not if `proposal.md` exists. This allows the artifact workflow to guide users through creating their first artifact.\n\n### Keep existing discovery functions\n\n`getActiveChangeIds()` continues to require `proposal.md` for backward compatibility with validation and other commands.\n\n## What Changes\n\n| Command | Before | After |\n|---------|--------|-------|\n| `openspec view` | Empty = \"Completed\" | Empty = \"Draft\" |\n| `openspec status --change X` | Requires proposal.md | Works on any directory |\n| `openspec validate X` | Requires proposal.md | Unchanged (still requires it) |\n\n## Breaking Changes\n\n### Minimal Breaking Change\n\n1. **`openspec view` output**: Empty changes move from \"Completed\" section to new \"Draft\" section\n\n### Non-Breaking\n\n- Active/Completed semantics unchanged (still task-based)\n- `getActiveChangeIds()` unchanged\n- `openspec validate` unchanged\n- Archived changes unaffected\n\n## Out of Scope\n\n- Merging task-based and artifact-based progress (they serve different purposes)\n- Changing what \"Completed\" means (it stays = all tasks done)\n- Adding artifact progress to view command (separate enhancement)\n- Shell tab completions for artifact workflow commands (not yet registered)\n\n## Related Commands Analysis\n\n| Command | Uses `getActiveChangeIds()` | Should include scaffolded? | Change needed? |\n|---------|-----------------------------|-----------------------------|----------------|\n| `openspec view` | No (reads dirs directly) | Yes → Draft section | **Yes** |\n| `openspec list` | No (reads dirs directly) | Yes (shows \"No tasks\") | No |\n| `openspec status/next/instructions` | Yes | Yes | **Yes** |\n| `openspec validate` | Yes | No (can't validate empty) | No |\n| `openspec show` | Yes | No (nothing to show) | No |\n| Tab completions | Yes | Future enhancement | No |\n\n## Success Criteria\n\n1. `openspec new change foo && openspec view` shows `foo` in \"Draft\" section\n2. `openspec new change foo && openspec status --change foo` works\n3. Changes with all tasks done still show as \"Completed\"\n4. All existing tests pass\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-29-unify-change-state-model/specs/cli-artifact-workflow/spec.md",
    "content": "# cli-artifact-workflow Specification Delta\n\n## MODIFIED Requirements\n\n### Requirement: Status Command\n\nThe system SHALL display artifact completion status for a change, including scaffolded (empty) changes.\n\n> **Fixes bug**: Previously required `proposal.md` to exist via `getActiveChangeIds()`.\n\n#### Scenario: Show status with all states\n\n- **WHEN** user runs `openspec status --change <id>`\n- **THEN** the system displays each artifact with status indicator:\n  - `[x]` for completed artifacts\n  - `[ ]` for ready artifacts\n  - `[-]` for blocked artifacts (with missing dependencies listed)\n\n#### Scenario: Status shows completion summary\n\n- **WHEN** user runs `openspec status --change <id>`\n- **THEN** output includes completion percentage and count (e.g., \"2/4 artifacts complete\")\n\n#### Scenario: Status JSON output\n\n- **WHEN** user runs `openspec status --change <id> --json`\n- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array\n\n#### Scenario: Status on scaffolded change\n\n- **WHEN** user runs `openspec status --change <id>` on a change with no artifacts\n- **THEN** system displays all artifacts with their status\n- **AND** root artifacts (no dependencies) show as ready `[ ]`\n- **AND** dependent artifacts show as blocked `[-]`\n\n#### Scenario: Missing change parameter\n\n- **WHEN** user runs `openspec status` without `--change`\n- **THEN** the system displays an error with list of available changes\n- **AND** includes scaffolded changes (directories without proposal.md)\n\n#### Scenario: Unknown change\n\n- **WHEN** user runs `openspec status --change unknown-id`\n- **AND** directory `openspec/changes/unknown-id/` does not exist\n- **THEN** the system displays an error listing all available change directories\n\n### Requirement: Next Command\n\nThe system SHALL show which artifacts are ready to be created, including for scaffolded changes.\n\n#### Scenario: Show ready artifacts\n\n- **WHEN** user runs `openspec next --change <id>`\n- **THEN** the system lists artifacts whose dependencies are all satisfied\n\n#### Scenario: No artifacts ready\n\n- **WHEN** all artifacts are either completed or blocked\n- **THEN** the system indicates no artifacts are ready (with explanation)\n\n#### Scenario: All artifacts complete\n\n- **WHEN** all artifacts in the change are completed\n- **THEN** the system indicates the change is complete\n\n#### Scenario: Next JSON output\n\n- **WHEN** user runs `openspec next --change <id> --json`\n- **THEN** the system outputs JSON array of ready artifact IDs\n\n#### Scenario: Next on scaffolded change\n\n- **WHEN** user runs `openspec next --change <id>` on a change with no artifacts\n- **THEN** system shows root artifacts (e.g., \"proposal\") as ready to create\n\n### Requirement: Instructions Command\n\nThe system SHALL output enriched instructions for creating an artifact, including for scaffolded changes.\n\n#### Scenario: Show enriched instructions\n\n- **WHEN** user runs `openspec instructions <artifact> --change <id>`\n- **THEN** the system outputs:\n  - Artifact metadata (ID, output path, description)\n  - Template content\n  - Dependency status (done/missing)\n  - Unlocked artifacts (what becomes available after completion)\n\n#### Scenario: Instructions JSON output\n\n- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`\n- **THEN** the system outputs JSON matching ArtifactInstructions interface\n\n#### Scenario: Unknown artifact\n\n- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`\n- **THEN** the system displays an error listing valid artifact IDs for the schema\n\n#### Scenario: Artifact with unmet dependencies\n\n- **WHEN** user requests instructions for a blocked artifact\n- **THEN** the system displays instructions with a warning about missing dependencies\n\n#### Scenario: Instructions on scaffolded change\n\n- **WHEN** user runs `openspec instructions proposal --change <id>` on a scaffolded change\n- **THEN** system outputs template and metadata for creating the proposal\n- **AND** does not require any artifacts to already exist\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-29-unify-change-state-model/specs/cli-view/spec.md",
    "content": "# cli-view Specification Delta\n\n## ADDED Requirements\n\n### Requirement: Draft Changes Display\n\nThe dashboard SHALL display changes without tasks in a separate \"Draft\" section.\n\n#### Scenario: Draft changes listing\n\n- **WHEN** there are changes with no tasks.md or zero tasks defined\n- **THEN** system shows them in a \"Draft Changes\" section\n- **AND** uses a distinct indicator (e.g., `○`) to show draft status\n\n#### Scenario: Draft section ordering\n\n- **WHEN** multiple draft changes exist\n- **THEN** system sorts them alphabetically by name\n\n## MODIFIED Requirements\n\n### Requirement: Completed Changes Display\n\nThe dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.\n\n> **Fixes bug**: Previously, changes with `total === 0` were incorrectly shown as completed.\n\n#### Scenario: Completed changes listing\n\n- **WHEN** there are changes with `tasks.total > 0` AND `tasks.completed === tasks.total`\n- **THEN** system shows them with checkmark indicators in a dedicated section\n\n#### Scenario: Mixed completion states\n\n- **WHEN** some changes are complete and others active\n- **THEN** system separates them into appropriate sections\n\n#### Scenario: Empty changes not completed\n\n- **WHEN** a change has no tasks.md or zero tasks defined\n- **THEN** system does NOT show it in \"Completed Changes\" section\n- **AND** shows it in \"Draft Changes\" section instead\n\n### Requirement: Summary Section\n\nThe dashboard SHALL display a summary section with key project metrics, including draft change count.\n\n#### Scenario: Complete summary display\n\n- **WHEN** dashboard is rendered with specs and changes\n- **THEN** system shows total number of specifications and requirements\n- **AND** shows number of draft changes\n- **AND** shows number of active changes in progress\n- **AND** shows number of completed changes\n- **AND** shows overall task progress percentage\n\n#### Scenario: Empty project summary\n\n- **WHEN** no specs or changes exist\n- **THEN** summary shows zero counts for all metrics\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-29-unify-change-state-model/tasks.md",
    "content": "# Tasks: Unify Change State Model\n\n## Phase 1: Fix Artifact Workflow Discovery\n\n- [x] Update `validateChangeExists()` in `artifact-workflow.ts` to check directory existence instead of using `getActiveChangeIds()`\n- [x] Update error message to list all change directories (not just those with proposal.md)\n- [x] Add test for `openspec status --change <scaffolded-change>`\n- [x] Add test for `openspec next --change <scaffolded-change>`\n- [x] Add test for `openspec instructions proposal --change <scaffolded-change>`\n\n## Phase 2: Fix View Command\n\n- [x] Update `getChangesData()` in `view.ts` to return three categories: draft, active, completed\n- [x] Fix completion logic: `total === 0` → draft, not completed\n- [x] Add \"Draft Changes\" section to dashboard rendering\n- [x] Update summary to include draft count\n- [x] Add test for draft changes appearing correctly in view\n\n## Phase 3: Cleanup and Validation\n\n- [x] Clean up test changes (`test-workflow`, `test-workflow-2`)\n- [x] Run full test suite\n- [x] Manual test: `openspec new change foo && openspec status --change foo`\n- [x] Manual test: `openspec new change foo && openspec view` shows foo in Draft\n- [x] Validate with `openspec validate unify-change-state-model --strict`\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-30-add-antigravity-support/proposal.md",
    "content": "## Why\nGoogle is rolling out Antigravity, a Windsurf-derived IDE that discovers workflows from `.agent/workflows/*.md`. Today OpenSpec can only scaffold slash commands for Windsurf directories, so Antigravity users cannot run the proposal/apply/archive flows from the IDE.\n\n## What Changes\n- Add Antigravity as a selectable native tool in `openspec init` so it creates `.agent/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with YAML frontmatter containing only a `description` field plus the standard OpenSpec-managed body.\n- Ensure `openspec update` refreshes the body of any existing Antigravity workflows inside `.agent/workflows/` without creating missing files, mirroring the Windsurf behavior.\n- Share e2e/template coverage confirming the generator writes the proper directory, filename casing, and frontmatter format so Antigravity picks up the workflows.\n\n## Impact\n- Affected specs: `specs/cli-init`, `specs/cli-update`\n- Expected code: CLI init/update tool registries, slash-command templates, associated tests\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-30-add-antigravity-support/specs/cli-init/spec.md",
    "content": "# Delta for CLI Init\n\n## MODIFIED Requirements\n### Requirement: Slash Command Configuration\nThe init command SHALL generate slash command files for supported editors using shared templates.\n\n#### Scenario: Generating slash commands for Antigravity\n- **WHEN** the user selects Antigravity during initialization\n- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`\n- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers\n- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory\n\n#### Scenario: Generating slash commands for Claude Code\n- **WHEN** the user selects Claude Code during initialization\n- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for CodeBuddy Code\n- **WHEN** the user selects CodeBuddy Code during initialization\n- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cline\n- **WHEN** the user selects Cline during initialization\n- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include Cline-specific Markdown heading frontmatter\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Crush\n- **WHEN** the user selects Crush during initialization\n- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include Crush-specific frontmatter with OpenSpec category and tags\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cursor\n- **WHEN** the user selects Cursor during initialization\n- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Factory Droid\n- **WHEN** the user selects Factory Droid during initialization\n- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`\n- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input\n- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands\n\n#### Scenario: Generating slash commands for OpenCode\n- **WHEN** the user selects OpenCode during initialization\n- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Windsurf\n- **WHEN** the user selects Windsurf during initialization\n- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Kilo Code\n- **WHEN** the user selects Kilo Code during initialization\n- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Codex\n- **WHEN** the user selects Codex during initialization\n- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)\n- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)\n- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes\n\n#### Scenario: Generating slash commands for GitHub Copilot\n- **WHEN** the user selects GitHub Copilot during initialization\n- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`\n- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage\n- **AND** include `$ARGUMENTS` placeholder to capture user input\n- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Gemini CLI\n- **WHEN** the user selects Gemini CLI during initialization\n- **THEN** create `.gemini/commands/openspec/proposal.toml`, `.gemini/commands/openspec/apply.toml`, and `.gemini/commands/openspec/archive.toml`\n- **AND** populate each file as TOML that sets a stage-specific `description = \"<summary>\"` and a multi-line `prompt = \"\"\"` block with the shared OpenSpec template\n- **AND** wrap the OpenSpec managed markers (`<!-- OPENSPEC:START -->` / `<!-- OPENSPEC:END -->`) inside the `prompt` value so `openspec update` can safely refresh the body between markers without touching the TOML framing\n- **AND** ensure the slash-command copy matches the existing proposal/apply/archive templates used by other tools\n\n#### Scenario: Generating slash commands for iFlow CLI\n- **WHEN** the user selects iFlow CLI during initialization\n- **THEN** create `.iflow/commands/openspec-proposal.md`, `.iflow/commands/openspec-apply.md`, and `.iflow/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include YAML frontmatter with `name`, `id`, `category`, and `description` fields for each command\n- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for RooCode\n- **WHEN** the user selects RooCode during initialization\n- **THEN** create `.roo/commands/openspec-proposal.md`, `.roo/commands/openspec-apply.md`, and `.roo/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include simple Markdown headings (e.g., `# OpenSpec: Proposal`) without YAML frontmatter\n- **AND** wrap the generated content in OpenSpec managed markers where applicable so `openspec update` can safely refresh the commands\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-30-add-antigravity-support/specs/cli-update/spec.md",
    "content": "# Delta for CLI Update\n\n## MODIFIED Requirements\n### Requirement: Slash Command Updates\nThe 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.\n\n#### Scenario: Updating slash commands for Antigravity\n- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter\n- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs\n\n#### Scenario: Updating slash commands for Claude Code\n- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for CodeBuddy Code\n- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cline\n- **WHEN** `.clinerules/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** include Cline-specific Markdown heading frontmatter\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Crush\n- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** include Crush-specific frontmatter with OpenSpec category and tags\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cursor\n- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Factory Droid\n- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid\n- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched\n- **AND** skip creating missing files during update\n\n#### Scenario: Updating slash commands for OpenCode\n- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments\n\n#### Scenario: Updating slash commands for Windsurf\n- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Kilo Code\n- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Codex\n- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **WHEN** a user runs `openspec update`\n- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance)\n- **AND** preserve any unmanaged content outside the OpenSpec marker block\n- **AND** skip creation when a Codex prompt file is missing\n\n#### Scenario: Updating slash commands for GitHub Copilot\n- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md`\n- **THEN** refresh each file using shared templates while preserving the YAML frontmatter\n- **AND** update only the OpenSpec-managed block between markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Gemini CLI\n- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml`\n- **THEN** refresh the body of each file using the shared proposal/apply/archive templates\n- **AND** replace only the content between `<!-- OPENSPEC:START -->` and `<!-- OPENSPEC:END -->` markers inside the `prompt = \"\"\"` block so the TOML framing (`description`, `prompt`) stays intact\n- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed\n\n#### Scenario: Updating slash commands for iFlow CLI\n- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields\n- **AND** update only the OpenSpec-managed block between markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Missing slash command file\n- **WHEN** a tool lacks a slash command file\n- **THEN** do not create a new file during update\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-30-add-antigravity-support/tasks.md",
    "content": "## 1. CLI init support\n- [x] 1.1 Surface Antigravity in the native-tool picker (interactive + `--tools`) so it toggles alongside other IDEs.\n- [x] 1.2 Generate `.agent/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with YAML frontmatter restricted to a single `description` field for each stage and wrap the body in OpenSpec markers.\n- [x] 1.3 Confirm workspace scaffolding covers missing directory creation and re-run scenarios so repeated init refreshes the managed block.\n\n## 2. CLI update support\n- [x] 2.1 Detect existing Antigravity workflow files during `openspec update` and refresh only the managed body, skipping creation when files are missing.\n- [x] 2.2 Ensure update logic preserves the `description` frontmatter block exactly as written by init, including case and spacing, and refreshes body templates alongside other tools.\n\n## 3. Templates and tests\n- [x] 3.1 Add shared template entries for Antigravity that reuse the Windsurf copy but target `.agent/workflows` plus the description-only frontmatter requirement.\n- [x] 3.2 Expand automated coverage (unit or integration) verifying init and update produce the expected file paths and frontmatter + body markers for Antigravity.\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-30-fix-cline-workflows-implementation/proposal.md",
    "content": "## Why\nThe Cline implementation was architecturally incorrect. According to Cline's official documentation, Cline uses workflows for on-demand automation and rules for behavioral guidelines. The OpenSpec slash commands are procedural workflows (scaffold → implement → archive), not behavioral rules, so they should be placed in `.clinerules/workflows/` instead of `.clinerules/`.\n\n## What Changes\n- Update ClineSlashCommandConfigurator to use `.clinerules/workflows/` paths instead of `.clinerules/` paths\n- Update all tests to expect the correct workflow file locations\n- Update README.md documentation to reflect workflows instead of rules\n- **BREAKING**: Existing Cline users will need to re-run `openspec init` to get the corrected workflow files\n\n## Impact\n- Affected specs: cli-init, cli-update (corrected Cline workflow paths)\n- Affected code: `src/core/configurators/slash/cline.ts`, test files, README.md\n- Modified files: `.clinerules/workflows/openspec-*.md` (moved from `.clinerules/openspec-*.md`)\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-30-fix-cline-workflows-implementation/specs/cli-init/spec.md",
    "content": "# Delta for CLI Init\n\n## MODIFIED Requirements\n### Requirement: Slash Command Configuration\nThe init command SHALL generate slash command files for supported editors using shared templates.\n\n#### Scenario: Generating slash commands for Antigravity\n- **WHEN** the user selects Antigravity during initialization\n- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`\n- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers\n- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory\n\n#### Scenario: Generating slash commands for Claude Code\n- **WHEN** the user selects Claude Code during initialization\n- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for CodeBuddy Code\n- **WHEN** the user selects CodeBuddy Code during initialization\n- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cline\n- **WHEN** the user selects Cline during initialization\n- **THEN** create `.clinerules/workflows/openspec-proposal.md`, `.clinerules/workflows/openspec-apply.md`, and `.clinerules/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include Cline-specific Markdown heading frontmatter\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Crush\n- **WHEN** the user selects Crush during initialization\n- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include Crush-specific frontmatter with OpenSpec category and tags\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cursor\n- **WHEN** the user selects Cursor during initialization\n- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Factory Droid\n- **WHEN** the user selects Factory Droid during initialization\n- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`\n- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input\n- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands\n\n#### Scenario: Generating slash commands for OpenCode\n- **WHEN** the user selects OpenCode during initialization\n- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Windsurf\n- **WHEN** the user selects Windsurf during initialization\n- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Kilo Code\n- **WHEN** the user selects Kilo Code during initialization\n- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Codex\n- **WHEN** the user selects Codex during initialization\n- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)\n- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)\n- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes\n\n#### Scenario: Generating slash commands for GitHub Copilot\n- **WHEN** the user selects GitHub Copilot during initialization\n- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`\n- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage\n- **AND** include `$ARGUMENTS` placeholder to capture user input\n- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Gemini CLI\n- **WHEN** the user selects Gemini CLI during initialization\n- **THEN** create `.gemini/commands/openspec/proposal.toml`, `.gemini/commands/openspec/apply.toml`, and `.gemini/commands/openspec/archive.toml`\n- **AND** populate each file as TOML that sets a stage-specific `description = \"<summary>\"` and a multi-line `prompt = \"\"\"` block with the shared OpenSpec template\n- **AND** wrap the OpenSpec managed markers (`<!-- OPENSPEC:START -->` / `<!-- OPENSPEC:END -->`) inside the `prompt` value so `openspec update` can safely refresh the body between markers without touching the TOML framing\n- **AND** ensure the slash-command copy matches the existing proposal/apply/archive templates used by other tools\n\n#### Scenario: Generating slash commands for iFlow CLI\n- **WHEN** the user selects iFlow CLI during initialization\n- **THEN** create `.iflow/commands/openspec-proposal.md`, `.iflow/commands/openspec-apply.md`, and `.iflow/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include YAML frontmatter with `name`, `id`, `category`, and `description` fields for each command\n- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for RooCode\n- **WHEN** the user selects RooCode during initialization\n- **THEN** create `.roo/commands/openspec-proposal.md`, `.roo/commands/openspec-apply.md`, and `.roo/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include simple Markdown headings (e.g., `# OpenSpec: Proposal`) without YAML frontmatter\n- **AND** wrap the generated content in OpenSpec managed markers where applicable so `openspec update` can safely refresh the commands\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-30-fix-cline-workflows-implementation/specs/cli-update/spec.md",
    "content": "# Delta for CLI Update\n\n## MODIFIED Requirements\n### Requirement: Slash Command Updates\nThe 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.\n\n#### Scenario: Updating slash commands for Antigravity\n- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter\n- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs\n\n#### Scenario: Updating slash commands for Claude Code\n- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for CodeBuddy Code\n- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cline\n- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** include Cline-specific Markdown heading frontmatter\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Crush\n- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** include Crush-specific frontmatter with OpenSpec category and tags\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cursor\n- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Factory Droid\n- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid\n- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched\n- **AND** skip creating missing files during update\n\n#### Scenario: Updating slash commands for OpenCode\n- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments\n\n#### Scenario: Updating slash commands for Windsurf\n- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Kilo Code\n- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Codex\n- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **WHEN** a user runs `openspec update`\n- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance)\n- **AND** preserve any unmanaged content outside the OpenSpec marker block\n- **AND** skip creation when a Codex prompt file is missing\n\n#### Scenario: Updating slash commands for GitHub Copilot\n- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md`\n- **THEN** refresh each file using shared templates while preserving the YAML frontmatter\n- **AND** update only the OpenSpec-managed block between markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Gemini CLI\n- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml`\n- **THEN** refresh the body of each file using the shared proposal/apply/archive templates\n- **AND** replace only the content between `<!-- OPENSPEC:START -->` and `<!-- OPENSPEC:END -->` markers inside the `prompt = \"\"\"` block so the TOML framing (`description`, `prompt`) stays intact\n- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed\n\n#### Scenario: Updating slash commands for iFlow CLI\n- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields\n- **AND** update only the OpenSpec-managed block between markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Missing slash command file\n- **WHEN** a tool lacks a slash command file\n- **THEN** do not create a new file during update\n"
  },
  {
    "path": "openspec/changes/archive/2025-12-30-fix-cline-workflows-implementation/tasks.md",
    "content": "## 1. Update ClineSlashCommandConfigurator\n- [x] Change FILE_PATHS in `src/core/configurators/slash/cline.ts` from `.clinerules/openspec-*.md` to `.clinerules/workflows/openspec-*.md`\n\n## 2. Update Tests\n- [x] Update \"should refresh existing Cline rule files\" test in `test/core/update.test.ts` to use workflow paths\n- [x] Update \"should create Cline rule files with templates\" test in `test/core/init.test.ts` to use workflow paths\n\n## 3. Update Documentation\n- [x] Update README.md table to show \"Workflows in `.clinerules/workflows/` directory\" for Cline\n\n## 4. Validate Changes\n- [x] Ensure all tests pass with the new paths\n- [x] Verify the change follows OpenSpec conventions\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-add-agent-schema-selection/proposal.md",
    "content": "## Why\n\nWith per-change schema metadata in place (see `add-per-change-schema-metadata`), agents can now create changes with different workflow schemas. However, the agent skills are still hardcoded to `spec-driven` artifacts and don't offer schema selection to users.\n\n## What Changes\n\n**Scope: Experimental artifact workflow agent skills**\n\n**Depends on:** `add-per-change-schema-metadata` (must be implemented first)\n\n- Update `openspec-new-change` skill to prompt user for schema selection\n- Update `openspec-continue-change` skill to work with any schema's artifacts\n- Update `openspec-apply-change` skill to handle schema-specific task structures\n- Add schema descriptions to help users choose appropriate workflow\n\n## Capabilities\n\n### Modified Capabilities\n- `cli-artifact-workflow`: Agent skills support dynamic schema selection\n\n## Impact\n\n- **Affected code**: `src/core/templates/skill-templates.ts`\n- **User experience**: Users can choose TDD, spec-driven, or future workflows when starting a change\n- **Agent behavior**: Skills read artifact list from schema rather than hardcoding\n- **Backward compatible**: Default remains `spec-driven` if user doesn't choose\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-add-agent-schema-selection/tasks.md",
    "content": "## Prerequisites\n\n- [x] 0.1 Implement `add-per-change-schema-metadata` change first\n\n## 1. Schema Discovery\n\n- [x] 1.1 Add CLI command or helper to list schemas with descriptions (for agent use)\n- [x] 1.2 Ensure `openspec templates --schema <name>` returns artifact list for any schema\n\n## 2. Update New Change Skill\n\n- [x] 2.1 Add schema selection prompt using AskUserQuestion tool\n- [x] 2.2 Present available schemas with descriptions (spec-driven, tdd, etc.)\n- [x] 2.3 Pass selected schema to `openspec new change --schema <name>`\n- [x] 2.4 Update output to show which schema/workflow was selected\n\n## 3. Update Continue Change Skill\n\n- [x] 3.1 Remove hardcoded artifact references (proposal, specs, design, tasks)\n- [x] 3.2 Read artifact list dynamically from `openspec status --json`\n- [x] 3.3 Adjust artifact creation guidelines to be schema-agnostic\n- [x] 3.4 Handle schema-specific artifact types (e.g., TDD's `tests` artifact)\n\n## 4. Update Apply Change Skill\n\n- [x] 4.1 Make task detection work with different schema structures\n- [x] 4.2 Adjust context file reading for schema-specific artifacts\n\n## 5. Documentation\n\n- [x] 5.1 Add schema descriptions to help text or skill instructions\n- [x] 5.2 Document when to use each schema (TDD for bug fixes, spec-driven for features, etc.)\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-add-per-change-schema-metadata/design.md",
    "content": "## Context\n\nThe experimental artifact workflow supports multiple schemas (`spec-driven`, `tdd`), but schema selection must be passed on every command. This creates friction for agents and users.\n\nWe need a lightweight metadata file to persist the schema choice per change.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Store schema choice once at change creation\n- Auto-detect schema in experimental workflow commands\n- Maintain backward compatibility (no metadata = default)\n- Validate metadata with Zod schema\n\n**Non-Goals:**\n- Migrate existing changes (they use default)\n- Extend to legacy commands\n- Store additional metadata beyond schema (keep minimal for now)\n\n## Decisions\n\n### Decision: Zod Schema Design\n\nThe metadata file (`.openspec.yaml`) will be validated with this Zod schema:\n\n```typescript\n// src/core/artifact-graph/types.ts (or new metadata.ts)\n\nimport { z } from 'zod';\nimport { listSchemas } from './resolver.js';\n\n/**\n * Schema for per-change metadata stored in .openspec.yaml\n */\nexport const ChangeMetadataSchema = z.object({\n  // Required: which workflow schema this change uses\n  schema: z.string().min(1, { message: 'schema is required' }).refine(\n    (val) => listSchemas().includes(val),\n    (val) => ({ message: `Unknown schema '${val}'. Available: ${listSchemas().join(', ')}` })\n  ),\n\n  // Optional: creation timestamp (ISO date string)\n  created: z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/, {\n    message: 'created must be YYYY-MM-DD format'\n  }).optional(),\n});\n\nexport type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;\n```\n\n**Rationale:**\n- `schema` is required and validated against available schemas at parse time\n- `created` is optional, ISO date format for consistency\n- Minimal fields - can extend later without breaking existing files\n- Follows existing codebase pattern (see `ArtifactSchema`, `SchemaYamlSchema`)\n\n### Decision: File Location and Format\n\n**Location:** `openspec/changes/<name>/.openspec.yaml`\n\n**Format:**\n```yaml\nschema: tdd\ncreated: 2025-01-05\n```\n\n**Alternatives considered:**\n- `change.yaml` - less hidden, but clutters directory\n- Frontmatter in `proposal.md` - couples to proposal existence\n- `openspec.json` - YAML matches existing schema files\n\n### Decision: Read/Write Functions\n\n```typescript\n// src/utils/change-metadata.ts\n\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as yaml from 'yaml';\nimport { ChangeMetadataSchema, type ChangeMetadata } from '../core/artifact-graph/types.js';\n\nconst METADATA_FILENAME = '.openspec.yaml';\n\nexport function writeChangeMetadata(\n  changeDir: string,\n  metadata: ChangeMetadata\n): void {\n  // Validate before writing\n  const validated = ChangeMetadataSchema.parse(metadata);\n  const content = yaml.stringify(validated);\n  fs.writeFileSync(path.join(changeDir, METADATA_FILENAME), content);\n}\n\nexport function readChangeMetadata(\n  changeDir: string\n): ChangeMetadata | null {\n  const metaPath = path.join(changeDir, METADATA_FILENAME);\n\n  if (!fs.existsSync(metaPath)) {\n    return null;\n  }\n\n  const content = fs.readFileSync(metaPath, 'utf-8');\n  const parsed = yaml.parse(content);\n\n  // Validate and return (throws ZodError if invalid)\n  return ChangeMetadataSchema.parse(parsed);\n}\n```\n\n### Decision: Schema Resolution Order\n\nWhen determining which schema to use:\n\n1. **Explicit `--schema` flag** (highest priority - user override)\n2. **`.openspec.yaml` metadata** (persisted choice)\n3. **Default `spec-driven`** (fallback)\n\n```typescript\nfunction resolveSchemaForChange(\n  changeDir: string,\n  explicitSchema?: string\n): string {\n  if (explicitSchema) return explicitSchema;\n\n  const metadata = readChangeMetadata(changeDir);\n  if (metadata?.schema) return metadata.schema;\n\n  return 'spec-driven';\n}\n```\n\n## Risks / Trade-offs\n\n- **Extra file per change** → Minimal overhead, hidden file\n- **YAML parsing dependency** → Already using `yaml` package for schema files\n- **Schema validation at read time** → Fail fast with clear error if corrupted\n\n## Migration Plan\n\nNo migration needed:\n- Existing changes without `.openspec.yaml` continue to work (use default)\n- New changes created with `openspec new change --schema X` get metadata file\n\n## Open Questions\n\n- Should `openspec new change` prompt for schema interactively if not specified? (Leaning no - default is fine)\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-add-per-change-schema-metadata/proposal.md",
    "content": "## Why\n\nCurrently, the schema (workflow type) must be passed via `--schema` flag on every experimental workflow command. This is repetitive and error-prone. Agents have no way to know which schema a change uses, so they default to `spec-driven` and cannot leverage alternative workflows like `tdd`.\n\n## What Changes\n\n**Scope: Experimental artifact workflow only** (`openspec new change`, `openspec status`, `openspec instructions`, `openspec templates`)\n\n- Store schema choice in `.openspec.yaml` metadata file when creating a change via `openspec new change`\n- Auto-detect schema from metadata in experimental workflow commands\n- Make `--schema` flag optional (override only, metadata takes precedence)\n- Add `--schema` option to `openspec new change` command\n\n**Not affected**: Legacy commands (`openspec validate`, `openspec archive`, `openspec list`, `openspec show`)\n\n## Capabilities\n\n### New Capabilities\n- `change-metadata`: Reading/writing per-change metadata files\n\n### Modified Capabilities\n- `cli-artifact-workflow`: Commands auto-detect schema from change metadata\n\n## Impact\n\n- **Affected code**: `src/utils/change-utils.ts`, `src/core/artifact-graph/instruction-loader.ts`, `src/commands/artifact-workflow.ts`\n- **Agent skills**: Can be simplified - no longer need to pass schema explicitly\n- **Backward compatible**: Changes without `.openspec.yaml` fall back to `spec-driven` default\n- **Isolation**: All changes contained within experimental workflow code; legacy commands untouched\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-add-per-change-schema-metadata/specs/cli-artifact-workflow/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Change Metadata\n\nThe system SHALL store and validate per-change metadata in `.openspec.yaml` files using a Zod schema.\n\n#### Scenario: Metadata file created with new change\n\n- **WHEN** user runs `openspec new change add-feature --schema tdd`\n- **THEN** the system creates `.openspec.yaml` in the change directory\n- **AND** the file contains `schema: tdd` and `created: <YYYY-MM-DD>`\n\n#### Scenario: Metadata validated on read\n\n- **WHEN** the system reads `.openspec.yaml`\n- **AND** the `schema` field references an unknown schema\n- **THEN** the system displays a validation error listing available schemas\n\n#### Scenario: Metadata schema validation\n\n- **WHEN** `.openspec.yaml` contains invalid YAML or missing required fields\n- **THEN** the system displays a Zod validation error with details\n\n#### Scenario: Missing metadata file\n\n- **WHEN** a change directory has no `.openspec.yaml` file\n- **THEN** the system falls back to the default schema (`spec-driven`)\n\n## MODIFIED Requirements\n\n### Requirement: New Change Command\n\nThe system SHALL create new change directories with validation and optional schema metadata.\n\n#### Scenario: Create valid change\n\n- **WHEN** user runs `openspec new change add-feature`\n- **THEN** the system creates `openspec/changes/add-feature/` directory\n- **AND** creates `.openspec.yaml` with `schema: spec-driven` (default)\n\n#### Scenario: Create change with schema\n\n- **WHEN** user runs `openspec new change add-feature --schema tdd`\n- **THEN** the system creates `openspec/changes/add-feature/` directory\n- **AND** creates `.openspec.yaml` with `schema: tdd`\n\n#### Scenario: Invalid schema on create\n\n- **WHEN** user runs `openspec new change add-feature --schema unknown`\n- **THEN** the system displays an error listing available schemas\n- **AND** does not create the change directory\n\n#### Scenario: Invalid change name\n\n- **WHEN** user runs `openspec new change \"Add Feature\"` with invalid name\n- **THEN** the system displays validation error with guidance\n\n#### Scenario: Duplicate change name\n\n- **WHEN** user runs `openspec new change existing-change` for an existing change\n- **THEN** the system displays an error indicating the change already exists\n\n#### Scenario: Create with description\n\n- **WHEN** user runs `openspec new change add-feature --description \"Add new feature\"`\n- **THEN** the system creates the change directory with description in README.md\n\n### Requirement: Schema Selection\n\nThe system SHALL support custom schema selection for workflow commands, with automatic detection from change metadata.\n\n#### Scenario: Schema auto-detected from metadata\n\n- **WHEN** user runs `openspec status --change <id>` without `--schema`\n- **AND** the change has `.openspec.yaml` with `schema: tdd`\n- **THEN** the system uses the `tdd` schema\n\n#### Scenario: Explicit schema overrides metadata\n\n- **WHEN** user runs `openspec status --change <id> --schema spec-driven`\n- **AND** the change has `.openspec.yaml` with `schema: tdd`\n- **THEN** the system uses `spec-driven` (explicit flag wins)\n\n#### Scenario: Default schema fallback\n\n- **WHEN** user runs workflow commands without `--schema`\n- **AND** the change has no `.openspec.yaml` file\n- **THEN** the system uses the \"spec-driven\" schema\n\n#### Scenario: Custom schema via flag\n\n- **WHEN** user runs `openspec status --change <id> --schema tdd`\n- **THEN** the system uses the specified schema for artifact graph\n\n#### Scenario: Unknown schema\n\n- **WHEN** user specifies an unknown schema\n- **THEN** the system displays an error listing available schemas\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-add-per-change-schema-metadata/tasks.md",
    "content": "## 1. Zod Schema and Types\n\n- [x] 1.1 Add `ChangeMetadataSchema` Zod schema to `src/core/artifact-graph/types.ts`\n- [x] 1.2 Export `ChangeMetadata` type inferred from schema\n\n## 2. Core Metadata Functions\n\n- [x] 2.1 Create `src/utils/change-metadata.ts` with `writeChangeMetadata()` function\n- [x] 2.2 Add `readChangeMetadata()` function with Zod validation\n- [x] 2.3 Update `createChange()` to accept optional `schema` param and write metadata\n\n## 3. Auto-Detection in Instruction Loader\n\n- [x] 3.1 Modify `loadChangeContext()` to read schema from `.openspec.yaml`\n- [x] 3.2 Make `schemaName` parameter optional (fall back to metadata, then default)\n\n## 4. CLI Updates\n\n- [x] 4.1 Add `--schema <name>` option to `openspec new change` command\n- [x] 4.2 Verify existing commands (`status`, `instructions`) work with auto-detection\n\n## 5. Tests\n\n- [x] 5.1 Test `ChangeMetadataSchema` validates correctly (valid/invalid cases)\n- [x] 5.2 Test `writeChangeMetadata()` creates valid YAML\n- [x] 5.3 Test `readChangeMetadata()` parses and validates schema\n- [x] 5.4 Test `loadChangeContext()` auto-detects schema from metadata\n- [x] 5.5 Test fallback to default when no metadata exists\n- [x] 5.6 Test `--schema` flag overrides metadata\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-add-specs-apply-command/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-01-06\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-add-specs-apply-command/design.md",
    "content": "## Context\n\nCurrently, delta specs are only applied to main specs when running `openspec archive`. This bundles two concerns:\n1. Applying spec changes (delta → main)\n2. Archiving the change (move to archive folder)\n\nUsers want flexibility to sync specs earlier, especially when iterating. The archive command already contains the reconciliation logic in `buildUpdatedSpec()`.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Decouple spec syncing from archiving\n- Provide `/opsx:sync` skill for agents to sync specs on demand\n- Keep operation idempotent (safe to run multiple times)\n\n**Non-Goals:**\n- Tracking whether specs have been synced (no state)\n- Changing archive behavior (it will continue to apply specs)\n- Supporting partial application (all deltas sync together)\n\n## Decisions\n\n### 1. Reuse existing reconciliation logic\n\n**Decision**: Extract `buildUpdatedSpec()` logic from `ArchiveCommand` into a shared module.\n\n**Rationale**: The archive command already implements delta parsing and application. Rather than duplicate, we extract and reuse.\n\n**Alternatives considered**:\n- Duplicate logic in new command (rejected: maintenance burden)\n- Have sync call archive with flags (rejected: coupling)\n\n### 2. No state tracking\n\n**Decision**: Don't track whether specs have been synced. Each invocation reads delta and main specs, reconciles.\n\n**Rationale**:\n- Idempotent operations don't need state\n- Avoids sync issues between flag and reality\n- Simpler implementation and mental model\n\n**Alternatives considered**:\n- Track `specsSynced: true` in `.openspec.yaml` (rejected: unnecessary complexity)\n- Store snapshot of synced deltas (rejected: over-engineering)\n\n### 3. Agent-driven approach (no CLI command)\n\n**Decision**: The `/opsx:sync` skill is fully agent-driven - the agent reads delta specs and directly edits main specs.\n\n**Rationale**:\n- Allows intelligent merging (add scenarios without copying entire requirements)\n- Delta represents *intent*, not wholesale replacement\n- More flexible and natural editing workflow\n- Archive still uses programmatic merge (for finalized changes)\n\n### 4. Archive behavior unchanged\n\n**Decision**: Archive continues to apply specs as part of its flow. If specs are already reconciled, the operation is a no-op.\n\n**Rationale**: Backward compatibility. Users who don't use `/opsx:sync` get the same experience.\n\n## Risks / Trade-offs\n\n**[Risk] Multiple changes modify same spec**\n→ Last to sync wins. Same as today with archive. Users should coordinate or use sequential archives.\n\n**[Risk] User syncs specs then continues editing deltas**\n→ Running `/opsx:sync` again reconciles. Idempotent design handles this.\n\n**[Trade-off] No undo mechanism**\n→ Users can `git checkout` main specs if needed. Explicit undo command is out of scope.\n\n## Implementation Approach\n\n1. Extract spec application logic from `ArchiveCommand.buildUpdatedSpec()` into `src/core/specs-apply.ts`\n2. Add skill template for `/opsx:sync` in `skill-templates.ts`\n3. Register skill in managed skills\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-add-specs-apply-command/proposal.md",
    "content": "## Why\n\nSpec application is currently bundled with archive - users must run `openspec archive` to apply delta specs to main specs. This couples two distinct concerns (applying specs vs. archiving the change) and forces users to wait until they're \"done\" to see main specs updated. Users want the flexibility to sync specs earlier in the workflow while iterating.\n\n## What Changes\n\n- Add `/opsx:sync` skill that syncs delta specs to main specs as a standalone action\n- The operation is idempotent - safe to run multiple times, agent reconciles main specs to match deltas\n- Archive continues to work as today (applies specs if not already reconciled, then moves to archive)\n- No new state tracking - the agent reads delta and main specs, reconciles on each run\n- Agent-driven approach allows intelligent merging (partial updates, adding scenarios)\n\n**Workflow becomes:**\n```\n/opsx:new → /opsx:continue → /opsx:apply → archive\n                                  │\n                                  └── /opsx:sync (optional, anytime)\n```\n\n## Capabilities\n\n### New Capabilities\n- `specs-sync-skill`: Skill template for `/opsx:sync` command that reconciles main specs with delta specs\n\n### Modified Capabilities\n- None (agent-driven, no CLI command needed)\n\n## Impact\n\n- **Skills**: New `openspec-sync-specs` skill in `skill-templates.ts`\n- **Archive**: No changes needed - already does reconciliation, will continue to work\n- **Agent workflow**: Users gain flexibility to sync specs before archive\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-add-specs-apply-command/specs/specs-sync-skill/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Specs Sync Skill\nThe system SHALL provide an `/opsx:sync` skill that syncs delta specs from a change to the main specs.\n\n#### Scenario: Sync delta specs to main specs\n- **WHEN** agent executes `/opsx:sync` with a change name\n- **THEN** the agent reads delta specs from `openspec/changes/<name>/specs/`\n- **AND** reads corresponding main specs from `openspec/specs/`\n- **AND** reconciles main specs to match what the deltas describe\n\n#### Scenario: Idempotent operation\n- **WHEN** agent executes `/opsx:sync` multiple times on the same change\n- **THEN** the result is the same as running it once\n- **AND** no duplicate requirements are created\n\n#### Scenario: Change selection prompt\n- **WHEN** agent executes `/opsx:sync` without specifying a change\n- **THEN** the agent prompts user to select from available changes\n- **AND** shows changes that have delta specs\n\n### Requirement: Delta Reconciliation Logic\nThe agent SHALL reconcile main specs with delta specs using the delta operation headers.\n\n#### Scenario: ADDED requirements\n- **WHEN** delta contains `## ADDED Requirements` with a requirement\n- **AND** the requirement does not exist in main spec\n- **THEN** add the requirement to main spec\n\n#### Scenario: ADDED requirement already exists\n- **WHEN** delta contains `## ADDED Requirements` with a requirement\n- **AND** a requirement with the same name already exists in main spec\n- **THEN** update the existing requirement to match the delta version\n\n#### Scenario: MODIFIED requirements\n- **WHEN** delta contains `## MODIFIED Requirements` with a requirement\n- **AND** the requirement exists in main spec\n- **THEN** replace the requirement in main spec with the delta version\n\n#### Scenario: REMOVED requirements\n- **WHEN** delta contains `## REMOVED Requirements` with a requirement name\n- **AND** the requirement exists in main spec\n- **THEN** remove the requirement from main spec\n\n#### Scenario: RENAMED requirements\n- **WHEN** delta contains `## RENAMED Requirements` with FROM:/TO: format\n- **AND** the FROM requirement exists in main spec\n- **THEN** rename the requirement to the TO name\n\n#### Scenario: New capability spec\n- **WHEN** delta spec exists for a capability not in main specs\n- **THEN** create new main spec file at `openspec/specs/<capability>/spec.md`\n\n### Requirement: Skill Output\nThe skill SHALL provide clear feedback on what was synced.\n\n#### Scenario: Show synced changes\n- **WHEN** reconciliation completes successfully\n- **THEN** display summary of changes per capability:\n  - Number of requirements added\n  - Number of requirements modified\n  - Number of requirements removed\n  - Number of requirements renamed\n\n#### Scenario: No changes needed\n- **WHEN** main specs already match delta specs\n- **THEN** display \"Specs already in sync - no changes needed\"\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-add-specs-apply-command/tasks.md",
    "content": "## Tasks\n\n### Core Implementation\n\n- [x] Extract spec application logic from `ArchiveCommand` into `src/core/specs-apply.ts`\n  - Move `buildUpdatedSpec()`, `findSpecUpdates()`, `writeUpdatedSpec()` to shared module\n  - Keep `ArchiveCommand` importing from the new module\n  - Ensure all validation logic is preserved\n\n### Skill Template\n\n- [x] Add `getSyncSpecsSkillTemplate()` function in `src/core/templates/skill-templates.ts`\n  - Skill name: `openspec-sync-specs`\n  - Description: Sync delta specs to main specs\n  - **Agent-driven**: Instructions for agent to read deltas and edit main specs directly\n\n- [x] Add `/opsx:sync` slash command template in `skill-templates.ts`\n  - Mirror the skill template for slash command format\n  - **Agent-driven**: No CLI command, agent does the merge\n\n### Registration\n\n- [x] Register skill in managed skills (via `artifact-experimental-setup`)\n  - Add to skill list with appropriate metadata\n  - Ensure it appears in setup output\n\n### Design Decision\n\n**Why agent-driven instead of CLI-driven?**\n\nThe programmatic merge operates at requirement-level granularity:\n- MODIFIED requires copying ALL scenarios, not just the changed ones\n- If agent forgets a scenario, it gets deleted\n- Delta specs become bloated with copied content\n\nAgent-driven approach:\n- Agent can apply partial updates (add a scenario without copying others)\n- Delta represents *intent*, not wholesale replacement\n- More flexible and natural editing workflow\n- Archive still uses programmatic merge (for finalized changes)\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-make-apply-instructions-schema-aware/proposal.md",
    "content": "## Why\n\nThe `generateApplyInstructions` function is hardcoded to check for `spec-driven` artifacts (`proposal.md`, `specs/`, `design.md`, `tasks.md`). If a user selects a different schema like `tdd`, the apply instructions are meaningless - they check for files that don't exist in that schema.\n\nThis blocks the experimental workflow from supporting multiple schemas properly.\n\n## What Changes\n\n**Scope: Experimental artifact workflow** (`openspec instructions apply`)\n\n**Depends on:** `add-per-change-schema-metadata` (to know which schema a change uses)\n\n- Make `generateApplyInstructions` read artifact definitions from the schema\n- Dynamically determine which artifacts exist based on schema\n- Define when a change becomes \"implementable\" (see Design Decision below)\n- Generate schema-appropriate context files and instructions\n\n## Design Decision: When is a change implementable?\n\nThis is the key question. Different approaches:\n\n### Option A: Explicit `apply` artifact in schema\n\nAdd a field to mark which artifact is the \"implementation gate\":\n\n```yaml\nartifacts:\n  - id: tasks\n    generates: tasks.md\n    apply: true  # ← This artifact triggers apply mode\n```\n\n**Pros:** Explicit, flexible\n**Cons:** Another field to maintain, what if multiple artifacts are `apply: true`?\n\n### Option B: Leaf artifacts are implementable\n\nThe artifact(s) with no dependents (nothing depends on them) are the apply target.\n\n- `spec-driven`: `tasks` is a leaf → apply = execute tasks\n- `tdd`: `docs` is a leaf → but that doesn't make sense for TDD...\n\n**Pros:** No extra schema field, derived from graph\n**Cons:** Doesn't match TDD semantics (implementation is the action, not docs)\n\n### Option C: Schema-level `apply_phase` definition\n\nAdd a top-level field to the schema:\n\n```yaml\nname: spec-driven\napply_phase:\n  requires: [tasks]  # Must exist before apply\n  tracks: tasks.md   # File with checkboxes to track\n  instruction: \"Work through tasks, mark complete as you go\"\n```\n\n```yaml\nname: tdd\napply_phase:\n  requires: [tests]  # Must have tests before implementing\n  tracks: null       # No checkbox tracking - just make tests pass\n  instruction: \"Run tests, implement until green, refactor\"\n```\n\n**Pros:** Full flexibility, schema controls its own apply semantics\n**Cons:** More complex schema format\n\n### Option D: Convention-based (artifact ID matching)\n\nIf artifact ID is `tasks` or `implementation`, it's the apply target.\n\n**Pros:** Simple, no schema changes\n**Cons:** Brittle, doesn't work for custom schemas\n\n### Option E: All artifacts complete → apply available\n\nApply becomes available when ALL schema artifacts exist. Implementation is whatever the user does after planning.\n\n**Pros:** Simple, no schema changes\n**Cons:** Doesn't guide what \"apply\" means for different workflows\n\n---\n\n## Decision: Add `apply` block to schema.yaml\n\nAdd a top-level `apply` field to schema definitions:\n\n```yaml\nname: spec-driven\nversion: 1\ndescription: Default OpenSpec workflow\n\nartifacts:\n  # ... existing artifacts ...\n\napply:\n  requires: [tasks]           # Artifacts that must exist before apply\n  tracks: tasks.md            # File with checkboxes for progress (optional)\n  instruction: |              # Guidance shown to agent\n    Read context files, work through pending tasks, mark complete as you go.\n    Pause if you hit blockers or need clarification.\n```\n\n```yaml\nname: tdd\nversion: 1\ndescription: Test-driven development workflow\n\nartifacts:\n  # ... existing artifacts ...\n\napply:\n  requires: [tests]           # Must have tests before implementing\n  tracks: null                # No checkbox tracking\n  instruction: |\n    Run tests to see failures. Implement minimal code to pass each test.\n    Refactor while keeping tests green.\n```\n\n**Key properties:**\n- `requires`: Array of artifact IDs that must exist before apply is available\n- `tracks`: Path to file with checkboxes (relative to change dir), or `null` if no tracking\n- `instruction`: Custom guidance for the apply phase\n\n**Fallback behavior:** Schemas without `apply` block default to \"all artifacts must exist\"\n\n## Capabilities\n\n### Modified Capabilities\n- `cli-artifact-workflow`: Apply instructions become schema-aware\n\n## Impact\n\n- **Affected code**: `src/commands/artifact-workflow.ts` (generateApplyInstructions)\n- **Schema format**: May need new `apply_phase` field\n- **Existing schemas**: Need to add apply_phase to `spec-driven` and `tdd`\n- **Backward compatible**: Schemas without apply_phase can use default behavior\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-make-apply-instructions-schema-aware/specs/cli-artifact-workflow/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Schema Apply Block\n\nThe system SHALL support an `apply` block in schema definitions that controls when and how implementation begins.\n\n#### Scenario: Schema with apply block\n\n- **WHEN** a schema defines an `apply` block\n- **THEN** the system uses `apply.requires` to determine which artifacts must exist before apply\n- **AND** uses `apply.tracks` to identify the file for progress tracking (or null if none)\n- **AND** uses `apply.instruction` for guidance shown to the agent\n\n#### Scenario: Schema without apply block\n\n- **WHEN** a schema has no `apply` block\n- **THEN** the system requires all artifacts to exist before apply is available\n- **AND** uses default instruction: \"All artifacts complete. Proceed with implementation.\"\n\n### Requirement: Apply Instructions Command\n\nThe system SHALL generate schema-aware apply instructions via `openspec instructions apply`.\n\n#### Scenario: Generate apply instructions\n\n- **WHEN** user runs `openspec instructions apply --change <id>`\n- **AND** all required artifacts (per schema's `apply.requires`) exist\n- **THEN** the system outputs:\n  - Context files from all existing artifacts\n  - Schema-specific instruction text\n  - Progress tracking file path (if `apply.tracks` is set)\n\n#### Scenario: Apply blocked by missing artifacts\n\n- **WHEN** user runs `openspec instructions apply --change <id>`\n- **AND** required artifacts are missing\n- **THEN** the system indicates apply is blocked\n- **AND** lists which artifacts must be created first\n\n#### Scenario: Apply instructions JSON output\n\n- **WHEN** user runs `openspec instructions apply --change <id> --json`\n- **THEN** the system outputs JSON with:\n  - `contextFiles`: array of paths to existing artifacts\n  - `instruction`: the apply instruction text\n  - `tracks`: path to progress file or null\n  - `applyRequires`: list of required artifact IDs\n\n## MODIFIED Requirements\n\n### Requirement: Status Command\n\nThe system SHALL display artifact completion status for a change, including apply readiness.\n\n#### Scenario: Status JSON includes apply requirements\n\n- **WHEN** user runs `openspec status --change <id> --json`\n- **THEN** the system outputs JSON with:\n  - `changeName`, `schemaName`, `isComplete`, `artifacts` array\n  - `applyRequires`: array of artifact IDs needed for apply phase\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-make-apply-instructions-schema-aware/tasks.md",
    "content": "## Prerequisites\n\n- [x] 0.1 Implement `add-per-change-schema-metadata` first (to auto-detect schema)\n\n## 1. Schema Format\n\n- [x] 1.1 Add `ApplyPhaseSchema` Zod schema to `src/core/artifact-graph/types.ts`\n- [x] 1.2 Update `SchemaYamlSchema` to include optional `apply` field\n- [x] 1.3 Export `ApplyPhase` type\n\n## 2. Update Existing Schemas\n\n- [x] 2.1 Add `apply` block to `schemas/spec-driven/schema.yaml`\n- [x] 2.2 Add `apply` block to `schemas/tdd/schema.yaml`\n\n## 3. Refactor generateApplyInstructions\n\n- [x] 3.1 Load schema via `resolveSchema(schemaName)`\n- [x] 3.2 Read `apply.requires` to determine required artifacts\n- [x] 3.3 Check artifact existence dynamically (not hardcoded paths)\n- [x] 3.4 Use `apply.tracks` for progress tracking (or skip if null)\n- [x] 3.5 Use `apply.instruction` for the instruction text\n- [x] 3.6 Build `contextFiles` from all existing artifacts in schema\n\n## 4. Handle Fallback\n\n- [x] 4.1 If schema has no `apply` block, require all artifacts to exist\n- [x] 4.2 Default instruction: \"All artifacts complete. Proceed with implementation.\"\n\n## 5. Tests\n\n- [x] 5.1 Test apply instructions with spec-driven schema\n- [x] 5.2 Test apply instructions with tdd schema\n- [x] 5.3 Test fallback when schema has no apply block\n- [x] 5.4 Test blocked state when required artifacts missing\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-opsx-archive-command/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-01-07\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-opsx-archive-command/design.md",
    "content": "## Context\n\nThe experimental workflow (OPSX) provides a complete lifecycle for creating changes:\n- `/opsx:new` - Scaffold a new change with schema\n- `/opsx:continue` - Create next artifact\n- `/opsx:ff` - Fast-forward all artifacts\n- `/opsx:apply` - Implement tasks\n- `/opsx:sync` - Sync delta specs to main\n\nThe missing piece is archiving. The existing `openspec archive` command works but:\n1. Applies specs programmatically (not agent-driven)\n2. Doesn't use the artifact graph for completion checking\n3. Doesn't integrate with the OPSX workflow philosophy\n\n## Goals / Non-Goals\n\n**Goals:**\n- Add `/opsx:archive` skill to complete the OPSX workflow lifecycle\n- Use artifact graph for schema-aware completion checking\n- Integrate with `/opsx:sync` for agent-driven spec syncing\n- Preserve `.openspec.yaml` schema metadata in archive\n\n**Non-Goals:**\n- Replacing the existing `openspec archive` CLI command\n- Changing how specs are applied in the CLI command\n- Modifying the artifact graph or schema system\n\n## Decisions\n\n### Decision 1: Skill-only implementation (no new CLI command)\n\nThe `/opsx:archive` will be a slash command/skill only, not a new CLI command.\n\n**Rationale**: The existing `openspec archive` CLI command already handles the core archive functionality (moving to archive folder, date prefixing). The OPSX version just needs different pre-archive checks and optional sync prompting, which are agent behaviors better suited to a skill.\n\n**Alternatives considered**:\n- Adding flags to `openspec archive` (e.g., `--experimental`) - Rejected: adds complexity to CLI, harder to maintain two code paths\n- New CLI command `openspec archive-experimental` - Rejected: unnecessary duplication, agent skills are the OPSX pattern\n\n### Decision 2: Prompt for sync before archive\n\nThe skill will check for unsynced delta specs and prompt the user before archiving.\n\n**Rationale**: The OPSX philosophy is agent-driven intelligent merging via `/opsx:sync`. Rather than programmatically applying specs like the regular archive command, we prompt the user to sync first if needed. This maintains workflow flexibility (user can decline and just archive).\n\n**Flow**:\n1. Check if `specs/` directory exists in the change\n2. If yes, ask: \"This change has delta specs. Would you like to sync them to main specs before archiving?\"\n3. If user says yes, execute `/opsx:sync` logic\n4. Proceed with archive regardless of answer\n\n### Decision 3: Use artifact graph for completion checking\n\nThe skill will use `openspec status --change \"<name>\" --json` to check artifact completion instead of just validating proposal.md and specs.\n\n**Rationale**: The experimental workflow is schema-aware. Different schemas have different required artifacts. The artifact graph knows which artifacts are complete/incomplete for the current schema.\n\n**Behavior**:\n- Show warning if any artifacts are not `done`\n- Don't block archive (user may have valid reasons to archive early)\n- List incomplete artifacts so user can make informed decision\n\n### Decision 4: Reuse tasks.md completion check from regular archive\n\nThe skill will parse tasks.md and warn about incomplete tasks, same as regular archive.\n\n**Rationale**: Task completion checking is valuable regardless of workflow. The logic is simple (count `- [ ]` vs `- [x]`) and doesn't need special OPSX handling.\n\n### Decision 5: Move change to archive/ with date prefix\n\nSame archive behavior as regular command: move to `openspec/changes/archive/YYYY-MM-DD-<name>/`.\n\n**Rationale**: Consistency with existing archive convention. The `.openspec.yaml` file moves with the change, preserving schema metadata.\n\n## Risks / Trade-offs\n\n**Risk**: Users confused about when to use `/opsx:archive` vs `openspec archive`\n→ **Mitigation**: Documentation should clarify: use `/opsx:archive` if you've been using the OPSX workflow, use `openspec archive` otherwise. Both produce the same archived result.\n\n**Risk**: Incomplete sync if user declines and has delta specs\n→ **Mitigation**: The prompt is informational; user has full control. They may want to archive without syncing (e.g., abandoned change). Log a note in output.\n\n**Trade-off**: No programmatic spec application in OPSX archive\n→ **Accepted**: This is intentional. OPSX philosophy is agent-driven merging. If user wants programmatic application, use `openspec archive` instead.\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-opsx-archive-command/proposal.md",
    "content": "## Why\n\nThe experimental workflow (OPSX) provides a schema-driven, artifact-by-artifact approach to creating changes with `/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:apply`, and `/opsx:sync`. However, there's no corresponding archive command to finalize and archive completed changes. Users must currently fall back to the regular `openspec archive` command, which doesn't integrate with the OPSX philosophy of agent-driven spec syncing and schema-aware artifact tracking.\n\n## What Changes\n\n- Add `/opsx:archive` slash command for archiving changes in the experimental workflow\n- Use artifact graph to check completion status (schema-aware) instead of just validating proposal + specs\n- Prompt for `/opsx:sync` before archiving instead of programmatically applying specs\n- Preserve `.openspec.yaml` schema metadata when moving to archive\n- Integrate with existing OPSX commands for a cohesive workflow\n\n## Capabilities\n\n### New Capabilities\n\n- `opsx-archive-skill`: Slash command and skill for archiving completed changes in the experimental workflow. Checks artifact completion via artifact graph, verifies task completion, optionally syncs specs via `/opsx:sync`, and moves the change to `archive/YYYY-MM-DD-<name>/`.\n\n### Modified Capabilities\n\n(none - this is a new skill that doesn't modify existing specs)\n\n## Impact\n\n- New file: `.claude/commands/opsx/archive.md`\n- New skill definition (generated via `openspec artifact-experimental-setup`)\n- No changes to existing archive command or other OPSX commands\n- Completes the OPSX command suite for full lifecycle management\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-opsx-archive-command/specs/opsx-archive-skill/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: OPSX Archive Skill\n\nThe system SHALL provide an `/opsx:archive` skill that archives completed changes in the experimental workflow.\n\n#### Scenario: Archive a change with all artifacts complete\n\n- **WHEN** agent executes `/opsx:archive` with a change name\n- **AND** all artifacts in the schema are complete\n- **AND** all tasks are complete\n- **THEN** the agent moves the change to `openspec/changes/archive/YYYY-MM-DD-<name>/`\n- **AND** displays success message with archived location\n\n#### Scenario: Change selection prompt\n\n- **WHEN** agent executes `/opsx:archive` without specifying a change\n- **THEN** the agent prompts user to select from available changes\n- **AND** shows only active changes (excludes archive/)\n\n### Requirement: Artifact Completion Check\n\nThe skill SHALL check artifact completion status using the artifact graph before archiving.\n\n#### Scenario: Incomplete artifacts warning\n\n- **WHEN** agent checks artifact status\n- **AND** one or more artifacts have status other than `done`\n- **THEN** display warning listing incomplete artifacts\n- **AND** prompt user for confirmation to continue\n- **AND** proceed if user confirms\n\n#### Scenario: All artifacts complete\n\n- **WHEN** agent checks artifact status\n- **AND** all artifacts have status `done`\n- **THEN** proceed without warning\n\n### Requirement: Task Completion Check\n\nThe skill SHALL check task completion status from tasks.md before archiving.\n\n#### Scenario: Incomplete tasks found\n\n- **WHEN** agent reads tasks.md\n- **AND** incomplete tasks are found (marked with `- [ ]`)\n- **THEN** display warning showing count of incomplete tasks\n- **AND** prompt user for confirmation to continue\n- **AND** proceed if user confirms\n\n#### Scenario: All tasks complete\n\n- **WHEN** agent reads tasks.md\n- **AND** all tasks are complete (marked with `- [x]`)\n- **THEN** proceed without task-related warning\n\n#### Scenario: No tasks file\n\n- **WHEN** tasks.md does not exist\n- **THEN** proceed without task-related warning\n\n### Requirement: Spec Sync Prompt\n\nThe skill SHALL prompt to sync delta specs before archiving if specs exist.\n\n#### Scenario: Delta specs exist\n\n- **WHEN** agent checks for delta specs\n- **AND** `specs/` directory exists in the change with spec files\n- **THEN** prompt user: \"This change has delta specs. Would you like to sync them to main specs before archiving?\"\n- **AND** if user confirms, execute `/opsx:sync` logic\n- **AND** proceed with archive regardless of sync choice\n\n#### Scenario: No delta specs\n\n- **WHEN** agent checks for delta specs\n- **AND** no `specs/` directory or no spec files exist\n- **THEN** proceed without sync prompt\n\n### Requirement: Archive Process\n\nThe skill SHALL move the change to the archive folder with date prefix.\n\n#### Scenario: Successful archive\n\n- **WHEN** archiving a change\n- **THEN** create `archive/` directory if it doesn't exist\n- **AND** generate target name as `YYYY-MM-DD-<change-name>` using current date\n- **AND** move entire change directory to archive location\n- **AND** preserve `.openspec.yaml` file in archived change\n\n#### Scenario: Archive already exists\n\n- **WHEN** target archive directory already exists\n- **THEN** fail with error message\n- **AND** suggest renaming existing archive or using different date\n\n### Requirement: Skill Output\n\nThe skill SHALL provide clear feedback about the archive operation.\n\n#### Scenario: Archive complete with sync\n\n- **WHEN** archive completes after syncing specs\n- **THEN** display summary:\n  - Specs synced (from `/opsx:sync` output)\n  - Change archived to location\n  - Schema that was used\n\n#### Scenario: Archive complete without sync\n\n- **WHEN** archive completes without syncing specs\n- **THEN** display summary:\n  - Note that specs were not synced (if applicable)\n  - Change archived to location\n  - Schema that was used\n\n#### Scenario: Archive complete with warnings\n\n- **WHEN** archive completes with incomplete artifacts or tasks\n- **THEN** include note about what was incomplete\n- **AND** suggest reviewing if archive was intentional\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-06-opsx-archive-command/tasks.md",
    "content": "## 1. Create Slash Command\n\n- [x] 1.1 Create `.claude/commands/opsx/archive.md` with skill definition\n- [x] 1.2 Add YAML frontmatter (name, description, category, tags)\n- [x] 1.3 Implement change selection logic (prompt if not provided)\n- [x] 1.4 Implement artifact completion check using `openspec status --json`\n- [x] 1.5 Implement task completion check (parse tasks.md for `- [ ]`)\n- [x] 1.6 Implement spec sync prompt (check for specs/ directory, offer `/opsx:sync`)\n- [x] 1.7 Implement archive process (move to archive/YYYY-MM-DD-<name>/)\n- [x] 1.8 Add output formatting for success/warning cases\n\n## 2. Regenerate Skills\n\n- [x] 2.1 Run `openspec artifact-experimental-setup` to regenerate skills\n- [x] 2.2 Verify skill appears in `.claude/skills/` directory\n\n## 3. Testing\n\n- [x] 3.1 Test `/opsx:archive` with a complete change (all artifacts, all tasks done)\n- [x] 3.2 Test `/opsx:archive` with incomplete artifacts (verify warning shown)\n- [x] 3.3 Test `/opsx:archive` with incomplete tasks (verify warning shown)\n- [x] 3.4 Test `/opsx:archive` with delta specs (verify sync prompt shown)\n- [x] 3.5 Test `/opsx:archive` without change name (verify selection prompt)\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-07-add-nix-flake-support/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-01-07\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-07-add-nix-flake-support/design.md",
    "content": "## Context\n\nOpenSpec is a TypeScript CLI tool using pnpm for dependency management. The project requires Node.js ≥20.19.0. Nix uses its own build system that needs to understand how to fetch dependencies and build the project reproducibly.\n\nThe Nix ecosystem has specific patterns for packaging Node.js/pnpm projects that differ from the traditional npm ecosystem.\n\n## Goals\n\n- Enable OpenSpec to be run directly via `nix run github:Fission-AI/OpenSpec`\n- Support all major platforms (Linux x86/ARM, macOS x86/ARM)\n- Use existing pnpm-lock.yaml for reproducible builds\n- Provide development environment for Nix users\n\n## Non-Goals\n\n- Replace existing npm/pnpm publishing workflow\n- Publish to nixpkgs (can be done later as separate effort)\n- Support Windows (Nix doesn't run natively on Windows)\n\n## Decisions\n\n### Use stdenv.mkDerivation instead of buildNpmPackage\n\n**Decision**: Package OpenSpec using `stdenv.mkDerivation` with pnpm hooks.\n\n**Rationale**: The zigbee2mqtt package in nixpkgs demonstrates the current best practice for pnpm projects. Using `buildNpmPackage` with pnpm requires complex configuration, while `mkDerivation` with the right hooks is more straightforward and better supported.\n\n**Alternative considered**: Using `buildNpmPackage` with `npmConfigHook = pkgs.pnpmConfigHook` - this is the older pattern and causes issues with dependency fetching.\n\n### Use fetchPnpmDeps with explicit pnpm version\n\n**Decision**: Use `pkgs.fetchPnpmDeps` with `pnpm = pkgs.pnpm_9` and `fetcherVersion = 3`.\n\n**Rationale**:\n- pnpm lockfile version 9.0 requires fetcherVersion 3\n- Explicit pnpm_9 ensures consistency between fetch and build\n- This is the documented way to handle pnpm projects in nixpkgs\n\n### Multi-platform support without flake-utils\n\n**Decision**: Implement multi-platform support using plain Nix with `nixpkgs.lib.genAttrs`.\n\n**Rationale**: Per user request, avoid extra dependencies. The `genAttrs` pattern is simple and well-understood in the Nix community.\n\n### Node.js 20 instead of latest\n\n**Decision**: Pin to nodejs_20 to match package.json engines requirement.\n\n**Rationale**: Ensures consistency with development environment and npm package requirements. Avoids potential compatibility issues with newer Node versions.\n\n## Key Implementation Details\n\n### Dependency Hash Management\n\nThe `pnpmDeps.hash` field must be updated whenever dependencies change. The workflow:\n1. Set hash to fake value (all zeros)\n2. Run `nix build`\n3. Nix fails with actual hash\n4. Update flake.nix with correct hash\n\nThis is standard Nix workflow for fixed-output derivations.\n\n### Build Inputs\n\nRequired nativeBuildInputs:\n- `nodejs_20` - runtime\n- `npmHooks.npmInstallHook` - handles installation phase\n- `pnpmConfigHook` - configures pnpm environment\n- `pnpm_9` - pnpm executable\n\nThe `dontNpmPrune = true` is important to keep all dependencies after build.\n\n## Risks / Trade-offs\n\n**[Risk]** Hash needs updating when dependencies change → **Mitigation**: Document this clearly; error message from Nix provides correct hash\n\n**[Risk]** Nix builds might lag behind npm releases → **Mitigation**: This is fine; Nix users can still use npm if they need bleeding edge\n\n**[Trade-off]** Additional maintenance burden for hash updates → **Benefit**: Better experience for Nix ecosystem users\n\n## Migration Plan\n\n1. Add flake.nix to repository\n2. Test builds on multiple platforms (can use GitHub Actions with Nix)\n3. Update README with Nix installation instructions\n4. Optionally add to CI pipeline to catch hash mismatches early\n\nNo breaking changes - this is purely additive.\n\n## Open Questions\n\n- Should we add automatic hash updating to CI? (Could use nix-update-script)\n- Should we submit to nixpkgs after validation? (Separate decision)\n- Do we want to support older Node versions in flake? (Probably no - stick to package.json requirement)\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-07-add-nix-flake-support/proposal.md",
    "content": "## Why\n\nOpenSpec users on NixOS or using the Nix package manager cannot easily install or run OpenSpec without going through npm. Adding a Nix flake makes OpenSpec a first-class citizen in the Nix ecosystem, enabling users to run `nix run github:Fission-AI/OpenSpec -- init` or include OpenSpec in their development environments declaratively.\n\n## What Changes\n\n- Add `flake.nix` to repository root with multi-platform support (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin)\n- Package uses pnpm for dependency management (matching existing development workflow)\n- Support both direct execution via `nix run` and installation via `nix profile install`\n- Provide dev shell for contributors using Nix\n\n## Capabilities\n\n### New Capabilities\n- `nix-flake-support`: Nix flake configuration for building and running OpenSpec\n\n### Modified Capabilities\n- None\n\n## Impact\n\n- **New files**: `flake.nix` in repository root\n- **Documentation**: Should add installation instructions for Nix users\n- **CI/CD**: Could add flake checking to CI pipeline (optional)\n- **Maintenance**: Requires updating pnpmDeps hash when dependencies change\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-07-add-nix-flake-support/specs/nix-flake-support/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Multi-platform Nix flake\nThe system SHALL provide a Nix flake that builds OpenSpec for multiple platforms.\n\n#### Scenario: Build on Linux x86_64\n- **WHEN** user runs `nix build` on x86_64-linux system\n- **THEN** system builds OpenSpec package successfully\n- **AND** package includes the `openspec` binary\n\n#### Scenario: Build on macOS ARM\n- **WHEN** user runs `nix build` on aarch64-darwin system\n- **THEN** system builds OpenSpec package successfully\n- **AND** package includes the `openspec` binary\n\n#### Scenario: Build on Linux ARM\n- **WHEN** user runs `nix build` on aarch64-linux system\n- **THEN** system builds OpenSpec package successfully\n\n#### Scenario: Build on macOS x86_64\n- **WHEN** user runs `nix build` on x86_64-darwin system\n- **THEN** system builds OpenSpec package successfully\n\n### Requirement: Direct execution via nix run\nThe system SHALL allow users to run OpenSpec directly from GitHub without installing.\n\n#### Scenario: Run init command from GitHub\n- **WHEN** user runs `nix run github:Fission-AI/OpenSpec -- init`\n- **THEN** system downloads and builds OpenSpec\n- **AND** executes `openspec init` command\n\n#### Scenario: Run any OpenSpec command\n- **WHEN** user runs `nix run github:Fission-AI/OpenSpec -- <command> <args>`\n- **THEN** system executes `openspec <command> <args>`\n\n### Requirement: pnpm dependency management\nThe system SHALL use pnpm for building OpenSpec in the Nix flake.\n\n#### Scenario: Fetch dependencies with pnpm\n- **WHEN** Nix builds the package\n- **THEN** system uses `fetchPnpmDeps` to download dependencies\n- **AND** uses pnpm-lock.yaml for reproducible builds\n- **AND** uses fetcherVersion 3 for lockfile version 9.0\n\n#### Scenario: Build with pnpm\n- **WHEN** Nix runs the build phase\n- **THEN** system executes `pnpm run build`\n- **AND** produces dist directory with compiled TypeScript\n\n### Requirement: Node.js version compatibility\nThe system SHALL use Node.js 20 as specified in package.json engines field.\n\n#### Scenario: Build with correct Node version\n- **WHEN** Nix builds OpenSpec\n- **THEN** system uses nodejs_20 from nixpkgs\n- **AND** build succeeds without version compatibility errors\n\n### Requirement: Development shell\nThe system SHALL provide a Nix development shell for contributors.\n\n#### Scenario: Enter dev shell\n- **WHEN** user runs `nix develop` in OpenSpec repository\n- **THEN** system provides shell with nodejs_20 and pnpm_9\n- **AND** displays welcome message with versions\n- **AND** provides instructions to run `pnpm install`\n\n### Requirement: Proper binary installation\nThe system SHALL install the openspec binary correctly.\n\n#### Scenario: Binary in PATH\n- **WHEN** package is built or installed\n- **THEN** `openspec` binary is available in `$out/bin/openspec`\n- **AND** binary is executable\n- **AND** binary can be invoked without full path when installed\n\n#### Scenario: Binary executes correctly\n- **WHEN** user runs the installed `openspec` command\n- **THEN** system executes the CLI entry point\n- **AND** all subcommands work correctly\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-07-add-nix-flake-support/tasks.md",
    "content": "## 1. Create Flake Structure\n\n- [x] 1.1 Create flake.nix in repository root\n- [x] 1.2 Define inputs (nixpkgs only, no flake-utils)\n- [x] 1.3 Set up supportedSystems list (4 platforms)\n- [x] 1.4 Create forAllSystems helper function\n\n## 2. Configure Package Build\n\n- [x] 2.1 Set up stdenv.mkDerivation with finalAttrs pattern\n- [x] 2.2 Configure pnpmDeps with fetchPnpmDeps\n- [x] 2.3 Set pnpm = pnpm_9 and fetcherVersion = 3\n- [x] 2.4 Add placeholder hash (all zeros)\n- [x] 2.5 Configure nativeBuildInputs (nodejs_20, hooks, pnpm_9)\n- [x] 2.6 Set dontNpmPrune = true\n\n## 3. Define Build Phase\n\n- [x] 3.1 Add buildPhase with runHook preBuild\n- [x] 3.2 Add pnpm run build command\n- [x] 3.3 Add runHook postBuild\n\n## 4. Configure Installation\n\n- [x] 4.1 Let npmInstallHook handle installation automatically\n- [x] 4.2 Verify binary ends up in $out/bin/openspec\n\n## 5. Add Metadata\n\n- [x] 5.1 Set meta.description\n- [x] 5.2 Set meta.homepage\n- [x] 5.3 Set meta.license (MIT)\n- [x] 5.4 Set meta.mainProgram = \"openspec\"\n\n## 6. Configure App Entry Point\n\n- [x] 6.1 Add apps output with forAllSystems\n- [x] 6.2 Set default app to openspec binary\n- [x] 6.3 Test that nix run works\n\n## 7. Add Development Shell\n\n- [x] 7.1 Add devShells output with forAllSystems\n- [x] 7.2 Include nodejs_20 and pnpm_9 in buildInputs\n- [x] 7.3 Add shellHook with welcome message and instructions\n\n## 8. Get Correct Dependency Hash\n\n- [x] 8.1 Run nix build to trigger hash mismatch\n- [x] 8.2 Copy correct hash from error message\n- [x] 8.3 Update pnpmDeps.hash in flake.nix\n- [x] 8.4 Verify build succeeds\n\n## 9. Testing\n\n- [x] 9.1 Test `nix build` on x86_64-linux\n- [x] 9.2 Test `nix run . -- --version` works\n- [x] 9.3 Test `nix develop` provides correct environment\n- [ ] 9.4 Test on macOS if available\n- [ ] 9.5 Test `nix run github:Fission-AI/OpenSpec -- init` after merge to main\n\n## 10. Documentation\n\n- [x] 10.1 Add Nix installation section to README\n- [x] 10.2 Include example commands for common Nix workflows in README\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-add-flake-update-script/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-01-09\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-add-flake-update-script/design.md",
    "content": "## Context\n\nThe Nix flake added in the previous change requires manual maintenance when:\n1. Package version changes (must update flake.nix version field)\n2. Dependencies change (must update pnpmDeps hash)\n\nCurrently this requires maintainers to:\n- Manually edit flake.nix version\n- Set placeholder hash\n- Run nix build to get error\n- Copy hash from error message\n- Update flake.nix again\n- Verify build works\n\nThis is tedious and error-prone, especially for maintainers unfamiliar with Nix.\n\n## Goals\n\n- Automate version and hash updates for flake.nix\n- Make script idempotent and safe to run multiple times\n- Provide clear feedback during execution\n- Integrate easily into release workflow\n\n## Non-Goals\n\n- Automatically commit changes (maintainer decides when to commit)\n- Support non-pnpm package managers\n- Handle complex Nix configurations beyond OpenSpec's use case\n\n## Decisions\n\n### Use Bash instead of Node.js script\n\n**Decision**: Implement as bash script rather than Node.js.\n\n**Rationale**:\n- Needs to call Nix commands which are bash-native\n- Parsing Nix output is simpler in bash with grep/sed\n- Maintainers updating flake.nix likely have Nix installed (bash environment)\n- Node.js would add unnecessary complexity for shell operations\n\n**Alternative considered**: Node.js script with child_process - adds dependency on extra npm packages for shell operations, less natural for Nix tooling.\n\n### Extract hash from build error output\n\n**Decision**: Trigger intentional build failure with placeholder hash to get correct hash.\n\n**Rationale**: This is the standard Nix workflow for updating fixed-output derivations. No API exists to compute the hash without building.\n\n**Alternative considered**: Pre-compute hash from pnpm-lock.yaml - would require understanding Nix's hash algorithm and pnpm's lockfile structure, fragile and non-standard.\n\n### Use sed for in-place file editing\n\n**Decision**: Use `sed -i` for updating flake.nix in place.\n\n**Rationale**: Simple, available on all Unix-like systems, handles the specific replacement patterns needed.\n\n**Alternative considered**:\n- Using Node.js to parse/modify: Overkill for simple string replacement\n- Manual `sed` without `-i`: Requires temp files, more complex\n\n### Verify build after hash update\n\n**Decision**: Always run verification build after updating hash.\n\n**Rationale**: Catches errors immediately, gives maintainer confidence the update worked.\n\n**Trade-off**: Takes extra time (~30s) but prevents broken flake.nix commits.\n\n## Key Implementation Details\n\n### Path Resolution\n\nScript calculates paths relative to its own location:\n```bash\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPROJECT_ROOT=\"$(cd \"$SCRIPT_DIR/..\" && pwd)\"\n```\n\nThis allows running from any working directory.\n\n### Error Handling\n\nUses `set -euo pipefail` for strict error handling:\n- `-e`: Exit on any command failure\n- `-u`: Exit on undefined variable access\n- `-o pipefail`: Catch failures in pipes\n\n### Hash Extraction Pattern\n\nUses grep with Perl regex to extract hash:\n```bash\ngrep -oP 'got:\\s+\\Ksha256-[A-Za-z0-9+/=]+'\n```\n\nThis reliably extracts the hash regardless of surrounding text.\n\n## Risks / Trade-offs\n\n**[Risk]** Script assumes standard Nix error message format → **Mitigation**: If extraction fails, script exits with error and shows full output\n\n**[Risk]** Build might fail for reasons other than hash mismatch → **Mitigation**: Script checks for hash in output before proceeding\n\n**[Trade-off]** Requires Nix installed to run → **Benefit**: Only maintainers updating flake need to run this, and they have Nix\n\n## Migration Plan\n\n1. Add script to scripts directory\n2. Document in scripts/README.md\n3. Use in next version bump to verify workflow\n4. Update CONTRIBUTING.md if needed to mention script\n\nNo breaking changes - purely additive tooling.\n\n## Open Questions\n\nNone - straightforward automation script.\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-add-flake-update-script/proposal.md",
    "content": "## Why\n\nMaintaining the Nix flake requires manual updates to version and dependency hash when releasing new versions or updating dependencies. This is error-prone and requires maintainers to understand Nix internals. Automating this process ensures consistency and reduces friction for releases.\n\n## What Changes\n\n- Add `scripts/update-flake.sh` to automatically update flake.nix version and dependency hash\n- Add `scripts/README.md` documenting all maintenance scripts\n- Script extracts version from package.json and determines correct pnpm dependency hash automatically\n\n## Capabilities\n\n### New Capabilities\n- `flake-update-script`: Automation script for maintaining flake.nix\n\n### Modified Capabilities\n- None\n\n## Impact\n\n- **New files**: `scripts/update-flake.sh`, `scripts/README.md`\n- **Maintainer workflow**: Version bumps now include running `./scripts/update-flake.sh`\n- **Dependencies**: Script requires Node.js (already a dependency) and Nix (for maintainers using Nix)\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-add-flake-update-script/specs/flake-update-script/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Dynamic Version Support\nThe script SHALL support flake.nix configurations that read version dynamically from package.json.\n\n#### Scenario: Version validation\n- **WHEN** script runs\n- **THEN** version is read from package.json using Node.js\n- **AND** script verifies flake.nix uses dynamic version pattern\n- **AND** warns if hardcoded version is detected\n\n#### Scenario: Version display\n- **WHEN** script runs\n- **THEN** script displays current package version\n- **AND** indicates version is read dynamically by flake.nix\n\n### Requirement: Automatic Hash Determination\nThe script SHALL automatically determine and update the correct pnpm dependency hash.\n\n#### Scenario: Trigger build to get hash\n- **WHEN** script needs to determine correct hash\n- **THEN** script sets placeholder hash in flake.nix\n- **AND** runs nix build which fails with correct hash\n- **AND** extracts correct hash from build error output\n\n#### Scenario: Hash extraction from build output\n- **WHEN** nix build fails with hash mismatch\n- **THEN** script parses \"got: sha256-...\" from error output\n- **AND** updates flake.nix with correct hash\n\n#### Scenario: Hash update failure\n- **WHEN** script cannot extract hash from build output\n- **THEN** script restores original hash to flake.nix\n- **AND** exits with error code 1\n- **AND** displays build output for debugging\n\n### Requirement: Build Verification\nThe script SHALL verify that flake.nix builds successfully after updates.\n\n#### Scenario: Successful verification\n- **WHEN** hash has been updated\n- **THEN** script runs nix build to verify\n- **AND** reports success if build completes\n\n#### Scenario: Dirty git tree warning\n- **WHEN** build succeeds but git tree is dirty\n- **THEN** script reports warning about dirty tree\n- **AND** still indicates build success\n\n### Requirement: User Feedback\nThe script SHALL provide clear progress information and next steps.\n\n#### Scenario: Progress reporting\n- **WHEN** script runs\n- **THEN** each step is reported with descriptive message\n- **AND** detected version and hash are displayed\n\n#### Scenario: Success summary\n- **WHEN** script completes successfully\n- **THEN** summary shows version and hash changes\n- **AND** next steps are displayed (test, verify, commit)\n\n#### Scenario: No changes needed\n- **WHEN** hash is already up-to-date\n- **THEN** script reports no changes needed\n- **AND** exits with success code 0\n\n### Requirement: Script Safety\nThe script SHALL fail fast on errors and use safe defaults.\n\n#### Scenario: Bash error handling\n- **WHEN** script encounters an error\n- **THEN** script exits immediately (set -e)\n- **AND** undefined variables cause exit (set -u)\n- **AND** pipe failures are caught (set -o pipefail)\n\n#### Scenario: File path resolution\n- **WHEN** script determines file locations\n- **THEN** paths are calculated relative to script location\n- **AND** script works regardless of working directory\n\n### Requirement: Documentation\nThe system SHALL provide documentation for the update script.\n\n#### Scenario: Script usage documentation\n- **WHEN** maintainer needs to use update script\n- **THEN** scripts/README.md explains when and how to use it\n- **AND** example workflow is provided\n\n#### Scenario: Script listing\n- **WHEN** maintainer views scripts/README.md\n- **THEN** all maintenance scripts are documented\n- **AND** purpose of each script is clear\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-add-flake-update-script/tasks.md",
    "content": "## 1. Create Update Script\n\n- [x] 1.1 Create scripts/update-flake.sh file\n- [x] 1.2 Add shebang and error handling (set -euo pipefail)\n- [x] 1.3 Add path resolution for project root and files\n- [x] 1.4 Make script executable (chmod +x)\n\n## 2. Implement Version Update Logic\n\n- [x] 2.1 Extract version from package.json using Node.js\n- [x] 2.2 Use sed to update version in flake.nix\n- [x] 2.3 Report if version already up-to-date\n- [x] 2.4 Display detected version to user\n\n## 3. Implement Hash Update Logic\n\n- [x] 3.1 Set placeholder hash in flake.nix\n- [x] 3.2 Run nix build and capture output (allow failure)\n- [x] 3.3 Extract correct hash from build error using grep\n- [x] 3.4 Handle case where hash extraction fails\n- [x] 3.5 Update flake.nix with correct hash\n- [x] 3.6 Display detected hash to user\n\n## 4. Add Build Verification\n\n- [x] 4.1 Run nix build after hash update\n- [x] 4.2 Check for dirty git tree warning\n- [x] 4.3 Report success or failure clearly\n\n## 5. Add User Feedback\n\n- [x] 5.1 Add progress messages for each step\n- [x] 5.2 Add success summary with version and hash\n- [x] 5.3 Add next steps instructions (test, commit)\n- [x] 5.4 Add error messages with context\n\n## 6. Create Documentation\n\n- [x] 6.1 Create scripts/README.md\n- [x] 6.2 Document update-flake.sh purpose and usage\n- [x] 6.3 Add example workflow\n- [x] 6.4 Document other existing scripts\n\n## 7. Testing\n\n- [x] 7.1 Test script runs successfully\n- [x] 7.2 Verify version is extracted correctly\n- [x] 7.3 Verify hash is updated correctly\n- [x] 7.4 Verify build succeeds after update\n- [x] 7.5 Test idempotency (running twice works)\n\n## 8. Integration\n\n- [ ] 8.1 Add note to release process documentation\n- [ ] 8.2 Use in next actual version bump to validate workflow\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-add-posthog-analytics/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-01-10\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-add-posthog-analytics/design.md",
    "content": "## Context\n\nOpenSpec needs usage analytics to understand adoption and inform product decisions. PostHog provides a privacy-conscious analytics platform suitable for open source projects.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Track daily/weekly/monthly active usage\n- Understand command usage patterns\n- Keep implementation minimal and privacy-respecting\n- Enable opt-out with minimal friction\n\n**Non-Goals:**\n- Detailed error tracking or diagnostics\n- User identification or profiling\n- Complex event hierarchies\n- Full CLI command for telemetry management (env var sufficient for now)\n\n## Decisions\n\n### Opt-Out Model\n\n**Decision:** Telemetry enabled by default, opt-out via environment variable.\n\n```bash\nOPENSPEC_TELEMETRY=0    # Disable telemetry\nDO_NOT_TRACK=1          # Industry standard, also respected\n```\n\nAuto-disabled when `CI=true` is detected.\n\n**Rationale:**\n- Opt-in typically yields ~3% participation—not enough for meaningful data\n- Understanding usage patterns requires statistically significant sample sizes\n- Environment variable opt-out is simple and immediate\n- Respecting `DO_NOT_TRACK` follows industry convention\n\n**Alternatives considered:**\n- Opt-in only - Insufficient data for product decisions\n- Config file setting - More complex, env var sufficient for MVP\n- Full `openspec telemetry` command - Can add later if users request\n\n### Event Design\n\n**Decision:** Single event type with minimal properties.\n\n```typescript\n{\n  event: 'command_executed',\n  properties: {\n    command: 'init',      // Command name only\n    version: '1.2.3'      // OpenSpec version\n  }\n}\n```\n\n**Rationale:**\n- Answers the core questions: how much usage, which commands are popular\n- PostHog derives DAU/WAU/MAU from anonymous user counts over time\n- No arguments, paths, or content—clean privacy story\n- Easy to explain in disclosure notice\n\n**Not tracked:**\n- Command arguments\n- File paths or contents\n- Error messages or stack traces\n- Project names or spec content\n- IP addresses (`$ip: null` explicitly set)\n\n### Anonymous ID\n\n**Decision:** Random UUID, lazily generated on first telemetry send, stored in global config.\n\n```typescript\n// ~/.config/openspec/config.json\n{\n  \"telemetry\": {\n    \"anonymousId\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\"\n  }\n}\n```\n\n**Rationale:**\n- Random UUID has no relation to the person—can't be reversed\n- Stored in config so same user = same ID across sessions (needed for DAU/WAU/MAU)\n- Lazy generation means no ID created if user opts out before first command\n- User can delete config to reset identity\n\n**Alternatives considered:**\n- Machine-derived hash (hostname, MAC) - Feels invasive, fingerprint-like\n- Per-session UUID - Breaks user counting metrics entirely\n\n### SDK Configuration\n\n**Decision:** PostHog Node SDK with immediate flush, shutdown on exit.\n\n```typescript\nconst posthog = new PostHog(API_KEY, {\n  flushAt: 1,        // Send immediately, don't batch\n  flushInterval: 0   // No timer-based flushing\n});\n\n// Before CLI exits\nawait posthog.shutdown();\n```\n\n**Rationale:**\n- CLI processes are short-lived; batching would lose events\n- `flushAt: 1` ensures each event sends immediately\n- `shutdown()` guarantees flush before process exit\n- Adds ~100-300ms to exit—negligible for typical CLI workflows\n\n**Error handling:**\n- Network failures silently ignored (telemetry shouldn't break CLI)\n- `shutdown()` wrapped in try/catch\n\n### Hook Location\n\n**Decision:** Commander.js `preAction` and `postAction` hooks.\n\n```typescript\nprogram\n  .hook('preAction', (thisCommand) => {\n    maybeShowTelemetryNotice();\n    trackCommand(thisCommand.name(), VERSION);\n  })\n  .hook('postAction', async () => {\n    await shutdown();\n  });\n```\n\n**Rationale:**\n- Centralized—one place for all telemetry logic\n- Automatic—new commands get tracked without code changes\n- Clean separation—command handlers don't know about telemetry\n\n**Subcommand handling:**\n- Track full command path for nested commands (e.g., `change:apply`)\n\n### First-Run Notice\n\n**Decision:** One-liner on first command ever, stored \"seen\" flag in config.\n\n```\nNote: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0\n```\n\n**Rationale:**\n- First command (not just `init`) ensures notice is always seen\n- Non-blocking—no prompt, just informational\n- One-liner is visible but not intrusive\n- Storing \"seen\" in config prevents repeated display\n\n**Config after first run:**\n```json\n{\n  \"telemetry\": {\n    \"anonymousId\": \"...\",\n    \"noticeSeen\": true\n  }\n}\n```\n\n## Risks / Trade-offs\n\n| Risk | Mitigation |\n|------|------------|\n| Users prefer opt-in | Clear disclosure, trivial opt-out, transparent about what's collected |\n| GDPR concerns | No personal data, no IP, user can delete config |\n| Slows CLI exit by ~200ms | Negligible for most workflows; can optimize if needed |\n| PostHog outage affects CLI | Fire-and-forget with timeout; failures are silent |\n\n## Open Questions\n\nNone—design is intentionally minimal. Future enhancements (dedicated command, workflow tracking) can be added based on user feedback.\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-add-posthog-analytics/proposal.md",
    "content": "## Why\n\nOpenSpec currently has no visibility into how the tool is being used. Without analytics, we cannot:\n- Understand which commands and features are most valuable to users\n- Measure adoption and usage patterns\n- Make data-driven decisions about product development\n\nAdding PostHog analytics enables product insights while respecting user privacy through transparent, opt-out telemetry.\n\n## What Changes\n\n- Add PostHog Node.js SDK as a dependency\n- Implement telemetry system with environment variable opt-out\n- Track command usage (command name and version only)\n- Show first-run notice informing users about telemetry\n- Store anonymous ID in global config (`~/.config/openspec/config.json`)\n- Respect `DO_NOT_TRACK` and `OPENSPEC_TELEMETRY=0` environment variables\n- Auto-disable in CI environments\n\n## Capabilities\n\n### New Capabilities\n\n- `telemetry`: Anonymous usage analytics using PostHog. Covers command tracking, opt-out controls, and first-run disclosure notice.\n\n### Modified Capabilities\n\n- `global-config`: Add telemetry state storage (anonymous ID, notice seen flag)\n\n## Impact\n\n- **Dependencies**: Add `posthog-node` package\n- **Privacy**: Opt-out via env var, no personal data collected, clear disclosure\n- **Configuration**: New global config fields for telemetry state\n- **Network**: Async event sending with flush on exit (~100-300ms added)\n- **CI/CD**: Telemetry auto-disabled when `CI=true`\n- **Documentation**: Update README with telemetry disclosure\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-add-posthog-analytics/specs/global-config/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: Global configuration storage\nThe system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.\n\n#### Scenario: Initial config creation\n- **WHEN** no global config file exists\n- **AND** the first telemetry event is about to be sent\n- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration\n\n#### Scenario: Telemetry config structure\n- **WHEN** reading or writing telemetry configuration\n- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields\n\n#### Scenario: Config file format\n- **WHEN** storing configuration\n- **THEN** the system writes valid JSON that can be read and modified by users\n\n#### Scenario: Existing config preservation\n- **WHEN** adding telemetry fields to an existing config file\n- **THEN** the system preserves all existing configuration fields\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-add-posthog-analytics/specs/telemetry/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Command execution tracking\nThe system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.\n\n#### Scenario: Standard command execution\n- **WHEN** a user runs any openspec command\n- **THEN** the system sends a `command_executed` event with `command` and `version` properties\n\n#### Scenario: Subcommand execution\n- **WHEN** a user runs a nested command like `openspec change apply`\n- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)\n\n### Requirement: Privacy-preserving event design\nThe system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.\n\n#### Scenario: Command with arguments\n- **WHEN** a user runs `openspec init my-project --force`\n- **THEN** the telemetry event contains only `command: \"init\"` and `version: \"<version>\"` without arguments\n\n#### Scenario: IP address exclusion\n- **WHEN** the system sends a telemetry event\n- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking\n\n### Requirement: Environment variable opt-out\nThe system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.\n\n#### Scenario: OPENSPEC_TELEMETRY opt-out\n- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment\n- **THEN** the system sends no telemetry events\n\n#### Scenario: DO_NOT_TRACK opt-out\n- **WHEN** `DO_NOT_TRACK=1` is set in the environment\n- **THEN** the system sends no telemetry events\n\n#### Scenario: Environment variable takes precedence\n- **WHEN** the user has previously used the CLI (config exists)\n- **AND** the user sets `OPENSPEC_TELEMETRY=0`\n- **THEN** telemetry is disabled regardless of config state\n\n### Requirement: CI environment auto-disable\nThe system SHALL automatically disable telemetry when `CI=true` environment variable is detected.\n\n#### Scenario: CI environment detection\n- **WHEN** `CI=true` is set in the environment\n- **THEN** the system sends no telemetry events\n\n#### Scenario: CI with explicit enable\n- **WHEN** `CI=true` is set\n- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set\n- **THEN** telemetry remains disabled (CI takes precedence for privacy)\n\n### Requirement: First-run telemetry notice\nThe system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.\n\n#### Scenario: First command execution\n- **WHEN** a user runs their first openspec command\n- **AND** telemetry is enabled\n- **THEN** the system displays: \"Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0\"\n\n#### Scenario: Subsequent command execution\n- **WHEN** a user has already seen the notice (noticeSeen: true in config)\n- **THEN** the system does not display the notice\n\n#### Scenario: Notice before telemetry\n- **WHEN** displaying the first-run notice\n- **THEN** the notice appears before any telemetry event is sent\n\n### Requirement: Anonymous user identification\nThe system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.\n\n#### Scenario: First telemetry event\n- **WHEN** the first telemetry event is sent\n- **AND** no anonymousId exists in config\n- **THEN** the system generates a random UUID v4 and stores it in config\n\n#### Scenario: Persistent identity\n- **WHEN** a user runs multiple commands across sessions\n- **THEN** the same anonymousId is used for all events\n\n#### Scenario: Lazy generation with opt-out\n- **WHEN** a user opts out before running any command\n- **THEN** no anonymousId is ever generated or stored\n\n### Requirement: Immediate event sending\nThe system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.\n\n#### Scenario: Event transmission timing\n- **WHEN** a command executes\n- **THEN** the telemetry event is sent immediately, not queued for batch transmission\n\n### Requirement: Graceful shutdown\nThe system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.\n\n#### Scenario: Normal exit\n- **WHEN** a command completes successfully\n- **THEN** the system awaits `shutdown()` before exiting\n\n#### Scenario: Error exit\n- **WHEN** a command fails with an error\n- **THEN** the system still awaits `shutdown()` before exiting\n\n### Requirement: Silent failure handling\nThe system SHALL silently ignore telemetry failures without affecting CLI functionality.\n\n#### Scenario: Network failure\n- **WHEN** the telemetry request fails due to network error\n- **THEN** the CLI command completes normally without error message\n\n#### Scenario: PostHog outage\n- **WHEN** PostHog service is unavailable\n- **THEN** the CLI command completes normally without error message\n\n#### Scenario: Shutdown failure\n- **WHEN** `shutdown()` fails or times out\n- **THEN** the CLI exits normally without error message\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-add-posthog-analytics/tasks.md",
    "content": "## 1. Setup\n\n- [x] 1.1 Add `posthog-node` package as a dependency\n- [x] 1.2 Create `src/telemetry/` module directory\n- [x] 1.3 Add PostHog API key configuration (environment variable or embedded)\n\n## 2. Global Config\n\n- [x] 2.1 Create or extend global config module for `~/.config/openspec/config.json`\n- [x] 2.2 Implement read/write functions that preserve existing config fields\n- [x] 2.3 Define telemetry config structure (`anonymousId`, `noticeSeen`)\n\n## 3. Core Telemetry Module\n\n- [x] 3.1 Implement `isTelemetryEnabled()` checking `OPENSPEC_TELEMETRY`, `DO_NOT_TRACK`, and `CI` env vars\n- [x] 3.2 Implement `getOrCreateAnonymousId()` with lazy UUID generation\n- [x] 3.3 Initialize PostHog client with `flushAt: 1` and `flushInterval: 0`\n- [x] 3.4 Implement `trackCommand(commandName, version)` with `$ip: null`\n- [x] 3.5 Implement `shutdown()` with try/catch for silent failure handling\n\n## 4. First-Run Notice\n\n- [x] 4.1 Implement `maybeShowTelemetryNotice()` function\n- [x] 4.2 Check `noticeSeen` flag before displaying notice\n- [x] 4.3 Display notice text: \"Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0\"\n- [x] 4.4 Update `noticeSeen` in config after first display\n\n## 5. CLI Integration\n\n- [x] 5.1 Add Commander.js `preAction` hook to show notice and track command\n- [x] 5.2 Add Commander.js `postAction` hook to call shutdown\n- [x] 5.3 Handle subcommand path extraction (e.g., `change:apply`)\n\n## 6. Testing\n\n- [x] 6.1 Test opt-out via `OPENSPEC_TELEMETRY=0`\n- [x] 6.2 Test opt-out via `DO_NOT_TRACK=1`\n- [x] 6.3 Test auto-disable in CI environment\n- [x] 6.4 Test first-run notice display and noticeSeen persistence\n- [x] 6.5 Test anonymous ID generation and persistence\n- [x] 6.6 Test silent failure on network error (mock PostHog)\n\n## 7. Documentation\n\n- [x] 7.1 Add telemetry disclosure section to README\n- [x] 7.2 Document opt-out methods (`OPENSPEC_TELEMETRY=0`, `DO_NOT_TRACK=1`)\n- [x] 7.3 Document what data is collected and not collected\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-fix-codebuddy-frontmatter-fields/proposal.md",
    "content": "## Why\n\nCodeBuddy slash command configurator currently uses inconsistent frontmatter fields compared to other tools. It uses `category` and `tags` fields (like Crush) but should use `argument-hint` field (like Factory, Auggie, and Codex) for better consistency. Additionally, the `proposal` command is missing frontmatter fields entirely. After reviewing CodeBuddy's official documentation, the correct format should use `description` and `argument-hint` fields with square bracket parameter format.\n\n## What Changes\n\n- Replace `category` and `tags` fields with `argument-hint` field in CodeBuddy frontmatter\n- Add missing frontmatter fields to the `proposal` command\n- Use correct square bracket format for `argument-hint` parameters (e.g., `[change-id]`)\n- Ensure consistency with CodeBuddy's official documentation\n\n## Impact\n\n- Affected specs: cli-init, cli-update\n- Affected code: `src/core/configurators/slash/codebuddy.ts`\n- CodeBuddy users will get proper argument hints in the correct format for slash commands"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-fix-codebuddy-frontmatter-fields/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: Slash Command Configuration\n\nThe init command SHALL generate slash command files for supported editors using shared templates.\n\n#### Scenario: Generating slash commands for Antigravity\n- **WHEN** the user selects Antigravity during initialization\n- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`\n- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers\n- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory\n\n#### Scenario: Generating slash commands for Claude Code\n- **WHEN** the user selects Claude Code during initialization\n- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for CodeBuddy Code\n- **WHEN** the user selects CodeBuddy Code during initialization\n- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`\n- **AND** populate each file from shared templates that include CodeBuddy-compatible YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`)\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cline\n- **WHEN** the user selects Cline during initialization\n- **THEN** create `.clinerules/workflows/openspec-proposal.md`, `.clinerules/workflows/openspec-apply.md`, and `.clinerules/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include Cline-specific Markdown heading frontmatter\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Crush\n- **WHEN** the user selects Crush during initialization\n- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** include Crush-specific frontmatter with OpenSpec category and tags\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Cursor\n- **WHEN** the user selects Cursor during initialization\n- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Factory Droid\n- **WHEN** the user selects Factory Droid during initialization\n- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`\n- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input\n- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands\n\n#### Scenario: Generating slash commands for OpenCode\n- **WHEN** the user selects OpenCode during initialization\n- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`\n- **AND** populate each file from shared templates so command text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Windsurf\n- **WHEN** the user selects Windsurf during initialization\n- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Kilo Code\n- **WHEN** the user selects Kilo Code during initialization\n- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`\n- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools\n- **AND** each template includes instructions for the relevant OpenSpec workflow stage\n\n#### Scenario: Generating slash commands for Codex\n- **WHEN** the user selects Codex during initialization\n- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)\n- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)\n- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-fix-codebuddy-frontmatter-fields/specs/cli-update/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: Slash Command Updates\n\nThe 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.\n\n#### Scenario: Updating slash commands for Antigravity\n- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter\n- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs\n\n#### Scenario: Updating slash commands for Claude Code\n- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for CodeBuddy Code\n- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`)\n- **AND** preserve any user customizations outside the OpenSpec managed markers\n\n#### Scenario: Updating slash commands for Cline\n- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** include Cline-specific Markdown heading frontmatter\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Crush\n- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** include Crush-specific frontmatter with OpenSpec category and tags\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cursor\n- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Factory Droid\n- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid\n- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched\n- **AND** skip creating missing files during update\n\n#### Scenario: Updating slash commands for OpenCode\n- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments\n\n#### Scenario: Updating slash commands for Windsurf\n- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage"
  },
  {
    "path": "openspec/changes/archive/2026-01-09-fix-codebuddy-frontmatter-fields/tasks.md",
    "content": "## 1. Implementation\n\n- [x] 1.1 Update CodeBuddy frontmatter to use `argument-hint` instead of `category` and `tags`\n- [x] 1.2 Add missing frontmatter fields to the `proposal` command\n- [x] 1.3 Ensure all three commands (proposal, apply, archive) have consistent frontmatter structure\n- [x] 1.4 Test the changes by running `openspec init` and `openspec update`"
  },
  {
    "path": "openspec/changes/archive/2026-01-15-add-nix-ci-validation/design.md",
    "content": "# Design: Nix CI Validation\n\n## Context\n\nOpenSpec recently added Nix flake support to enable Nix users to install the tool. This includes:\n- `flake.nix`: Nix package definition with pnpm dependency fetching\n- `scripts/update-flake.sh`: Automation script to update version and hash when releasing\n\nCurrently, there is no CI validation ensuring these Nix artifacts remain functional. The existing CI workflow (.github/workflows/ci.yml) validates Node.js builds, tests, and linting across multiple platforms (Linux, macOS, Windows) but does not validate Nix builds.\n\n**Stakeholders**: Nix users, maintainers, contributors who need confidence that Nix support works.\n\n**Constraints**:\n- Must work in GitHub Actions Linux runners\n- Should minimize CI runtime impact (<5 minutes added)\n- Should support local testing with `act` for rapid iteration\n- Must integrate with existing required checks\n\n## Goals / Non-Goals\n\n**Goals**:\n- Validate `nix build` succeeds on every PR/push\n- Validate `scripts/update-flake.sh` executes without errors\n- Ensure Nix support doesn't regress silently\n- Support local testing with `act`\n- Optimize with caching to minimize CI time\n\n**Non-Goals**:\n- Testing on macOS (GitHub-hosted macOS runners are slower and more expensive; Nix flake already declares macOS support)\n- Building for all declared systems (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin) - focus on most common platform\n- Validating Nix flake quality/style (nixpkgs-fmt, etc.) - can be added later if needed\n- Running OpenSpec's full test suite through Nix build - existing CI already does this\n\n## Decisions\n\n### Decision 1: Use DeterminateSystems nix-installer-action\n\n**What**: Use `determinatesystems/nix-installer-action` for installing Nix in CI.\n\n**Why**:\n- Official GitHub Action maintained by Determinate Systems (Nix experts)\n- Handles GitHub Actions environment quirks automatically\n- Includes automatic caching configuration\n- More reliable than curl | sh installation script\n- Better error messages and diagnostics\n\n**Alternatives considered**:\n- Official Nix installer (`curl -L https://nixos.org/nix/install | sh`): Works but requires manual setup of flakes, caching, and CI-specific configuration\n- `cachix/install-nix-action`: Popular alternative but determinatesystems is more actively maintained and has better GHA integration\n\n### Decision 2: Use Magic Nix Cache for performance\n\n**What**: Use `determinatesystems/magic-nix-cache-action` for automatic binary caching.\n\n**Why**:\n- Zero-configuration caching for Nix store\n- Significantly reduces CI time on subsequent runs (from ~5min to ~1-2min)\n- Free for public repositories\n- Handles cache keys automatically\n\n**Alternatives considered**:\n- Manual Nix store caching with GitHub Actions cache: More complex, requires manual cache key management\n- Cachix: Excellent tool but requires account setup and token management\n- No caching: Acceptable for initial implementation, but poor developer experience\n\n### Decision 3: Separate job for Nix validation\n\n**What**: Create a dedicated `nix-validate` job in .github/workflows/ci.yml that runs in parallel with other jobs.\n\n**Why**:\n- Keeps Nix validation isolated from Node.js validation\n- Allows parallel execution for faster CI\n- Easier to debug when Nix-specific issues occur\n- Can be marked as required check independently\n\n**Alternatives considered**:\n- Add Nix steps to existing jobs: Creates coupling between Node.js and Nix validation, harder to maintain\n- Separate workflow file: Overkill for a single job, harder to manage required checks\n\n### Decision 4: Validate update script by executing it\n\n**What**: Run `scripts/update-flake.sh` as part of CI validation.\n\n**Why**:\n- Ensures the script doesn't break due to changes in package.json format, nix build output, or dependencies\n- Tests the full workflow users will follow when releasing\n- Catches errors early\n\n**Implementation approach**:\n- Execute script in a way that doesn't modify git state (or discard changes after)\n- Verify script exits with code 0\n- Optionally validate that flake.nix contains expected patterns after execution\n\n**Alternatives considered**:\n- Mock/dry-run mode: Would require modifying the script significantly\n- Skip validation: Risky - script could break and only be discovered at release time\n- Only run on release branches: Misses issues early in development\n\n### Decision 5: Run on pull_request and push to main\n\n**What**: Configure Nix validation job to run on:\n- `pull_request` events (any PR to main)\n- `push` events (direct pushes to main)\n- `workflow_dispatch` (manual trigger for testing)\n\n**Why**:\n- Catches issues before merge (pull_request)\n- Validates main branch stays healthy (push)\n- Allows manual testing without creating PRs (workflow_dispatch)\n\n### Decision 6: Support act for local testing\n\n**What**: Ensure workflow is compatible with `act` tool for local CI testing.\n\n**Why**:\n- Faster iteration when developing CI changes\n- Allows testing without pushing to GitHub\n- Reduces commit noise from CI debugging\n\n**Requirements**:\n- Use standard GitHub Actions syntax\n- Document any act-specific configuration needed\n- Test that Nix can be installed in act's Docker containers\n\n**Limitations**:\n- act may not perfectly replicate GitHub's runners, but close enough for validation\n\n## Risks / Trade-offs\n\n### Risk: CI runtime increase\n\n**Impact**: Adding Nix validation will increase total CI time by 2-5 minutes per run.\n\n**Mitigation**:\n- Run Nix job in parallel with existing jobs (no blocking delay)\n- Use magic-nix-cache for subsequent runs (~1-2 min with cache)\n- Configure appropriate timeout (10 minutes max)\n\n**Acceptance**: The benefit of preventing Nix regressions outweighs the cost.\n\n### Risk: Nix installer failures in CI\n\n**Impact**: Transient failures in Nix installation could block PRs.\n\n**Mitigation**:\n- Use determinatesystems action which has retry logic\n- Monitor for flaky failures and adjust if needed\n- Document troubleshooting steps\n\n**Acceptance**: Nix installation is generally stable in GHA; this is low risk.\n\n### Risk: Update script modifies git state\n\n**Impact**: Running update-flake.sh modifies flake.nix, which could cause CI to fail if git state is checked.\n\n**Mitigation**:\n- Run script in isolation without committing changes\n- Add `git checkout -- flake.nix` after validation\n- Or accept dirty git state in CI (doesn't affect build validation)\n\n**Acceptance**: Script validation is important enough to handle this carefully.\n\n### Risk: act compatibility issues\n\n**Impact**: Workflow might not work perfectly with act due to Docker environment differences.\n\n**Mitigation**:\n- Document known limitations\n- Focus on GitHub Actions as primary validation target\n- Use act as best-effort local testing\n\n**Acceptance**: act support is nice-to-have, not required.\n\n## Migration Plan\n\n### Phase 1: Add Nix job (new, non-required)\n1. Add `nix-validate` job to .github/workflows/ci.yml\n2. Configure to run in parallel with existing jobs\n3. Do NOT mark as required check initially\n4. Monitor for ~1 week to ensure stability\n\n### Phase 2: Make required\n1. After validation is stable, add to required checks\n2. Update branch protection rules in GitHub settings\n3. Document in CONTRIBUTING.md or README\n\n### Rollback Plan\nIf Nix validation causes issues:\n1. Remove job from required checks in GitHub settings (immediate)\n2. Comment out or remove job from workflow (permanent fix)\n3. Investigate and fix issues\n4. Re-enable following same phased approach\n\n## Open Questions\n\n- **Q**: Should we test update-flake.sh on every CI run, or only when package.json or pnpm-lock.yaml changes?\n  - **A**: Test on every run for simplicity. The script is fast (<30 seconds) and catching regressions is valuable.\n\n- **Q**: Should we validate on macOS as well?\n  - **A**: No for initial implementation. Linux validation is sufficient and macOS runners are slower/more expensive. Can add later if users report macOS-specific issues.\n\n- **Q**: Should we run full OpenSpec tests through the Nix build?\n  - **A**: No. The Nix build already runs `pnpm test` as part of its build phase. Existing CI jobs cover testing thoroughly. Nix validation focuses on build success.\n\n- **Q**: What timeout should we use for the Nix validation job?\n  - **A**: Start with 10 minutes. With caching, jobs should complete in 1-3 minutes. Without cache (first run), 5-7 minutes is expected.\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-15-add-nix-ci-validation/proposal.md",
    "content": "# Add Nix CI Validation\n\n## Why\n\nThe project recently added Nix flake support (flake.nix) and an automated update script (scripts/update-flake.sh) to enable Nix users to install OpenSpec. However, there is no CI validation ensuring these Nix artifacts continue to work as the project evolves. This creates risk that breaking changes could be merged without detection.\n\n## What Changes\n\n- Add a new GitHub Actions workflow job to validate Nix flake builds successfully\n- Add validation that the update-flake.sh script executes without errors\n- Test on Linux (where Nix support is most common)\n- Ensure CI fails if Nix build or update script breaks\n- Enable local testing with `act` for developers\n\n## Impact\n\n- Affected specs: New capability `ci-nix-validation`\n- Affected code: `.github/workflows/ci.yml` (add new job)\n- Affected infrastructure: GitHub Actions runners with Nix installed\n- Benefits: Prevents regressions in Nix support, gives confidence to Nix users\n- Trade-offs: Adds ~2-3 minutes to CI runtime\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-15-add-nix-ci-validation/specs/ci-nix-validation/spec.md",
    "content": "# CI Nix Validation Specification\n\n## ADDED Requirements\n\n### Requirement: Nix Flake Build Validation\n\nThe CI system SHALL validate that the Nix flake builds successfully on every pull request and push to main.\n\n#### Scenario: Successful flake build\n\n- **WHEN** a pull request or push to main is made\n- **THEN** the CI SHALL execute `nix build` and verify it completes with exit code 0\n- **AND** the build output SHALL contain the openspec binary\n\n#### Scenario: Flake build failure\n\n- **WHEN** the Nix flake configuration is broken\n- **THEN** the CI job SHALL fail with a non-zero exit code\n- **AND** the CI SHALL prevent merging of the pull request\n\n#### Scenario: Multi-platform support check\n\n- **WHEN** the flake declares support for multiple systems\n- **THEN** the CI SHALL validate the flake builds on at least Linux (x86_64-linux)\n\n### Requirement: Update Script Validation\n\nThe CI system SHALL validate that the update-flake.sh script executes successfully and produces valid output.\n\n#### Scenario: Update script execution\n\n- **WHEN** the CI runs the update script validation\n- **THEN** the script SHALL execute without errors\n- **AND** the script SHALL correctly extract the version from package.json\n- **AND** the script SHALL update flake.nix with the correct version\n\n#### Scenario: Update script with mock hash\n\n- **WHEN** validating the update script in CI\n- **THEN** the script SHALL be able to detect and extract the correct pnpm dependency hash\n- **AND** the flake.nix SHALL be updated with a valid sha256 hash\n\n### Requirement: CI Job Integration\n\nThe Nix validation jobs SHALL be integrated into the existing GitHub Actions workflow and required for merge.\n\n#### Scenario: PR merge requirements\n\n- **WHEN** a pull request is created\n- **THEN** the Nix validation job SHALL be included in required checks\n- **AND** the PR SHALL NOT be mergeable until Nix validation passes\n\n#### Scenario: Job execution triggers\n\n- **WHEN** code is pushed to a pull request OR pushed to main OR manually triggered\n- **THEN** the Nix validation job SHALL execute automatically\n\n### Requirement: Local Testing Support\n\nThe CI workflow SHALL be testable locally using the `act` tool to enable rapid iteration.\n\n#### Scenario: Local CI execution with act\n\n- **WHEN** a developer runs `act` with the Nix validation workflow\n- **THEN** the workflow SHALL execute in the local Docker environment\n- **AND** the developer SHALL receive feedback on Nix build status without pushing to GitHub\n\n#### Scenario: Act configuration compatibility\n\n- **WHEN** the workflow is designed\n- **THEN** it SHALL use standard GitHub Actions syntax compatible with `act`\n- **AND** any Nix-specific setup SHALL work in the act Docker environment\n\n### Requirement: Nix Installation in CI\n\nThe CI environment SHALL have Nix properly installed and configured before running validation.\n\n#### Scenario: Nix installation step\n\n- **WHEN** the Nix validation job starts\n- **THEN** Nix SHALL be installed using the official Nix installer or determinatesystems/nix-installer-action\n- **AND** the Nix installation SHALL be cached for subsequent runs to improve performance\n\n#### Scenario: Nix configuration for CI\n\n- **WHEN** Nix is installed in CI\n- **THEN** it SHALL be configured to work in the GitHub Actions environment\n- **AND** experimental features (flakes, nix-command) SHALL be enabled\n\n### Requirement: CI Performance Optimization\n\nThe Nix validation SHALL be optimized to minimize CI runtime impact.\n\n#### Scenario: Acceptable runtime\n\n- **WHEN** the Nix validation job runs\n- **THEN** it SHALL complete in under 5 minutes on a clean run\n- **AND** with caching, it SHALL complete in under 3 minutes on subsequent runs\n\n#### Scenario: Parallel execution\n\n- **WHEN** multiple CI jobs are running\n- **THEN** the Nix validation job SHALL run in parallel with other validation jobs (tests, lint)\n- **AND** SHALL NOT block other independent checks\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-15-add-nix-ci-validation/tasks.md",
    "content": "# Implementation Tasks\n\n## 1. Add Nix Installation to CI\n\n- [x] 1.1 Research Nix installation options for GitHub Actions (nix-installer-action vs manual install)\n- [x] 1.2 Add Nix installation step to .github/workflows/ci.yml\n- [x] 1.3 Configure Nix with experimental features enabled (flakes, nix-command)\n- [x] 1.4 Add Nix store caching to improve CI performance\n\n## 2. Create Nix Build Validation Job\n\n- [x] 2.1 Add new `nix-flake-validate` job to .github/workflows/ci.yml\n- [x] 2.2 Implement `nix build` step with proper error handling\n- [x] 2.3 Add verification step to confirm binary exists in build output\n- [x] 2.4 Add step to test binary execution (`nix run . -- --version`)\n\n## 3. Add Update Script Validation\n\n- [x] 3.1 Add job step to run scripts/update-flake.sh in dry-run or test mode\n- [x] 3.2 Verify script executes without errors\n- [x] 3.3 Add validation that version is correctly extracted from package.json\n- [x] 3.4 Verify flake.nix is updated with correct format (version and hash)\n\n## 4. Configure Job Dependencies and Requirements\n\n- [x] 4.1 Configure Nix validation job to run on pull_request and push events\n- [x] 4.2 Add Nix validation to required checks list\n- [x] 4.3 Configure job to run in parallel with existing test/lint jobs\n- [x] 4.4 Set appropriate timeout (5-10 minutes)\n\n## 5. Test with act Locally\n\n- [x] 5.1 Install act locally if not already available\n- [x] 5.2 Test Nix validation job using `act pull_request`\n- [x] 5.3 Verify act can run the workflow with Nix installed\n- [x] 5.4 Document any act-specific configuration needed in .actrc or README\n\n## 6. Documentation and Finalization\n\n- [x] 6.1 Add documentation about Nix CI validation to README or CONTRIBUTING.md\n- [x] 6.2 Document how to test CI locally with act\n- [ ] 6.3 Update CI badge or status indicators if needed\n- [ ] 6.4 Test end-to-end by creating a test PR\n\n## 7. Archive Change\n\n- [x] 7.1 After merge and verification, create new spec file at openspec/specs/ci-nix-validation/spec.md\n- [x] 7.2 Move change directory to openspec/changes/archive/[date]-add-nix-ci-validation/\n- [x] 7.3 Run `openspec validate --strict` to confirm archived change passes\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-30-opencode-command-references/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-01-30\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-30-opencode-command-references/README.md",
    "content": "# opencode-command-references\n\nTransform /opsx: to /opsx- in both commands and skills for OpenCode\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-30-opencode-command-references/design.md",
    "content": "## Context\n\nOpenCode is one of many supported AI tools. Each tool has:\n- A **command adapter** (in `src/core/command-generation/adapters/`) for generating tool-specific command files\n- **Skills** generated via `generateSkillContent()` in `src/core/shared/skill-generation.ts`\n\nCurrently:\n- Commands go through the adapter system which can transform content per-tool\n- Skills use a single shared function with no tool-specific transformation\n\nThe templates in `src/core/templates/skill-templates.ts` use Claude's colon-based format (`/opsx:new`) as the canonical format. Tools that use different formats need transformation at generation time.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Transform all `/opsx:` command references to `/opsx-` for OpenCode in both commands and skills\n- Create a shared, reusable transformation utility\n- Keep the transformation opt-in via a callback parameter (not hard-coded tool detection)\n\n**Non-Goals:**\n- Modifying the canonical template format (templates stay with `/opsx:`)\n- Applying transformation to other tools (only OpenCode for now)\n- Creating a full adapter system for skills (overkill for current needs)\n\n## Decisions\n\n### Decision 1: Shared Utility Function\n\n**Choice**: Create `transformToHyphenCommands()` in `src/utils/command-references.ts`\n\n**Rationale**: \n- Single source of truth for the transformation logic\n- Can be used by both command adapter and skill generation\n- Easy to test in isolation\n- Follows existing utils pattern in the codebase\n\n**Alternatives considered**:\n- Inline the transformation in each location - Duplicates logic, harder to maintain\n\n### Decision 2: Callback Parameter for Skill Generation\n\n**Choice**: Add optional `transformInstructions?: (instructions: string) => string` parameter to `generateSkillContent()`\n\n**Rationale**:\n- Flexible - callers define the transformation, not the generation function\n- No coupling - `generateSkillContent()` doesn't need to know about tool formats\n- Extensible - could support other transformations in the future\n- Follows inversion of control principle\n\n**Alternatives considered**:\n- Add tool ID parameter and switch on it - Creates coupling, harder to extend\n- Create skill adapter system parallel to commands - Over-engineering for current needs\n- Transform in templates directly - Breaks single-source-of-truth principle\n\n### Decision 3: Apply at Generation Sites\n\n**Choice**: Pass transformer in `init.ts` and `update.ts` when `tool.value === 'opencode'`\n\n**Rationale**:\n- These are the only two places that generate skills\n- Simple conditional check, no new abstractions needed\n- Easy to extend to other tools if needed later\n\n## Risks / Trade-offs\n\n| Risk | Mitigation |\n|------|------------|\n| Other `/opsx:` patterns exist that shouldn't be transformed | All occurrences in templates are command invocations - verified by inspection |\n| Future tools may need same transformation | Utility is shared and easy to reuse; can add to other tools' generation |\n| Callback adds complexity to function signature | Optional parameter with sensible default (no transformation) |\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-30-opencode-command-references/proposal.md",
    "content": "## Why\n\nOpenCode uses hyphen-based command syntax (`/opsx-new`) but our templates contain colon-based references (`/opsx:new`). This creates inconsistency where generated command files and skill files contain references that don't match the actual command invocation syntax, confusing both the AI and users.\n\n## What Changes\n\n- Create a shared transformation utility (`transformToHyphenCommands`) for converting `/opsx:` to `/opsx-`\n- Update the OpenCode command adapter to transform body text using this utility\n- Add an optional `transformInstructions` callback parameter to `generateSkillContent()`\n- Update `init.ts` and `update.ts` to pass the transformer when generating skills for OpenCode\n\n## Capabilities\n\n### New Capabilities\n\nNone - this is a bug fix, not a new capability.\n\n### Modified Capabilities\n\nNone - no spec-level behavior changes. This is an implementation fix in the OpenCode adapter and skill generation that doesn't change any external requirements or contracts.\n\n## Impact\n\n- **Code**: \n  - `src/utils/command-references.ts` (new file)\n  - `src/utils/index.ts` (export)\n  - `src/core/shared/skill-generation.ts` (add callback parameter)\n  - `src/core/command-generation/adapters/opencode.ts` (use transformer)\n  - `src/core/init.ts` (pass transformer for OpenCode)\n  - `src/core/update.ts` (pass transformer for OpenCode)\n- **Users**: OpenCode users will see correct `/opsx-` command references in both generated command files AND skill files\n- **Other tools**: No impact - transformation only applies to OpenCode\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-30-opencode-command-references/specs/no-changes.md",
    "content": "# No Spec Changes\n\nThis is a bug fix that doesn't modify any external requirements or contracts.\n\nThe proposal's Capabilities section indicates:\n- **New Capabilities**: None\n- **Modified Capabilities**: None\n\nNo spec files are needed for this implementation-only fix.\n"
  },
  {
    "path": "openspec/changes/archive/2026-01-30-opencode-command-references/tasks.md",
    "content": "## 1. Implementation\n\n- [x] 1.1 Create `src/utils/command-references.ts` with `transformToHyphenCommands()` function\n- [x] 1.2 Export `transformToHyphenCommands` from `src/utils/index.ts`\n- [x] 1.3 Update `generateSkillContent()` in `src/core/shared/skill-generation.ts` to accept optional `transformInstructions` callback\n- [x] 1.4 Update OpenCode adapter in `src/core/command-generation/adapters/opencode.ts` to use `transformToHyphenCommands()` for body text\n- [x] 1.5 Update `init.ts` to pass transformer when generating skills for OpenCode\n- [x] 1.6 Update `update.ts` to pass transformer when generating skills for OpenCode\n\n## 2. Testing\n\n- [x] 2.1 Create `test/utils/command-references.test.ts` with unit tests for `transformToHyphenCommands()`\n- [x] 2.2 Add test to `test/core/command-generation/adapters.test.ts` for OpenCode body transformation\n- [x] 2.3 Add test to `test/core/shared/skill-generation.test.ts` for transformer callback\n\n## 3. Verification\n\n- [x] 3.1 Run `npx vitest run test/utils/command-references.test.ts test/core/command-generation/adapters.test.ts test/core/shared/skill-generation.test.ts` to ensure tests pass\n- [x] 3.2 Run `pnpm run build` to ensure no TypeScript errors\n- [x] 3.3 Run `openspec init --tools opencode` in a temp directory and verify:\n  - Command files in `.opencode/command/` contain `/opsx-` references (not `/opsx:`)\n  - Skill files in `.opencode/skills/` contain `/opsx-` references (not `/opsx:`)\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-feedback-command/proposal.md",
    "content": "## Why\n\nUsers and agents need a simple way to submit feedback about OpenSpec directly from the CLI. Currently there's no mechanism to collect user feedback, feature requests, or bug reports in a way that enables follow-up conversation. Using GitHub Issues allows us to track feedback, prevent spam via GitHub auth, and enables outreach to users.\n\n## What Changes\n\n- Add `openspec feedback <message>` CLI command\n- Leverage `gh` CLI for GitHub authentication and issue creation\n- Add `/feedback` skill for agent-assisted feedback with context enrichment\n- Ensure cross-platform compatibility (macOS, Linux, Windows)\n\n## Impact\n\n- Affected specs: New `cli-feedback` capability\n- Affected code:\n  - `src/cli/index.ts` - Register feedback command\n  - `src/commands/feedback.ts` - Command implementation using `gh` CLI\n  - `src/core/templates/skill-templates.ts` - Feedback skill template\n  - `src/core/completions/command-registry.ts` - Shell completions\n- External dependency: Requires `gh` CLI installed and authenticated\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-feedback-command/specs/cli-feedback/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Feedback command\n\nThe system SHALL provide an `openspec feedback` command that creates a GitHub Issue in the openspec repository using the `gh` CLI. The system SHALL use `execFileSync` with argument arrays to prevent shell injection vulnerabilities.\n\n#### Scenario: Simple feedback submission\n\n- **WHEN** user executes `openspec feedback \"Great tool!\"`\n- **THEN** the system executes `gh issue create` with title \"Feedback: Great tool!\"\n- **AND** the issue is created in the openspec repository\n- **AND** the issue has the `feedback` label\n- **AND** the system displays the created issue URL\n\n#### Scenario: Safe command execution\n\n- **WHEN** submitting feedback via `gh` CLI\n- **THEN** the system uses `execFileSync` with separate arguments array\n- **AND** user input is NOT passed through a shell\n- **AND** shell metacharacters (quotes, backticks, $(), etc.) are treated as literal text\n\n#### Scenario: Feedback with body\n\n- **WHEN** user executes `openspec feedback \"Title here\" --body \"Detailed description...\"`\n- **THEN** the system creates a GitHub Issue with the specified title\n- **AND** the issue body contains the detailed description\n- **AND** the issue body includes metadata (OpenSpec version, platform, timestamp)\n\n### Requirement: GitHub CLI dependency\n\nThe system SHALL use `gh` CLI for automatic feedback submission when available, and provide a manual submission fallback when `gh` is not installed or not authenticated. The system SHALL use platform-appropriate commands to detect `gh` CLI availability.\n\n#### Scenario: Missing gh CLI with fallback\n\n- **WHEN** user runs `openspec feedback \"message\"`\n- **AND** `gh` CLI is not installed (not found in PATH)\n- **THEN** the system displays warning: \"GitHub CLI not found. Manual submission required.\"\n- **AND** outputs structured feedback content with delimiters:\n  - \"--- FORMATTED FEEDBACK ---\"\n  - Title line\n  - Labels line\n  - Body content with metadata\n  - \"--- END FEEDBACK ---\"\n- **AND** displays pre-filled GitHub issue URL for manual submission\n- **AND** exits with zero code (successful fallback)\n\n#### Scenario: Cross-platform gh CLI detection on Unix\n\n- **WHEN** system is running on macOS or Linux (platform is 'darwin' or 'linux')\n- **AND** checking if `gh` CLI is installed\n- **THEN** the system executes `which gh` command\n\n#### Scenario: Cross-platform gh CLI detection on Windows\n\n- **WHEN** system is running on Windows (platform is 'win32')\n- **AND** checking if `gh` CLI is installed\n- **THEN** the system executes `where gh` command\n\n#### Scenario: Unauthenticated gh CLI with fallback\n\n- **WHEN** user runs `openspec feedback \"message\"`\n- **AND** `gh` CLI is installed but not authenticated\n- **THEN** the system displays warning: \"GitHub authentication required. Manual submission required.\"\n- **AND** outputs structured feedback content (same format as missing gh CLI scenario)\n- **AND** displays pre-filled GitHub issue URL for manual submission\n- **AND** displays authentication instructions: \"To auto-submit in the future: gh auth login\"\n- **AND** exits with zero code (successful fallback)\n\n#### Scenario: Authenticated gh CLI\n\n- **WHEN** user runs `openspec feedback \"message\"`\n- **AND** `gh auth status` returns success (authenticated)\n- **THEN** the system proceeds with feedback submission\n\n### Requirement: Issue metadata\n\nThe system SHALL include relevant metadata in the GitHub Issue body.\n\n#### Scenario: Standard metadata\n\n- **WHEN** creating a GitHub Issue for feedback\n- **THEN** the issue body includes:\n  - OpenSpec CLI version\n  - Platform (darwin, linux, win32)\n  - Submission timestamp\n  - Separator line: \"---\\nSubmitted via OpenSpec CLI\"\n\n#### Scenario: Windows platform metadata\n\n- **WHEN** creating a GitHub Issue for feedback on Windows\n- **THEN** the issue body includes \"Platform: win32\"\n- **AND** all platform detection uses Node.js `os.platform()` API\n\n#### Scenario: No sensitive metadata\n\n- **WHEN** creating a GitHub Issue for feedback\n- **THEN** the issue body does NOT include:\n  - File paths from user's system\n  - Project names or directory names\n  - Environment variables\n  - IP addresses\n\n### Requirement: Feedback always works\n\nThe system SHALL allow feedback submission regardless of telemetry settings.\n\n#### Scenario: Feedback with telemetry disabled\n\n- **WHEN** user has disabled telemetry via `OPENSPEC_TELEMETRY=0`\n- **AND** user runs `openspec feedback \"message\"`\n- **THEN** the feedback is still submitted via `gh` CLI\n- **AND** telemetry events are not sent\n\n#### Scenario: Feedback in CI environment\n\n- **WHEN** `CI=true` is set in the environment\n- **AND** user runs `openspec feedback \"message\"`\n- **THEN** the feedback submission proceeds normally (if `gh` is available and authenticated)\n\n### Requirement: Error handling\n\nThe system SHALL handle feedback submission errors gracefully.\n\n#### Scenario: gh CLI execution failure\n\n- **WHEN** `gh issue create` command fails\n- **THEN** the system displays the error output from `gh` CLI\n- **AND** exits with the same exit code as `gh`\n\n#### Scenario: Network failure\n\n- **WHEN** `gh` CLI reports network connectivity issues\n- **THEN** the system displays the error message from `gh`\n- **AND** suggests checking network connectivity\n- **AND** exits with non-zero code\n\n### Requirement: Feedback skill for agents\n\nThe system SHALL provide a `/feedback` skill that guides agents through collecting and submitting user feedback.\n\n#### Scenario: Agent-initiated feedback\n\n- **WHEN** user invokes `/feedback` in an agent conversation\n- **THEN** the agent gathers context from the conversation\n- **AND** drafts a feedback issue with enriched content\n- **AND** anonymizes sensitive information\n- **AND** presents the draft to the user for approval\n- **AND** submits via `openspec feedback` command on user confirmation\n\n#### Scenario: Context enrichment\n\n- **WHEN** agent drafts feedback\n- **THEN** the agent includes relevant context such as:\n  - What task was being performed\n  - What worked well or poorly\n  - Specific friction points or praise\n\n#### Scenario: Anonymization\n\n- **WHEN** agent drafts feedback\n- **THEN** the agent removes or replaces:\n  - File paths with `<path>` or generic descriptions\n  - API keys, tokens, secrets with `<redacted>`\n  - Company/organization names with `<company>`\n  - Personal names with `<user>`\n  - Specific URLs with `<url>` unless public/relevant\n\n#### Scenario: User confirmation required\n\n- **WHEN** agent has drafted feedback\n- **THEN** the agent MUST show the complete draft to the user\n- **AND** ask for explicit approval before submitting\n- **AND** allow the user to request modifications\n- **AND** only submit after user confirms\n\n### Requirement: Shell completions\n\nThe system SHALL provide shell completions for the feedback command.\n\n#### Scenario: Command completion\n\n- **WHEN** user types `openspec fee<TAB>`\n- **THEN** the shell completes to `openspec feedback`\n\n#### Scenario: Flag completion\n\n- **WHEN** user types `openspec feedback \"msg\" --<TAB>`\n- **THEN** the shell suggests available flags (`--body`)\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-feedback-command/tasks.md",
    "content": "## 1. Feedback Command\n\n- [x] 1.1 Create `src/commands/feedback.ts` with command implementation\n- [x] 1.2 Check `gh` CLI availability using platform-appropriate command (`which` on Unix/macOS, `where` on Windows)\n- [x] 1.3 Check GitHub auth status with `gh auth status`\n- [x] 1.4 Execute `gh issue create` with formatted title and body using `execFileSync` to prevent shell injection\n- [x] 1.5 Display issue URL returned by `gh` CLI\n- [x] 1.6 Register `feedback <message>` command in `src/cli/index.ts`\n- [x] 1.7 Ensure cross-platform compatibility (macOS, Linux, Windows)\n\n## 2. Shell Completions\n\n- [x] 2.1 Add `feedback` command to command registry\n- [x] 2.2 Regenerate completion scripts for all shells\n\n## 3. Feedback Skill\n\n- [x] 3.1 Create feedback skill template in `skill-templates.ts`\n- [x] 3.2 Document context gathering workflow\n- [x] 3.3 Document anonymization rules\n- [x] 3.4 Document user confirmation flow\n\n## 4. Testing\n\n- [x] 4.1 Add unit tests for feedback command (mock `gh` subprocess calls)\n- [x] 4.2 Add integration test for full feedback flow with mocked `gh` CLI\n- [x] 4.3 Test error handling for missing `gh` CLI\n- [x] 4.4 Test error handling for unauthenticated `gh` session\n- [x] 4.5 Test cross-platform `gh` CLI detection (verify `which` on Unix, `where` on Windows)\n- [x] 4.6 Test platform metadata includes correct value for Windows (win32)\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-opsx-onboard-skill/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-01-24\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-opsx-onboard-skill/design.md",
    "content": "## Context\n\nOpenSpec has a complete skill and slash command generation system. Skills are defined in `src/core/templates/skill-templates.ts` as functions that return `SkillTemplate` objects (for Agent Skills) and `CommandTemplate` objects (for slash commands). These are registered in `src/core/shared/skill-generation.ts` and generated during `openspec init` and `openspec update`.\n\nExisting skills follow a consistent pattern:\n- `getXxxSkillTemplate()` returns the skill with name, description, instructions\n- `getOpsxXxxCommandTemplate()` returns the slash command with name, description, category, tags, content\n- Both are registered in their respective arrays in `skill-generation.ts`\n\n## Goals / Non-Goals\n\n**Goals:**\n- Add `/opsx:onboard` skill that teaches the OpenSpec workflow through guided practice\n- Follow existing patterns for skill/command template generation\n- Provide comprehensive narration that explains each step\n- Include codebase analysis to suggest real, appropriately-scoped tasks\n\n**Non-Goals:**\n- Creating a separate \"demo mode\" or simulated workflow (we do real work)\n- Adding new CLI commands (this is purely agent instructions)\n- Modifying the init/update flow (just adding to the template arrays)\n\n## Decisions\n\n### Decision 1: Single Monolithic Skill\n\nThe onboard skill will be a single comprehensive instruction set rather than composing existing skills with flags.\n\n**Rationale:**\n- Slash commands don't support flags (they're just prompts)\n- A monolithic skill gives complete control over narration and pacing\n- Easier to maintain a single cohesive experience\n- Users learn the real commands by seeing them mentioned in narration\n\n### Decision 2: Codebase Analysis Patterns\n\nThe skill instructions will direct the agent to look for specific patterns when suggesting starter tasks:\n\n1. TODO/FIXME comments in code\n2. Missing error handling (`catch` blocks that swallow errors, no try-catch around risky operations)\n3. Functions without tests (cross-reference src/ with test files)\n4. Type: `any` in TypeScript files\n5. Console.log statements in non-debug code\n6. Missing input validation on user-facing inputs\n7. Recent git commits (for context on what user is working on)\n\n**Rationale:** These are universally applicable, easy to detect, and produce well-scoped tasks.\n\n### Decision 3: Narration Integration Style\n\nEach phase will follow a pattern:\n1. **EXPLAIN** what we're about to do and why (1-2 sentences)\n2. **DO** the action (run command, create artifact)\n3. **SHOW** what happened\n4. **PAUSE** at key transitions (not every step)\n\nPauses occur at:\n- After task selection (before creating change)\n- After drafting proposal (before saving)\n- After tasks are generated (before implementation)\n- After archive (final recap)\n\n**Rationale:** Too many pauses becomes tedious. Too few loses the teaching opportunity. These are the natural \"chapter breaks.\"\n\n### Decision 4: Scope Guardrail Approach\n\nWhen user selects a task that's too large, the skill will:\n1. Acknowledge the task is valuable\n2. Explain why smaller is better for first time\n3. Suggest a smaller slice or alternative\n4. Let user override if they insist\n\n**Rationale:** Soft guardrails teach without frustrating. Users learn scope calibration as part of the experience.\n\n### Decision 5: Template Structure\n\nThe skill template will be ~400-600 lines of instruction text, structured as:\n\n```\n- Preflight checks (init status)\n- Phase 1: Welcome & Setup\n- Phase 2: Task Selection (with codebase analysis instructions)\n- Phase 3: Explore Demo (brief)\n- Phase 4: Change Creation\n- Phase 5: Proposal\n- Phase 6: Specs\n- Phase 7: Design\n- Phase 8: Tasks\n- Phase 9: Apply (Implementation)\n- Phase 10: Archive\n- Phase 11: Recap & Next Steps\n- Edge cases & graceful exits\n```\n\nThe command template will be identical to the skill template (same content, different wrapper).\n\n**Rationale:** Following the established pattern where skill and command share the same core instructions.\n\n## Risks / Trade-offs\n\n**Risk: Instruction length**\nThe skill will be significantly longer than existing skills (~500 lines vs ~100-200).\n→ Mitigation: This is acceptable since onboarding is inherently comprehensive. Token cost is one-time per session.\n\n**Risk: Codebase analysis may find nothing**\nSome codebases (new projects, very clean code) may not have obvious improvement opportunities.\n→ Mitigation: Fall back to asking user what they want to build. Include \"add a new feature\" as an option.\n\n**Risk: Task suggestions may be inappropriate**\nAgent might suggest tasks that touch sensitive code or have hidden complexity.\n→ Mitigation: User always chooses; agent just suggests. Scope estimates help set expectations.\n\n**Risk: User abandons mid-way**\nOnboarding takes ~15 minutes; users may not complete it.\n→ Mitigation: Graceful exit handling - note the change is saved, explain how to continue later.\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-opsx-onboard-skill/proposal.md",
    "content": "## Why\n\nUsers who run `openspec init` are left with files but no clear path to actually using the system. There's a gap between \"I have OpenSpec set up\" and \"I understand the workflow.\" An onboarding skill would guide users through their first complete change cycle on a real task in their codebase, teaching the workflow by doing it.\n\n## What Changes\n\n- Add new `/opsx:onboard` skill that guides users through their first OpenSpec change\n- Add corresponding slash command template for editor integrations\n- The skill will:\n  - Analyze the user's codebase to suggest appropriately-scoped starter tasks\n  - Walk through the full workflow (explore → new → proposal → specs → design → tasks → apply → archive)\n  - Provide narration explaining each step as it happens\n  - Result in a real, implemented change in the user's codebase\n\n## Capabilities\n\n### New Capabilities\n- `opsx-onboard-skill`: The onboarding skill that guides users through their first complete OpenSpec workflow cycle with narration and codebase-aware task suggestions\n\n### Modified Capabilities\n<!-- No existing specs are being modified - this is purely additive -->\n\n## Impact\n\n- `src/core/templates/skill-templates.ts`: Add `getOnboardSkillTemplate()` and `getOpsxOnboardCommandTemplate()` functions\n- `src/core/shared/skill-generation.ts`: Register the new skill and command templates in `getSkillTemplates()` and `getCommandTemplates()`\n- Users running `openspec init` or `openspec update` will get the new skill/command files generated\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-opsx-onboard-skill/specs/opsx-onboard-skill/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: OPSX Onboard Skill\n\nThe system SHALL provide an `/opsx:onboard` skill that guides users through their first complete OpenSpec workflow cycle with narration and real codebase work.\n\n#### Scenario: Skill invocation\n\n- **WHEN** user invokes `/opsx:onboard`\n- **THEN** agent checks if OpenSpec is initialized\n- **AND** if not initialized, prompts user to run `openspec init` first\n- **AND** if initialized, proceeds with onboarding flow\n\n#### Scenario: Welcome and expectations\n\n- **WHEN** onboarding begins\n- **THEN** agent displays welcome message explaining what will happen\n- **AND** sets expectation of ~15 minute duration\n- **AND** explains the workflow phases: explore → new → artifacts → apply → archive\n\n### Requirement: Codebase Analysis for Task Suggestions\n\nThe skill SHALL analyze the user's codebase to suggest appropriately-scoped starter tasks.\n\n#### Scenario: Codebase scanning\n\n- **WHEN** onboarding reaches task selection phase\n- **THEN** agent scans codebase for small improvement opportunities\n- **AND** looks for: TODO/FIXME comments, missing error handling, functions without tests, outdated dependencies, type: any in TypeScript, console.log in production code, missing input validation\n- **AND** checks recent git commits for context on current work\n\n#### Scenario: Task suggestion presentation\n\n- **WHEN** agent has analyzed codebase\n- **THEN** agent presents 3-4 specific task suggestions with scope estimates\n- **AND** each suggestion includes: task description, estimated scope (files/lines), why it's a good starter\n- **AND** offers option for user to specify their own task\n\n#### Scenario: Scope guardrail\n\n- **WHEN** user selects or describes a task that is too large\n- **THEN** agent gently redirects toward smaller scope\n- **AND** suggests breaking down or deferring the large task\n- **AND** offers appropriately-sized alternatives\n\n### Requirement: Explore Phase Demo\n\nThe skill SHALL briefly demonstrate explore mode before creating a change.\n\n#### Scenario: Brief explore demonstration\n\n- **WHEN** task is selected\n- **THEN** agent briefly demonstrates `/opsx:explore` by investigating relevant code\n- **AND** explains explore mode is for thinking before doing\n- **AND** keeps this phase short (not a full exploration session)\n- **AND** transitions to change creation\n\n### Requirement: Guided Artifact Creation\n\nThe skill SHALL guide users through each artifact with narration explaining the purpose.\n\n#### Scenario: Change creation with narration\n\n- **WHEN** creating the change directory\n- **THEN** agent runs `openspec new change \"<name>\"` with derived kebab-case name\n- **AND** explains what a \"change\" is (container for thinking and planning)\n- **AND** shows the folder structure that was created\n- **AND** pauses for user acknowledgment before proceeding\n\n#### Scenario: Proposal creation with narration\n\n- **WHEN** creating proposal.md\n- **THEN** agent explains proposals capture WHY we're making this change\n- **AND** drafts proposal based on selected task\n- **AND** shows draft to user for approval before saving\n- **AND** explains the sections (Why, What Changes, Capabilities, Impact)\n\n#### Scenario: Specs creation with narration\n\n- **WHEN** creating spec files\n- **THEN** agent explains specs define WHAT we're building in detail\n- **AND** explains the requirement/scenario format\n- **AND** creates spec file(s) based on proposal capabilities\n- **AND** notes that specs become documentation that stays in sync\n\n#### Scenario: Design creation with narration\n\n- **WHEN** creating design.md\n- **THEN** agent explains design captures HOW we'll build it\n- **AND** notes this is where technical decisions and tradeoffs live\n- **AND** for small changes, acknowledges design may be brief\n- **AND** creates design based on proposal and specs\n\n#### Scenario: Tasks creation with narration\n\n- **WHEN** creating tasks.md\n- **THEN** agent explains tasks break work into checkboxes\n- **AND** explains these drive the apply phase\n- **AND** generates task list from design and specs\n- **AND** shows tasks and asks if ready to implement\n\n### Requirement: Guided Implementation\n\nThe skill SHALL implement tasks with narration connecting back to artifacts.\n\n#### Scenario: Implementation with narration\n\n- **WHEN** implementing tasks\n- **THEN** agent announces each task before working on it\n- **AND** implements the change in the codebase\n- **AND** occasionally references how specs/design informed decisions\n- **AND** marks each task complete as it finishes\n- **AND** keeps narration light (not over-explaining)\n\n#### Scenario: Implementation completion\n\n- **WHEN** all tasks are complete\n- **THEN** agent announces completion\n- **AND** summarizes what was done\n- **AND** transitions to archive phase\n\n### Requirement: Archive with Explanation\n\nThe skill SHALL archive the completed change and explain what happened.\n\n#### Scenario: Archive with narration\n\n- **WHEN** archiving the change\n- **THEN** agent explains archive moves change to dated folder\n- **AND** runs archive process\n- **AND** shows where archived change lives\n- **AND** explains the long-term value (finding decisions later)\n\n### Requirement: Recap and Next Steps\n\nThe skill SHALL conclude with a recap and command reference.\n\n#### Scenario: Final recap\n\n- **WHEN** onboarding is complete\n- **THEN** agent summarizes the workflow phases completed\n- **AND** emphasizes this rhythm works for any size change\n- **AND** provides command reference table (/opsx:explore, /opsx:new, /opsx:ff, /opsx:continue, /opsx:apply, /opsx:verify, /opsx:archive)\n- **AND** suggests next actions (try /opsx:new or /opsx:ff on something)\n\n### Requirement: Graceful Exit Handling\n\nThe skill SHALL handle users who want to stop mid-way.\n\n#### Scenario: User wants to stop\n\n- **WHEN** user indicates they want to stop during onboarding\n- **THEN** agent acknowledges gracefully\n- **AND** notes that the in-progress change is saved\n- **AND** explains how to continue later with `/opsx:continue <name>`\n- **AND** exits without pressure\n\n#### Scenario: User wants quick reference only\n\n- **WHEN** user says they just want to see the commands\n- **THEN** agent provides command cheat sheet\n- **AND** exits gracefully with encouragement to try `/opsx:new`\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-opsx-onboard-skill/tasks.md",
    "content": "## 1. Add Skill Template\n\n- [x] 1.1 Add `getOnboardSkillTemplate()` function to `src/core/templates/skill-templates.ts` with full onboarding instruction text covering all phases (preflight, welcome, task selection, explore demo, change creation, proposal, specs, design, tasks, apply, archive, recap)\n- [x] 1.2 Include codebase analysis instructions for suggesting starter tasks (TODO/FIXME, missing error handling, missing tests, type:any, console.log, missing validation)\n- [x] 1.3 Include narration pattern instructions (EXPLAIN → DO → SHOW → PAUSE at key transitions)\n- [x] 1.4 Include scope guardrail instructions for redirecting users away from overly large tasks\n- [x] 1.5 Include graceful exit handling instructions (user stops mid-way, user just wants command reference)\n\n## 2. Add Command Template\n\n- [x] 2.1 Add `getOpsxOnboardCommandTemplate()` function to `src/core/templates/skill-templates.ts` returning CommandTemplate with same instruction content as skill\n\n## 3. Register Templates\n\n- [x] 3.1 Add onboard skill to `getSkillTemplates()` array in `src/core/shared/skill-generation.ts` with dirName `openspec-onboard`\n- [x] 3.2 Add onboard command to `getCommandTemplates()` array in `src/core/shared/skill-generation.ts` with id `onboard`\n\n## 4. Verify\n\n- [x] 4.1 Run `pnpm run build` to ensure TypeScript compiles\n- [x] 4.2 Test skill generation by running `openspec init` in a test directory and verifying onboard skill/command files are created\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-verify-skill/design.md",
    "content": "# Design: Add /opsx:verify Skill\n\n## Architecture Decision: Dynamic Generation via Setup Command\n\n### Context\n\nAll existing opsx experimental skills (explore, new, continue, apply, ff, sync, archive) are dynamically generated when users run `openspec artifact-experimental-setup`. They are not manually created files checked into the repository.\n\n### Decision\n\n**Integrate verify into the existing artifact-experimental-setup system rather than creating static skill files.**\n\n### Rationale\n\n1. **Consistency**: All 7 existing opsx skills follow this pattern. Adding verify as the 8th skill should follow the same architecture.\n\n2. **Maintainability**: Template functions in `skill-templates.ts` are the single source of truth. Changes to skill definitions automatically propagate to all users when they re-run setup.\n\n3. **Distribution**: Users get the verify skill automatically when running `openspec artifact-experimental-setup`, just like all other opsx skills. No special installation steps needed.\n\n4. **Versioning**: Skills are generated from the installed npm package version, ensuring consistency between CLI version and skill behavior.\n\n### Implementation Approach\n\n#### 1. Template Functions\n\nAdd two template functions to `src/core/templates/skill-templates.ts`:\n\n```typescript\nexport function getVerifyChangeSkillTemplate(): SkillTemplate\nexport function getOpsxVerifyCommandTemplate(): CommandTemplate\n```\n\nThese return the skill definition (for Agent Skills) and slash command definition (for explicit invocation).\n\n#### 2. Setup Integration\n\nUpdate `artifactExperimentalSetupCommand()` in `src/commands/artifact-workflow.ts`:\n\n- Import both template functions\n- Add verify to the `skills` array (position 8)\n- Add verify to the `commands` array (position 8)\n- Update help text to list `/opsx:verify`\n\n#### 3. Generated Artifacts\n\nWhen users run `openspec artifact-experimental-setup`, the command creates:\n\n- `.claude/skills/openspec-verify-change/SKILL.md` - Agent Skills format\n- `.claude/commands/opsx/verify.md` - Slash command format\n\nBoth are generated from the template functions, with YAML frontmatter automatically added.\n\n### Alternatives Considered\n\n**Alternative 1: Static skill files in repository**\n\nCreate `.claude/skills/openspec-verify-change/SKILL.md` as a static file in the OpenSpec repository.\n\n**Rejected because:**\n- Inconsistent with all other opsx skills\n- Requires users to manually copy/update files\n- Versioning becomes complicated (repo version vs installed package version)\n- Breaks the established pattern\n\n**Alternative 2: Separate verify setup command**\n\nAdd `openspec setup-verify` as a separate command.\n\n**Rejected because:**\n- Fragments the setup experience\n- Users would need to run multiple commands\n- Doesn't scale if we add more skills in the future\n- Goes against the \"setup once, get everything\" philosophy\n\n### Trade-offs\n\n**Advantages:**\n- Consistent with existing architecture\n- Zero additional setup burden for users\n- Easy to update and maintain\n- Automatic version compatibility\n\n**Disadvantages:**\n- Slightly more complex initial implementation (template functions + integration)\n- Requires understanding the setup system (but that's already documented)\n\n### Verification\n\nThe implementation correctly follows this design if:\n\n1. Both template functions exist in `skill-templates.ts`\n2. Verify appears in both skills and commands arrays in `artifact-workflow.ts`\n3. Help text mentions `/opsx:verify`\n4. Running `openspec artifact-experimental-setup` generates both skill and command files\n5. Build succeeds with no TypeScript errors\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-verify-skill/proposal.md",
    "content": "# Change: Add /opsx:verify Skill\n\n## Why\n\nUsers need a way to validate that their implementation actually matches what was requested before archiving a change. Currently, there's no systematic way to check:\n- Whether all tasks are truly complete\n- Whether the implementation covers all spec requirements and scenarios\n- Whether the implementation follows the design decisions\n- Whether the code is coherent and makes sense\n\nA user requested: \"Can we get a :verify that will ensure that the implementation matches what was requested?\"\n\n## What Changes\n\n- Add `getVerifyChangeSkillTemplate()` function to `skill-templates.ts`\n- Add `getOpsxVerifyCommandTemplate()` function to `skill-templates.ts`\n- Integrate verify skill into `artifactExperimentalSetupCommand` in `artifact-workflow.ts`\n- Add verify to the skills and commands arrays in the setup command\n- Update help text to include `/opsx:verify` in the list of available commands\n- Create `opsx-verify-skill` capability spec\n\n## Verification Dimensions\n\nThe skill verifies across three dimensions:\n\n1. **Completeness** - Are all tasks done? Are all specs addressed?\n2. **Correctness** - Does the implementation match specs? Are scenarios covered?\n3. **Coherence** - Does the implementation make sense? Does it follow design.md?\n\n## Output Format\n\nProduces a prioritized report with:\n- Summary scorecard (tasks, specs, design adherence)\n- Critical issues first (must fix before archive)\n- Warnings second (should fix)\n- Suggestions third (nice to have)\n- Actionable fix recommendations for each issue\n\n## Impact\n\n- Affected specs: New `opsx-verify-skill` spec\n- Affected code:\n  - `src/core/templates/skill-templates.ts` - Added 2 new template functions\n  - `src/commands/artifact-workflow.ts` - Integrated verify into experimental setup\n- Generated artifacts: When users run `openspec artifact-experimental-setup`:\n  - Creates `.claude/skills/openspec-verify-change/SKILL.md`\n  - Creates `.claude/commands/opsx/verify.md`\n- Related skills: Works alongside `/opsx:apply` and before `/opsx:archive`\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-verify-skill/specs/opsx-verify-skill/spec.md",
    "content": "# opsx-verify-skill Specification\n\n## Purpose\nDefines the agent skill for verifying that implementation matches change artifacts (specs, tasks, design).\n\n## ADDED Requirements\n\n### Requirement: Verify Skill Invocation\nThe system SHALL provide an `/opsx:verify` skill that validates implementation against change artifacts.\n\n#### Scenario: Verify with change name provided\n- **WHEN** agent executes `/opsx:verify <change-name>`\n- **THEN** the agent verifies implementation for that specific change\n- **AND** produces a verification report\n\n#### Scenario: Verify without change name\n- **WHEN** agent executes `/opsx:verify` without a change name\n- **THEN** the agent prompts user to select from available changes\n- **AND** shows only changes that have implementation tasks\n\n#### Scenario: Change has no tasks\n- **WHEN** selected change has no tasks.md or tasks are empty\n- **THEN** the agent reports \"No tasks to verify\"\n- **AND** suggests running `/opsx:continue` to create tasks\n\n### Requirement: Completeness Verification\nThe agent SHALL verify that all required work has been completed.\n\n#### Scenario: Task completion check\n- **WHEN** verifying completeness\n- **THEN** the agent reads tasks.md\n- **AND** counts tasks marked `- [x]` (complete) vs `- [ ]` (incomplete)\n- **AND** reports completion status with specific incomplete tasks listed\n\n#### Scenario: Spec coverage check\n- **WHEN** verifying completeness\n- **AND** delta specs exist in `openspec/changes/<name>/specs/`\n- **THEN** the agent extracts all requirements from delta specs\n- **AND** searches codebase for implementation of each requirement\n- **AND** reports which requirements appear to have implementation vs which are missing\n\n#### Scenario: All tasks complete\n- **WHEN** all tasks are marked complete\n- **THEN** report \"Tasks: N/N complete\"\n- **AND** mark completeness dimension as passed\n\n#### Scenario: Incomplete tasks found\n- **WHEN** some tasks are incomplete\n- **THEN** report \"Tasks: X/N complete\"\n- **AND** list each incomplete task\n- **AND** mark as CRITICAL issue\n- **AND** suggest: \"Complete remaining tasks or mark as done if already implemented\"\n\n### Requirement: Correctness Verification\nThe agent SHALL verify that implementation matches the specifications.\n\n#### Scenario: Requirement implementation mapping\n- **WHEN** verifying correctness\n- **THEN** for each requirement in delta specs:\n  - Search codebase for implementation\n  - Identify relevant files and line numbers\n  - Assess whether implementation satisfies the requirement\n\n#### Scenario: Scenario coverage check\n- **WHEN** verifying correctness\n- **THEN** for each scenario in delta specs:\n  - Check if the scenario's conditions are handled in code\n  - Check if tests exist that cover the scenario\n  - Report coverage status\n\n#### Scenario: Implementation matches spec\n- **WHEN** implementation appears to satisfy a requirement\n- **THEN** report which files/lines implement it\n- **AND** mark requirement as covered\n\n#### Scenario: Implementation diverges from spec\n- **WHEN** implementation exists but doesn't match spec intent\n- **THEN** report the divergence as WARNING\n- **AND** explain what differs\n- **AND** suggest: either update implementation or update spec to match reality\n\n#### Scenario: Missing implementation\n- **WHEN** no implementation found for a requirement\n- **THEN** report as CRITICAL issue\n- **AND** suggest: \"Implement requirement X\" with guidance on what's needed\n\n### Requirement: Coherence Verification\nThe agent SHALL verify that implementation is sensible and follows design decisions.\n\n#### Scenario: Design.md adherence check\n- **WHEN** verifying coherence\n- **AND** design.md exists for the change\n- **THEN** extract key decisions from design.md\n- **AND** verify implementation follows those decisions\n- **AND** report any deviations\n\n#### Scenario: No design.md\n- **WHEN** verifying coherence\n- **AND** no design.md exists\n- **THEN** skip design adherence check\n- **AND** note \"No design.md to verify against\"\n\n#### Scenario: Design decision followed\n- **WHEN** implementation follows a design decision\n- **THEN** report as confirmed\n- **AND** cite evidence from code\n\n#### Scenario: Design decision violated\n- **WHEN** implementation contradicts a design decision\n- **THEN** report as WARNING\n- **AND** explain the contradiction\n- **AND** suggest: either update implementation or update design.md\n\n#### Scenario: Code pattern consistency\n- **WHEN** verifying coherence\n- **THEN** check if new code follows existing project patterns\n- **AND** flag any significant deviations as suggestions\n\n### Requirement: Verification Report Format\nThe agent SHALL produce a structured, prioritized report.\n\n#### Scenario: Report summary\n- **WHEN** verification completes\n- **THEN** display summary scorecard:\n  ```\n  ## Verification Report: <change-name>\n\n  ### Summary\n  | Dimension    | Status   |\n  |--------------|----------|\n  | Completeness | X/Y      |\n  | Correctness  | X/Y      |\n  | Coherence    | Followed |\n  ```\n\n#### Scenario: Issue prioritization\n- **WHEN** issues are found\n- **THEN** group and display in priority order:\n  1. CRITICAL - Must fix before archive (missing implementation, incomplete tasks)\n  2. WARNING - Should fix (divergence from spec/design, missing tests)\n  3. SUGGESTION - Nice to fix (pattern inconsistencies, minor improvements)\n\n#### Scenario: Actionable recommendations\n- **WHEN** reporting an issue\n- **THEN** include specific, actionable fix recommendation\n- **AND** reference relevant files and line numbers where applicable\n- **AND** avoid vague suggestions like \"consider reviewing\"\n\n#### Scenario: All checks pass\n- **WHEN** no issues found across all dimensions\n- **THEN** display:\n  ```\n  All checks passed. Ready for archive.\n  ```\n\n#### Scenario: Critical issues found\n- **WHEN** CRITICAL issues exist\n- **THEN** display:\n  ```\n  X critical issue(s) found. Fix before archiving.\n  ```\n- **AND** do NOT suggest running archive\n\n#### Scenario: Only warnings/suggestions\n- **WHEN** no CRITICAL issues but warnings exist\n- **THEN** display:\n  ```\n  No critical issues. Y warning(s) to consider.\n  Ready for archive (with noted improvements).\n  ```\n\n### Requirement: Flexible Artifact Handling\nThe agent SHALL gracefully handle changes with varying artifact completeness.\n\n#### Scenario: Minimal change (tasks only)\n- **WHEN** change has only tasks.md\n- **THEN** verify task completion only\n- **AND** skip spec and design checks\n- **AND** note which checks were skipped\n\n#### Scenario: Change with specs but no design\n- **WHEN** change has tasks.md and delta specs but no design.md\n- **THEN** verify completeness and correctness\n- **AND** skip design adherence\n- **AND** still check code coherence against project patterns\n\n#### Scenario: Full change (all artifacts)\n- **WHEN** change has proposal, design, specs, and tasks\n- **THEN** perform all verification checks\n- **AND** cross-reference artifacts for consistency\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-add-verify-skill/tasks.md",
    "content": "# Tasks: Add /opsx:verify Skill\n\n## 1. Skill Template Functions\n- [x] 1.1 Add `getVerifyChangeSkillTemplate()` to skill-templates.ts\n- [x] 1.2 Add `getOpsxVerifyCommandTemplate()` to skill-templates.ts\n\n## 2. Integration with artifact-experimental-setup\n- [x] 2.1 Import verify template functions in artifact-workflow.ts\n- [x] 2.2 Add verify to skills array in artifactExperimentalSetupCommand\n- [x] 2.3 Add verify to commands array in artifactExperimentalSetupCommand\n- [x] 2.4 Add verify to help text output\n\n## 3. Verification (Build & Test)\n- [x] 3.1 Verify TypeScript compilation succeeds\n- [x] 3.2 Verify all 8 skills are now included (was 7, now 8)\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-merge-init-experimental/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-01-23\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-merge-init-experimental/design.md",
    "content": "## Context\n\nCurrently `openspec init` and `openspec experimental` are separate commands with distinct purposes:\n\n- **init**: Creates `openspec/` directory, generates `AGENTS.md`/`project.md`, configures tool config files (`CLAUDE.md`, etc.), generates old slash commands (`/openspec:proposal`, etc.)\n- **experimental**: Generates skills (9 per tool), generates opsx slash commands (`/opsx:new`, etc.), creates `config.yaml`\n\nThe skill-based workflow (experimental) is the direction we're going, so we're making it the default by merging into `init`.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Single `openspec init` command that sets up the complete skill-based workflow\n- Clean migration path for existing users with legacy artifacts\n- Remove all code related to config files and old slash commands\n- Keep the polished UX from experimental (animated welcome, searchable multi-select)\n\n**Non-Goals:**\n- Supporting both workflows simultaneously\n- Providing options to use the old workflow\n- Backward compatibility for `/openspec:*` commands (breaking change)\n\n## Decisions\n\n### Decision 1: Merge into init, not into experimental\n\n**Choice**: Rewrite `init` to do what `experimental` does, then delete `experimental`.\n\n**Rationale**: `init` is the canonical setup command. Users expect `init` to set up their project. `experimental` was always meant to be temporary.\n\n**Alternatives considered**:\n- Keep `experimental` as the main command → confusing name for default behavior\n- Create new command → unnecessary, `init` already exists\n\n### Decision 2: Legacy cleanup with Y/N prompt\n\n**Choice**: Detect legacy artifacts, show what was found, prompt `\"Legacy files detected. Upgrade and clean up? [Y/n]\"`, then remove if confirmed.\n\n**Rationale**: Users should know what's being removed. A single Y/N is simple and decisive. No need for multiple options.\n\n**Alternatives considered**:\n- Multiple options (keep/remove/cancel) → overcomplicated\n- Silent removal → users might be surprised\n- Just warn without removing → leaves cruft\n\n### Decision 3: Surgical removal of legacy content\n\n**Choice**: For files with mixed content (OpenSpec markers + user content), only remove the OpenSpec marker block. For files that are 100% OpenSpec content, delete the entire file.\n\n**Rationale**: Respects user customizations. CLAUDE.md might have other instructions beyond OpenSpec.\n\n**Edge cases**:\n- **Config files with mixed content**: Remove only `<!-- OPENSPEC:START -->` to `<!-- OPENSPEC:END -->` block\n- **Config files that are 100% OpenSpec**: Delete file entirely (check if content outside markers is empty/whitespace)\n- **Old slash command directories** (`.claude/commands/openspec/`): Delete entire directory (ours)\n- **`openspec/AGENTS.md`**: Delete (ours)\n- **Root `AGENTS.md`**: Only remove OpenSpec marker block, preserve rest\n\n### Decision 6: Preserve project.md with migration hint\n\n**Choice**: Do NOT auto-delete `openspec/project.md`. Preserve it and show a message directing users to manually migrate content to `config.yaml`'s `context:` field.\n\n**Rationale**:\n- `project.md` may contain valuable user-written project documentation\n- The new workflow uses `config.yaml.context` for the same purpose (auto-injected into artifacts)\n- Auto-deleting would lose user content; auto-migrating is complex (needs LLM to compress)\n- Users can migrate manually or use `/opsx:explore` to get AI assistance\n\n**Migration path**:\n1. During legacy cleanup, detect `openspec/project.md` but do not delete\n2. Show in output: \"openspec/project.md still exists - migrate content to config.yaml's context: field, then delete\"\n3. User migrates manually or asks Claude in explore mode: \"help me migrate project.md to config.yaml\"\n4. User deletes project.md when ready\n\n**Why not auto-migrate?**\n- `project.md` is verbose (sections, headers, placeholders)\n- `config.yaml.context` should be concise and dense\n- LLM compression would be ideal but adds complexity and non-determinism to init\n- Manual migration lets users decide what's actually important\n\n### Decision 4: Hidden alias for experimental\n\n**Choice**: Keep `openspec experimental` as a hidden command that delegates to `init`.\n\n**Rationale**: Users who learned `experimental` can still use it during transition. Hidden means it won't show in help.\n\n### Decision 5: Reuse existing infrastructure\n\n**Choice**: Reuse skill templates, command adapters, welcome screen, and multi-select from experimental.\n\n**Rationale**: Already built and working. Just needs to be called from init instead of experimental.\n\n## Risks / Trade-offs\n\n| Risk | Mitigation |\n|------|------------|\n| Users with custom `/openspec:*` commands lose them | Document in release notes; old commands are in git history |\n| Mixed-content detection might be imperfect | Conservative approach: if unsure, preserve the file and warn |\n| Users confused by missing config files | Clear messaging in init output about what changed |\n| `openspec update` might break | Review and update `update` command to work with new structure |\n\n## Architecture\n\n### What init creates (after merge)\n\n```\nopenspec/\n  ├── config.yaml           # Schema settings (from experimental)\n  ├── specs/                # Empty, for user's specs\n  └── changes/              # Empty, for user's changes\n      └── archive/\n\n.<tool>/skills/             # 9 skills per selected tool\n  ├── openspec-explore/SKILL.md\n  ├── openspec-new-change/SKILL.md\n  ├── openspec-continue-change/SKILL.md\n  ├── openspec-apply-change/SKILL.md\n  ├── openspec-ff-change/SKILL.md\n  ├── openspec-verify-change/SKILL.md\n  ├── openspec-sync-specs/SKILL.md\n  ├── openspec-archive-change/SKILL.md\n  └── openspec-bulk-archive-change/SKILL.md\n\n.<tool>/commands/opsx/      # 9 slash commands per selected tool\n  ├── explore.md\n  ├── new.md\n  ├── continue.md\n  ├── apply.md\n  ├── ff.md\n  ├── verify.md\n  ├── sync.md\n  ├── archive.md\n  └── bulk-archive.md\n```\n\n### What init no longer creates\n\n- `CLAUDE.md`, `.cursorrules`, `.windsurfrules`, etc. (config files)\n- `openspec/AGENTS.md`\n- `openspec/project.md`\n- Root `AGENTS.md` stub\n- `.claude/commands/openspec/` (old slash commands)\n\n### Legacy detection targets\n\n| Artifact Type | Detection Method | Removal Method |\n|--------------|------------------|----------------|\n| Config files (CLAUDE.md, etc.) | File exists AND contains OpenSpec markers | Remove marker block; delete file if empty after |\n| Old slash command dirs | Directory exists at `.<tool>/commands/openspec/` | Delete entire directory |\n| openspec/AGENTS.md | File exists at `openspec/AGENTS.md` | Delete file |\n| openspec/project.md | File exists at `openspec/project.md` | **Preserve** - show migration hint only |\n| Root AGENTS.md | File exists at `AGENTS.md` AND contains OpenSpec markers | Remove marker block; delete file if empty after |\n\n### Code to remove\n\n- `src/core/configurators/` - entire directory (ToolRegistry, all config generators)\n- `src/core/configurators/slash/` - entire directory (SlashCommandRegistry, old command generators)\n- `src/core/templates/slash-command-templates.ts` - old `/openspec:*` content\n- `src/core/templates/claude-template.ts`\n- `src/core/templates/cline-template.ts`\n- `src/core/templates/costrict-template.ts`\n- `src/core/templates/agents-template.ts`\n- `src/core/templates/agents-root-stub.ts`\n- `src/core/templates/project-template.ts`\n- `src/commands/experimental/` - entire directory (merged into init)\n- Related test files\n\n### Code to migrate into init\n\n- Animated welcome screen (`src/ui/welcome-screen.ts`) - keep, call from init\n- Searchable multi-select (`src/prompts/searchable-multi-select.ts`) - keep, call from init\n- Skill templates (`src/core/templates/skill-templates.ts`) - keep\n- Command generation (`src/core/command-generation/`) - keep\n- Tool states detection (from `experimental/setup.ts`) - move to init\n\n## Open Questions\n\n1. **What happens to `openspec update`?** - RESOLVED\n\n   **Current behavior**: Updates `openspec/AGENTS.md`, config files (`CLAUDE.md`, etc.) via `ToolRegistry`, and old slash commands (`/openspec:*`) via `SlashCommandRegistry`.\n\n   **New behavior**: Rewrite to refresh skills and opsx commands instead:\n   - Detect which tools have skills installed (check for `.claude/skills/openspec-*/`, etc.)\n   - Refresh all 9 skill files per installed tool using `skill-templates.ts`\n   - Refresh all 9 opsx command files per installed tool using `command-generation/` adapters\n   - Remove imports of `ToolRegistry`, `SlashCommandRegistry`, `agentsTemplate`\n   - Update output messaging to reflect skills/commands instead of config files\n\n   **Key principle**: Same as current update - only refresh existing tools, don't add new ones.\n\n2. **Should we keep `openspec schemas` and other experimental subcommands?** - RESOLVED\n\n   **Decision**: Yes, keep them. Remove \"[Experimental]\" label from all subcommands (status, instructions, schemas, etc.). See task 4.3.\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-merge-init-experimental/proposal.md",
    "content": "## Why\n\nThe current setup has two separate commands (`openspec init` and `openspec experimental`) that configure different parts of the OpenSpec workflow. This creates confusion about which command to run, results in partial setups, and maintains two parallel systems (config files + old slash commands vs skills + opsx commands). Making the skill-based workflow the default simplifies onboarding and establishes a single, consistent way to use OpenSpec.\n\n## What Changes\n\n- **BREAKING**: `openspec init` now generates skills and `/opsx:*` commands instead of config files and `/openspec:*` commands\n- **BREAKING**: Config files (`CLAUDE.md`, `.cursorrules`, etc.) are no longer generated\n- **BREAKING**: Old slash commands (`/openspec:proposal`, `/openspec:apply`, `/openspec:archive`) are no longer generated\n- **BREAKING**: `openspec/AGENTS.md` and `openspec/project.md` are no longer generated\n- Merge `experimental` command functionality into `init`\n- Add legacy detection and auto-cleanup with Y/N confirmation\n- Keep `openspec experimental` as hidden alias for backward compatibility\n- Use the animated welcome screen from experimental for the unified init\n\n## Capabilities\n\n### New Capabilities\n\n- `legacy-cleanup`: Detect and remove legacy OpenSpec artifacts (config files, old slash commands, AGENTS.md) during init\n\n### Modified Capabilities\n\n- `cli-init`: Complete rewrite - generates skills and opsx commands instead of config files and old slash commands; removes AGENTS.md/project.md generation; adds legacy cleanup; uses experimental's animated welcome screen\n\n## Impact\n\n- **Code removal**: `ToolRegistry`, `SlashCommandRegistry`, config file generators, old slash command templates, AGENTS.md/project.md templates\n- **Code migration**: Move skill generation and command adapter logic from `experimental/setup.ts` into `init.ts`\n- **Commands affected**: `init` (rewritten), `experimental` (becomes hidden alias), `update` (may need adjustment)\n- **User migration**: Existing users running `init` will be prompted to clean up legacy files\n- **Breaking for**: Users relying on config files for passive triggering, users using `/openspec:*` commands\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-merge-init-experimental/specs/cli-init/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: Directory Creation\n\nThe command SHALL create the OpenSpec directory structure with config file.\n\n#### Scenario: Creating OpenSpec structure\n\n- **WHEN** `openspec init` is executed\n- **THEN** create the following directory structure:\n```\nopenspec/\n├── config.yaml\n├── specs/\n└── changes/\n    └── archive/\n```\n\n### Requirement: AI Tool Configuration\n\nThe command SHALL configure AI coding assistants with skills and slash commands using a searchable multi-select experience.\n\n#### Scenario: Prompting for AI tool selection\n\n- **WHEN** run interactively\n- **THEN** display animated welcome screen with OpenSpec logo\n- **AND** present a searchable multi-select that shows all available tools\n- **AND** mark already configured tools with \"(configured ✓)\" indicator\n- **AND** pre-select configured tools for easy refresh\n- **AND** sort configured tools to appear first in the list\n- **AND** allow filtering by typing to search\n\n#### Scenario: Selecting tools to configure\n\n- **WHEN** user selects tools and confirms\n- **THEN** generate skills in `.<tool>/skills/` directory for each selected tool\n- **AND** generate slash commands in `.<tool>/commands/opsx/` directory for each selected tool\n- **AND** create `openspec/config.yaml` with default schema setting\n\n### Requirement: Skill Generation\n\nThe command SHALL generate Agent Skills for selected AI tools.\n\n#### Scenario: Generating skills for a tool\n\n- **WHEN** a tool is selected during initialization\n- **THEN** create 9 skill directories under `.<tool>/skills/`:\n  - `openspec-explore/SKILL.md`\n  - `openspec-new-change/SKILL.md`\n  - `openspec-continue-change/SKILL.md`\n  - `openspec-apply-change/SKILL.md`\n  - `openspec-ff-change/SKILL.md`\n  - `openspec-verify-change/SKILL.md`\n  - `openspec-sync-specs/SKILL.md`\n  - `openspec-archive-change/SKILL.md`\n  - `openspec-bulk-archive-change/SKILL.md`\n- **AND** each SKILL.md SHALL contain YAML frontmatter with name and description\n- **AND** each SKILL.md SHALL contain the skill instructions\n\n### Requirement: Slash Command Generation\n\nThe command SHALL generate opsx slash commands for selected AI tools.\n\n#### Scenario: Generating slash commands for a tool\n\n- **WHEN** a tool is selected during initialization\n- **THEN** create 9 slash command files using the tool's command adapter:\n  - `/opsx:explore`\n  - `/opsx:new`\n  - `/opsx:continue`\n  - `/opsx:apply`\n  - `/opsx:ff`\n  - `/opsx:verify`\n  - `/opsx:sync`\n  - `/opsx:archive`\n  - `/opsx:bulk-archive`\n- **AND** use tool-specific path conventions (e.g., `.claude/commands/opsx/` for Claude)\n- **AND** include tool-specific frontmatter format\n\n### Requirement: Success Output\n\nThe command SHALL provide clear, actionable next steps upon successful initialization.\n\n#### Scenario: Displaying success message\n\n- **WHEN** initialization completes successfully\n- **THEN** display categorized summary:\n  - \"Created: <tools>\" for newly configured tools\n  - \"Refreshed: <tools>\" for already-configured tools that were updated\n  - Count of skills and commands generated\n- **AND** display getting started section with:\n  - `/opsx:new` - Start a new change\n  - `/opsx:continue` - Create the next artifact\n  - `/opsx:apply` - Implement tasks\n- **AND** display links to documentation and feedback\n\n#### Scenario: Displaying restart instruction\n\n- **WHEN** initialization completes successfully and tools were created or refreshed\n- **THEN** display instruction to restart IDE for slash commands to take effect\n\n### Requirement: Config File Generation\n\nThe command SHALL create an OpenSpec config file with schema settings.\n\n#### Scenario: Creating config.yaml\n\n- **WHEN** initialization completes\n- **AND** config.yaml does not exist\n- **THEN** create `openspec/config.yaml` with default schema setting\n- **AND** display config location in output\n\n#### Scenario: Preserving existing config.yaml\n\n- **WHEN** initialization runs in extend mode\n- **AND** `openspec/config.yaml` already exists\n- **THEN** preserve the existing config file\n- **AND** display \"(exists)\" indicator in output\n\n### Requirement: Non-Interactive Mode\n\nThe command SHALL support non-interactive operation through command-line options.\n\n#### Scenario: Select all tools non-interactively\n\n- **WHEN** run with `--tools all`\n- **THEN** automatically select every available AI tool without prompting\n- **AND** proceed with skill and command generation\n\n#### Scenario: Select specific tools non-interactively\n\n- **WHEN** run with `--tools claude,cursor`\n- **THEN** parse the comma-separated tool IDs\n- **AND** generate skills and commands for specified tools only\n\n#### Scenario: Skip tool configuration non-interactively\n\n- **WHEN** run with `--tools none`\n- **THEN** create only the openspec directory structure and config.yaml\n- **AND** skip skill and command generation\n\n### Requirement: Experimental Command Alias\n\nThe command SHALL maintain backward compatibility with the experimental command.\n\n#### Scenario: Running openspec experimental\n\n- **WHEN** user runs `openspec experimental`\n- **THEN** delegate to `openspec init`\n- **AND** the command SHALL be hidden from help output\n\n## REMOVED Requirements\n\n### Requirement: File Generation\n\n**Reason**: AGENTS.md and project.md are no longer generated. Skills contain all necessary instructions.\n\n**Migration**: Skills in `.<tool>/skills/` provide all OpenSpec workflow instructions. No manual file needed.\n\n### Requirement: AI Tool Configuration Details\n\n**Reason**: Config files (CLAUDE.md, .cursorrules, etc.) are replaced by skills.\n\n**Migration**: Use skills in `.<tool>/skills/` instead of config files. Skills provide richer, tool-specific instructions.\n\n### Requirement: Slash Command Configuration\n\n**Reason**: Old `/openspec:*` slash commands are replaced by `/opsx:*` commands with richer functionality.\n\n**Migration**: Use `/opsx:new`, `/opsx:continue`, `/opsx:apply` instead of `/openspec:proposal`, `/openspec:apply`, `/openspec:archive`.\n\n### Requirement: Root instruction stub\n\n**Reason**: Root AGENTS.md stub is no longer needed. Skills provide tool-specific instructions.\n\n**Migration**: Skills are loaded automatically by supporting tools. No root stub needed.\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-merge-init-experimental/specs/legacy-cleanup/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Legacy artifact detection\n\nThe system SHALL detect legacy OpenSpec artifacts from previous init versions.\n\n#### Scenario: Detecting legacy config files\n\n- **WHEN** running `openspec init` on an existing project\n- **THEN** the system SHALL check for config files with OpenSpec markers:\n  - `CLAUDE.md`\n  - `.cursorrules`\n  - `.windsurfrules`\n  - `.clinerules`\n  - `.kilocode_rules`\n  - `.github/copilot-instructions.md`\n  - `.amazonq/instructions.md`\n  - `CODEBUDDY.md`\n  - `IFLOW.md`\n  - And all other tool config files from the legacy ToolRegistry\n\n#### Scenario: Detecting legacy slash command directories\n\n- **WHEN** running `openspec init` on an existing project\n- **THEN** the system SHALL check for old slash command directories:\n  - `.claude/commands/openspec/`\n  - `.cursor/commands/openspec/` (note: old format used `openspec-*.md` in commands root)\n  - `.windsurf/workflows/openspec-*.md`\n  - And equivalent directories for all tools in the legacy SlashCommandRegistry\n\n#### Scenario: Detecting legacy OpenSpec structure files\n\n- **WHEN** running `openspec init` on an existing project\n- **THEN** the system SHALL check for:\n  - `openspec/AGENTS.md`\n  - `openspec/project.md` (for migration messaging only, not deleted)\n  - Root `AGENTS.md` with OpenSpec markers\n\n### Requirement: Legacy cleanup confirmation\n\nThe system SHALL prompt for confirmation before removing legacy artifacts.\n\n#### Scenario: Prompting for cleanup when legacy detected\n\n- **WHEN** legacy artifacts are detected\n- **THEN** the system SHALL display what was found\n- **AND** prompt: \"Legacy files detected. Upgrade and clean up? [Y/n]\"\n- **AND** default to Yes if user presses Enter\n\n#### Scenario: User confirms cleanup\n\n- **WHEN** user responds Y or presses Enter\n- **THEN** the system SHALL remove legacy artifacts\n- **AND** proceed with skill-based setup\n\n#### Scenario: User declines cleanup\n\n- **WHEN** user responds N\n- **THEN** the system SHALL abort initialization\n- **AND** display message suggesting manual cleanup or using `--force` flag\n\n#### Scenario: Non-interactive mode\n\n- **WHEN** running with `--no-interactive` or in CI environment\n- **AND** legacy artifacts are detected\n- **THEN** the system SHALL abort with exit code 1\n- **AND** display detected legacy artifacts\n- **AND** suggest running interactively or using `--force` flag\n\n### Requirement: Surgical removal of config file content\n\nThe system SHALL preserve user content when removing OpenSpec markers from config files.\n\n#### Scenario: Config file with only OpenSpec content\n\n- **WHEN** a config file contains only OpenSpec marker block (whitespace outside is acceptable)\n- **THEN** the system SHALL remove the OpenSpec marker block\n- **AND** preserve the file (even if empty or whitespace-only)\n- **AND** NOT delete the file (config files belong to the user's project root)\n\n#### Scenario: Config file with mixed content\n\n- **WHEN** a config file contains content outside OpenSpec markers\n- **THEN** the system SHALL remove only the `<!-- OPENSPEC:START -->` to `<!-- OPENSPEC:END -->` block\n- **AND** preserve all content before and after the markers\n- **AND** clean up any resulting double blank lines\n\n#### Scenario: Root AGENTS.md with mixed content\n\n- **WHEN** root `AGENTS.md` contains OpenSpec markers AND other content\n- **THEN** the system SHALL remove only the OpenSpec marker block\n- **AND** preserve the rest of the file\n\n### Requirement: Legacy directory removal\n\nThe system SHALL remove legacy slash command directories entirely.\n\n#### Scenario: Removing old slash command directory\n\n- **WHEN** a legacy slash command directory exists (e.g., `.claude/commands/openspec/`)\n- **THEN** the system SHALL delete the entire directory and its contents\n- **AND** NOT delete the parent directory (e.g., `.claude/commands/` remains)\n\n#### Scenario: Removing legacy AGENTS.md\n\n- **WHEN** `openspec/AGENTS.md` exists\n- **THEN** the system SHALL delete the file\n- **AND** NOT delete the `openspec/` directory itself\n\n### Requirement: project.md migration hint\n\nThe system SHALL preserve project.md and display a migration hint instead of deleting it.\n\n#### Scenario: project.md exists during upgrade\n\n- **WHEN** `openspec/project.md` exists during legacy cleanup\n- **THEN** the system SHALL NOT delete the file\n- **AND** the system SHALL display a migration hint in the output:\n  ```\n  Manual migration needed:\n    → openspec/project.md still exists\n      Move useful content to config.yaml's \"context:\" field, then delete\n  ```\n\n#### Scenario: project.md migration rationale\n\n- **GIVEN** project.md may contain user-written project documentation\n- **AND** config.yaml's context field serves the same purpose (auto-injected into artifacts)\n- **WHEN** displaying the migration hint\n- **THEN** users can migrate manually or use `/opsx:explore` to get AI assistance\n\n### Requirement: Cleanup reporting\n\nThe system SHALL report what was cleaned up.\n\n#### Scenario: Displaying cleanup summary\n\n- **WHEN** legacy cleanup completes\n- **THEN** the system SHALL display a summary section:\n  ```\n  Cleaned up legacy files:\n    ✓ Removed OpenSpec markers from CLAUDE.md\n    ✓ Removed .claude/commands/openspec/ (replaced by /opsx:*)\n    ✓ Removed openspec/AGENTS.md (no longer needed)\n  ```\n- **AND IF** `openspec/project.md` exists\n- **THEN** the system SHALL display a separate migration section:\n  ```\n  Manual migration needed:\n    → openspec/project.md still exists\n      Move useful content to config.yaml's \"context:\" field, then delete\n  ```\n\n#### Scenario: No legacy detected\n\n- **WHEN** no legacy artifacts are found\n- **THEN** the system SHALL NOT display the cleanup section\n- **AND** proceed directly with skill setup\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-merge-init-experimental/tasks.md",
    "content": "## 1. Legacy Detection & Cleanup Module\n\n- [x] 1.1 Create `src/core/legacy-cleanup.ts` with detection functions for all legacy artifact types\n- [x] 1.2 Implement `detectLegacyConfigFiles()` - check for config files with OpenSpec markers\n- [x] 1.3 Implement `detectLegacySlashCommands()` - check for old `/openspec:*` command directories\n- [x] 1.4 Implement `detectLegacyStructureFiles()` - check for AGENTS.md (project.md detected separately for messaging)\n- [x] 1.5 Implement `removeMarkerBlock()` - surgically remove OpenSpec marker blocks from files\n- [x] 1.6 Implement `cleanupLegacyArtifacts()` - orchestrate removal with proper edge case handling (preserves project.md)\n- [x] 1.7 Implement migration hint output for project.md - show message directing users to migrate to config.yaml\n- [x] 1.8 Add unit tests for legacy detection and cleanup functions\n\n## 2. Rewrite Init Command\n\n- [x] 2.1 Replace `src/core/init.ts` with new implementation using experimental's approach\n- [x] 2.2 Import and use animated welcome screen from `src/ui/welcome-screen.ts`\n- [x] 2.3 Import and use searchable multi-select from `src/prompts/searchable-multi-select.ts`\n- [x] 2.4 Integrate legacy detection at start of init flow\n- [x] 2.5 Add Y/N prompt for legacy cleanup confirmation\n- [x] 2.6 Generate skills using existing `skill-templates.ts`\n- [x] 2.7 Generate slash commands using existing `command-generation/` adapters\n- [x] 2.8 Create `openspec/config.yaml` with default schema\n- [x] 2.9 Update success output to match new workflow (skills, /opsx:* commands)\n- [x] 2.10 Add `--force` flag to skip legacy cleanup prompt in non-interactive mode\n\n## 3. Remove Legacy Code\n\n- [x] 3.1 Delete `src/core/configurators/` directory (ToolRegistry, all config generators)\n- [x] 3.2 Delete `src/core/templates/slash-command-templates.ts`\n- [x] 3.3 Delete `src/core/templates/claude-template.ts`\n- [x] 3.4 Delete `src/core/templates/cline-template.ts`\n- [x] 3.5 Delete `src/core/templates/costrict-template.ts`\n- [x] 3.6 Delete `src/core/templates/agents-template.ts`\n- [x] 3.7 Delete `src/core/templates/agents-root-stub.ts`\n- [x] 3.8 Delete `src/core/templates/project-template.ts`\n- [x] 3.9 Delete `src/commands/experimental/` directory\n- [x] 3.10 Update `src/core/templates/index.ts` to remove deleted exports\n- [x] 3.11 Delete related test files for removed modules (wizard.ts)\n\n## 4. Update CLI Registration\n\n- [x] 4.1 Update `src/cli/index.ts` to remove `registerArtifactWorkflowCommands()` call\n- [x] 4.2 Keep experimental subcommands (status, instructions, schemas, etc.) but register directly\n- [x] 4.3 Remove \"[Experimental]\" labels from kept subcommands\n- [x] 4.4 Add hidden `experimental` command as alias to `init`\n\n## 5. Update Related Commands\n\n- [x] 5.1 Update `openspec update` command to refresh skills/commands instead of config files\n- [x] 5.2 Remove config file refresh logic from update\n- [x] 5.3 Add skill refresh logic to update\n\n## 6. Testing & Verification\n\n- [x] 6.1 Add integration tests for new init flow (fresh install)\n- [x] 6.2 Add integration tests for legacy detection and cleanup\n- [x] 6.3 Add integration tests for extend mode (re-running init)\n- [x] 6.4 Test non-interactive mode with `--tools` flag\n- [x] 6.5 Test `--force` flag for CI environments\n- [x] 6.6 Verify cross-platform path handling (use path.join throughout)\n- [x] 6.7 Run full test suite and fix any broken tests\n\n## 7. Documentation & Cleanup\n\n- [x] 7.1 Update README with new init behavior (skill-based workflow is self-documenting)\n- [x] 7.2 Document breaking changes for release notes (in tasks file)\n- [x] 7.3 Remove any orphaned imports/references to deleted modules (verified none exist)\n- [x] 7.4 Run linter and fix any issues (passed)\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-multi-provider-skill-generation/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-01-22\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-multi-provider-skill-generation/design.md",
    "content": "## Context\n\nThe `artifact-experimental-setup` command generates skill files and opsx slash commands for AI coding assistants. Currently it hardcodes paths to `.claude/skills` and `.claude/commands/opsx`.\n\nThe existing `AI_TOOLS` array in `config.ts` lists 22 AI tools but lacks path information. There's also an existing `SlashCommandConfigurator` system for the old workflow commands, but it's tightly coupled to the old 3 commands (proposal, apply, archive) and can't be easily extended for the 9 opsx commands.\n\nEach AI tool has:\n- Different skill directory conventions (`.claude/skills/`, `.cursor/skills/`, etc.)\n- Different command file paths (`.claude/commands/opsx/`, `.cursor/commands/`, etc.)\n- Different frontmatter formats (YAML keys, structure varies by tool)\n\n## Goals / Non-Goals\n\n**Goals:**\n- Support skill generation for any AI tool following the Agent Skills spec\n- Support command generation with tool-specific formatting via adapters\n- Require explicit tool selection (no defaults)\n- Create a generic, extensible command generation system\n\n**Non-Goals:**\n- Global path installation for tools other than Codex (Codex uses absolute adapter paths today)\n- Multi-tool generation in single command (future enhancement)\n- Unifying with existing SlashCommandConfigurator (separate systems for now)\n\n## Decisions\n\n### 1. Add `skillsDir` to `AIToolOption` interface\n\n**Decision**: Add single `skillsDir` field to existing interface. No `commandsDir` or `globalSkillsDir`.\n\n```typescript\ninterface AIToolOption {\n  name: string;\n  value: string;\n  available: boolean;\n  successLabel?: string;\n  skillsDir?: string;  // e.g., '.claude' - /skills suffix per Agent Skills spec\n}\n```\n\n**Rationale**:\n- Skills follow Agent Skills spec: `<toolDir>/skills/` - suffix is standard\n- Commands need per-tool formatting, handled by adapters (not a simple path)\n- Global paths supported — Codex adapter returns absolute paths via os.homedir()\n\n### 2. Strategy/Adapter pattern for command generation\n\n**Decision**: Create generic command generation with tool-specific adapters.\n\n```text\n┌─────────────────────────────────────────────────────────────────┐\n│                      CommandContent                              │\n│  (tool-agnostic: id, name, description, category, tags, body)   │\n└─────────────────────────────────────────────────────────────────┘\n                              │\n                              ▼\n┌─────────────────────────────────────────────────────────────────┐\n│                   generateCommand(content, adapter)              │\n└─────────────────────────────────────────────────────────────────┘\n                              │\n              ┌───────────────┼───────────────┐\n              ▼               ▼               ▼\n        ┌──────────┐   ┌──────────┐   ┌──────────┐\n        │  Claude  │   │  Cursor  │   │ Windsurf │\n        │ Adapter  │   │ Adapter  │   │ Adapter  │\n        └──────────┘   └──────────┘   └──────────┘\n```\n\n**Interfaces:**\n\n```typescript\n// Tool-agnostic command data\ninterface CommandContent {\n  id: string;           // e.g., 'explore', 'new', 'apply'\n  name: string;         // e.g., 'OpenSpec Explore'\n  description: string;  // e.g., 'Enter explore mode...'\n  category: string;     // e.g., 'OpenSpec'\n  tags: string[];       // e.g., ['openspec', 'explore']\n  body: string;         // The command instructions\n}\n\n// Per-tool formatting strategy\ninterface ToolCommandAdapter {\n  toolId: string;\n  getFilePath(commandId: string): string;\n  formatFile(content: CommandContent): string;\n}\n```\n\n**Rationale**:\n- Separates \"what to generate\" from \"how to format it\"\n- Each tool's frontmatter quirks encapsulated in its adapter\n- Easy to add new tools by implementing adapter interface\n- Body content shared across all tools\n\n**Alternative considered**: Extend existing SlashCommandConfigurator\n- Rejected: Tightly coupled to old 3 commands, significant refactor needed\n\n### 3. Adapter registry pattern\n\n**Decision**: Create `CommandAdapterRegistry` similar to existing `SlashCommandRegistry`.\n\n```typescript\nclass CommandAdapterRegistry {\n  private static adapters: Map<string, ToolCommandAdapter> = new Map();\n\n  static get(toolId: string): ToolCommandAdapter | undefined;\n  static getAll(): ToolCommandAdapter[];\n}\n```\n\n**Rationale**:\n- Consistent with existing codebase patterns\n- Easy lookup by tool ID\n- Centralized registration\n\n### 4. Required tool flag\n\n**Decision**: Require `--tool` flag - error if omitted.\n\n**Rationale**:\n- Explicit tool selection avoids assumptions\n- Consistent with project convention of not providing defaults\n- Users must consciously choose their target tool\n\n## Risks / Trade-offs\n\n**[Risk] Adapter maintenance burden** → Each new tool needs an adapter. Mitigated by simple interface - most adapters are ~20 lines.\n\n**[Risk] Frontmatter format drift** → Tools may change their formats. Mitigated by encapsulating format in adapter - single place to update.\n\n**[Trade-off] Two command systems** → Old SlashCommandConfigurator and new CommandAdapterRegistry coexist. Acceptable for now - can unify later if needed.\n\n**[Trade-off] skillsDir optional** → Tools without skillsDir configured will error. Acceptable - we add paths as tools are tested.\n\n## Implementation Approach\n\n1. Add `skillsDir` to `AIToolOption` and populate for known tools\n2. Create `CommandContent` and `ToolCommandAdapter` interfaces\n3. Implement adapters for Claude, Cursor, Windsurf (start with 3)\n4. Create `CommandAdapterRegistry`\n5. Create `generateCommand()` function\n6. Update `artifact-experimental-setup` to use new system\n7. Add `--tool` flag with validation\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-multi-provider-skill-generation/proposal.md",
    "content": "## Why\n\nThe `artifact-experimental-setup` command currently hardcodes skill output paths to `.claude/skills` and `.claude/commands/opsx`. This prevents users of other AI coding tools (Cursor, Windsurf, Codex, etc.) from using OpenSpec's skill generation. We need to support the diverse ecosystem of AI coding assistants, each with their own conventions for skill/instruction file locations and command frontmatter formats.\n\n## What Changes\n\n- Add `skillsDir` path configuration to the existing `AIToolOption` interface in `config.ts`\n- Add required `--tool <tool-id>` flag to the `artifact-experimental-setup` command\n- Create a generic command generation system using Strategy/Adapter pattern:\n  - `CommandContent`: tool-agnostic command data (id, name, description, body)\n  - `ToolCommandAdapter`: per-tool formatting (file paths, frontmatter format)\n  - `CommandGenerator`: orchestrates generation using content + adapter\n- Require explicit tool selection (no default) for clarity\n\n## Capabilities\n\n### New Capabilities\n\n- `ai-tool-paths`: Configuration mapping AI tool IDs to their project-local skill directory paths\n- `command-generation`: Generic command generation system with tool adapters for formatting differences\n\n### Modified Capabilities\n\n- `cli-artifact-workflow`: Adding `--tool` flag to setup command for provider selection\n\n## Impact\n\n- **Files Modified**:\n  - `src/core/config.ts` - Extend `AIToolOption` interface with `skillsDir` field\n  - `src/commands/artifact-workflow.ts` - Add `--tool` flag, use provider paths and adapters\n- **New Files**:\n  - `src/core/command-generation/types.ts` - CommandContent, ToolCommandAdapter interfaces\n  - `src/core/command-generation/generator.ts` - Generic command generator\n  - `src/core/command-generation/adapters/*.ts` - Per-tool adapters\n- **Backward Compatibility**: Existing workflows unaffected - this is a new command setup feature\n- **User-Facing**: Required `--tool` flag on `artifact-experimental-setup` command for explicit tool selection\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-multi-provider-skill-generation/specs/ai-tool-paths/spec.md",
    "content": "# ai-tool-paths Specification\n\n## Purpose\n\nDefine the path configuration for AI coding tool skill directories, enabling skill generation to target different tools following the Agent Skills spec.\n\n## Requirements\n\n## ADDED Requirements\n\n### Requirement: AIToolOption skillsDir field\n\nThe `AIToolOption` interface SHALL include an optional `skillsDir` field for skill generation path configuration.\n\n#### Scenario: Interface includes skillsDir field\n\n- **WHEN** a tool entry is defined in `AI_TOOLS` that supports skill generation\n- **THEN** it SHALL include a `skillsDir` field specifying the project-local base directory (e.g., `.claude`)\n\n#### Scenario: Skills path follows Agent Skills spec\n\n- **WHEN** generating skills for a tool with `skillsDir: '.claude'`\n- **THEN** skills SHALL be written to `<projectRoot>/<skillsDir>/skills/`\n- **AND** the `/skills` suffix is appended per Agent Skills specification\n\n### Requirement: Path configuration for supported tools\n\nThe `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent Skills specification.\n\n#### Scenario: Claude Code paths defined\n\n- **WHEN** looking up the `claude` tool\n- **THEN** `skillsDir` SHALL be `.claude`\n\n#### Scenario: Cursor paths defined\n\n- **WHEN** looking up the `cursor` tool\n- **THEN** `skillsDir` SHALL be `.cursor`\n\n#### Scenario: Windsurf paths defined\n\n- **WHEN** looking up the `windsurf` tool\n- **THEN** `skillsDir` SHALL be `.windsurf`\n\n#### Scenario: Tools without skillsDir\n\n- **WHEN** a tool has no `skillsDir` defined\n- **THEN** skill generation SHALL error with message indicating the tool is not supported\n\n### Requirement: Cross-platform path handling\n\nThe system SHALL handle paths correctly across operating systems.\n\n#### Scenario: Path construction on Windows\n\n- **WHEN** constructing skill paths on Windows\n- **THEN** the system SHALL use `path.join()` for all path construction\n- **AND** SHALL NOT hardcode forward slashes\n\n#### Scenario: Path construction on Unix\n\n- **WHEN** constructing skill paths on macOS or Linux\n- **THEN** the system SHALL use `path.join()` for consistency\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-multi-provider-skill-generation/specs/cli-artifact-workflow/spec.md",
    "content": "# cli-artifact-workflow Delta Specification\n\n## Purpose\n\nAdd `--tool` flag to the `artifact-experimental-setup` command for multi-provider support.\n\n## ADDED Requirements\n\n### Requirement: Tool selection flag\n\nThe `artifact-experimental-setup` command SHALL accept a `--tool <tool-id>` flag to specify the target AI tool.\n\n#### Scenario: Specify tool via flag\n\n- **WHEN** user runs `openspec artifact-experimental-setup --tool cursor`\n- **THEN** skill files are generated in `.cursor/skills/`\n- **AND** command files are generated using Cursor's frontmatter format\n\n#### Scenario: Missing tool flag\n\n- **WHEN** user runs `openspec artifact-experimental-setup` without `--tool`\n- **THEN** the system displays an error requiring the `--tool` flag\n- **AND** lists valid tool IDs in the error message\n\n#### Scenario: Unknown tool ID\n\n- **WHEN** user runs `openspec artifact-experimental-setup --tool unknown-tool`\n- **AND** the tool ID is not in `AI_TOOLS`\n- **THEN** the system displays an error listing valid tool IDs\n\n#### Scenario: Tool without skillsDir\n\n- **WHEN** user specifies a tool that has no `skillsDir` configured\n- **THEN** the system displays an error indicating skill generation is not supported for that tool\n\n#### Scenario: Tool without command adapter\n\n- **WHEN** user specifies a tool that has `skillsDir` but no command adapter registered\n- **THEN** skill files are generated successfully\n- **AND** command generation is skipped with informational message\n\n### Requirement: Output messaging\n\nThe setup command SHALL display clear output about what was generated.\n\n#### Scenario: Show target tool in output\n\n- **WHEN** setup command runs successfully\n- **THEN** output includes the target tool name (e.g., \"Setting up for Cursor...\")\n\n#### Scenario: Show generated paths\n\n- **WHEN** setup command completes\n- **THEN** output lists all generated skill file paths\n- **AND** lists all generated command file paths (if applicable)\n\n#### Scenario: Show skipped commands message\n\n- **WHEN** command generation is skipped due to missing adapter\n- **THEN** output includes message: \"Command generation skipped - no adapter for <tool>\"\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-multi-provider-skill-generation/specs/command-generation/spec.md",
    "content": "# command-generation Specification\n\n## Purpose\n\nDefine a generic command generation system that supports multiple AI tools through a Strategy/Adapter pattern, separating command content from tool-specific formatting.\n\n## ADDED Requirements\n\n### Requirement: CommandContent interface\n\nThe system SHALL define a tool-agnostic `CommandContent` interface for command data.\n\n#### Scenario: CommandContent structure\n\n- **WHEN** defining a command to generate\n- **THEN** `CommandContent` SHALL include:\n  - `id`: string identifier (e.g., 'explore', 'apply')\n  - `name`: human-readable name (e.g., 'OpenSpec Explore')\n  - `description`: brief description of command purpose\n  - `category`: grouping category (e.g., 'OpenSpec')\n  - `tags`: array of tag strings\n  - `body`: the command instruction content\n\n### Requirement: ToolCommandAdapter interface\n\nThe system SHALL define a `ToolCommandAdapter` interface for per-tool formatting.\n\n#### Scenario: Adapter interface structure\n\n- **WHEN** implementing a tool adapter\n- **THEN** `ToolCommandAdapter` SHALL require:\n  - `toolId`: string identifier matching `AIToolOption.value`\n  - `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex)\n  - `formatFile(content: CommandContent)`: returns complete file content with frontmatter\n\n#### Scenario: Claude adapter formatting\n\n- **WHEN** formatting a command for Claude Code\n- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields\n- **AND** file path SHALL follow pattern `.claude/commands/opsx/<id>.md`\n\n#### Scenario: Cursor adapter formatting\n\n- **WHEN** formatting a command for Cursor\n- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-<id>`, `id`, `category`, `description` fields\n- **AND** file path SHALL follow pattern `.cursor/commands/opsx-<id>.md`\n\n#### Scenario: Windsurf adapter formatting\n\n- **WHEN** formatting a command for Windsurf\n- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields\n- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-<id>.md`\n\n### Requirement: Command generator function\n\nThe system SHALL provide a `generateCommand` function that combines content with adapter.\n\n#### Scenario: Generate command file\n\n- **WHEN** calling `generateCommand(content, adapter)`\n- **THEN** it SHALL return an object with:\n  - `path`: the file path from `adapter.getFilePath(content.id)`\n  - `fileContent`: the formatted content from `adapter.formatFile(content)`\n\n#### Scenario: Generate multiple commands\n\n- **WHEN** generating all opsx commands for a tool\n- **THEN** the system SHALL iterate over command contents and generate each using the tool's adapter\n\n### Requirement: CommandAdapterRegistry\n\nThe system SHALL provide a registry for looking up tool adapters.\n\n#### Scenario: Get adapter by tool ID\n\n- **WHEN** calling `CommandAdapterRegistry.get('cursor')`\n- **THEN** it SHALL return the Cursor adapter or undefined if not registered\n\n#### Scenario: Get all adapters\n\n- **WHEN** calling `CommandAdapterRegistry.getAll()`\n- **THEN** it SHALL return array of all registered adapters\n\n#### Scenario: Adapter not found\n\n- **WHEN** looking up an adapter for unregistered tool\n- **THEN** `CommandAdapterRegistry.get()` SHALL return undefined\n- **AND** caller SHALL handle missing adapter appropriately\n\n### Requirement: Shared command body content\n\nThe body content of commands SHALL be shared across all tools.\n\n#### Scenario: Same instructions across tools\n\n- **WHEN** generating the 'explore' command for Claude and Cursor\n- **THEN** both SHALL use the same `body` content\n- **AND** only the frontmatter and file path SHALL differ\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-multi-provider-skill-generation/tasks.md",
    "content": "## 1. Extend AIToolOption Interface\n\n- [x] 1.1 Add `skillsDir?: string` field to `AIToolOption` interface in `src/core/config.ts`\n\n## 2. Add skillsDir to AI_TOOLS\n\n- [x] 2.1 Add `skillsDir: '.claude'` to Claude Code tool entry\n- [x] 2.2 Add `skillsDir: '.cursor'` to Cursor tool entry\n- [x] 2.3 Add `skillsDir: '.windsurf'` to Windsurf tool entry\n- [x] 2.4 Add skillsDir for other tools with known Agent Skills spec support (codex, opencode, roocode, kilocode, gemini, factory, github-copilot)\n\n## 3. Create Command Generation Types\n\n- [x] 3.1 Create `src/core/command-generation/types.ts` with `CommandContent` interface\n- [x] 3.2 Add `ToolCommandAdapter` interface to types.ts\n- [x] 3.3 Export types from module index\n\n## 4. Implement Tool Command Adapters\n\n- [x] 4.1 Create `src/core/command-generation/adapters/claude.ts` with Claude frontmatter format\n- [x] 4.2 Create `src/core/command-generation/adapters/cursor.ts` with Cursor frontmatter format\n- [x] 4.3 Create `src/core/command-generation/adapters/windsurf.ts` with Windsurf frontmatter format\n- [x] 4.4 Create base adapter or utility for shared YAML formatting logic (if applicable)\n\n## 5. Create Command Adapter Registry\n\n- [x] 5.1 Create `src/core/command-generation/registry.ts` with `CommandAdapterRegistry` class\n- [x] 5.2 Register Claude, Cursor, Windsurf adapters in static initializer\n- [x] 5.3 Add `get(toolId)` and `getAll()` methods\n\n## 6. Create Command Generator\n\n- [x] 6.1 Create `src/core/command-generation/generator.ts` with `generateCommand()` function\n- [x] 6.2 Add `generateCommands()` function for batch generation\n- [x] 6.3 Create module index `src/core/command-generation/index.ts` exporting public API\n\n## 7. Update artifact-experimental-setup Command\n\n- [x] 7.1 Add `--tool <tool-id>` option (required) to command in `src/commands/artifact-workflow.ts`\n- [x] 7.2 Add validation: `--tool` flag is required (error if missing with list of valid tools)\n- [x] 7.3 Add validation: tool exists in AI_TOOLS\n- [x] 7.4 Add validation: tool has skillsDir configured\n- [x] 7.5 Replace hardcoded `.claude` skill paths with `tool.skillsDir`\n- [x] 7.6 Replace hardcoded command generation with `CommandAdapterRegistry.get()` + `generateCommands()`\n- [x] 7.7 Handle missing adapter gracefully (skip commands with message)\n- [x] 7.8 Update output messages to show target tool name and paths\n\n## 8. Testing\n\n- [x] 8.1 Add unit tests for `CommandContent` and `ToolCommandAdapter` contracts\n- [x] 8.2 Add unit tests for Claude adapter (path + frontmatter format)\n- [x] 8.3 Add unit tests for Cursor adapter (path + frontmatter format)\n- [x] 8.4 Add unit tests for `CommandAdapterRegistry.get()` and missing adapter case\n- [x] 8.5 Add integration test for `--tool` flag validation\n- [x] 8.6 Verify cross-platform path handling uses `path.join()` throughout\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-config/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: \"2025-01-13\"\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-config/design.md",
    "content": "# Design: Project Config\n\n## Context\n\nOpenSpec currently has a fixed schema resolution order:\n1. `--schema` CLI flag\n2. `.openspec.yaml` in change directory\n3. Hardcoded default: `\"spec-driven\"`\n\nThis forces users who want project-level customization to fork entire schemas, even for simple additions like injecting tech stack context or adding artifact-specific rules.\n\nThe proposal introduces `openspec/config.yaml` as a lightweight customization layer that sits between preset schemas and full forking. It allows teams to:\n- Set a default schema\n- Inject project context into all artifacts\n- Add per-artifact rules\n\n**Constraints:**\n- Must not break existing changes that lack config\n- Must maintain clean separation between \"configure\" (this) and \"fork\" (project-local-schemas)\n- Config is project-level only (no global/user-level config)\n\n**Key stakeholders:**\n- OpenSpec users who need light customization without forking\n- Teams sharing workflow conventions via committed config\n\n## Goals / Non-Goals\n\n**Goals:**\n- Load and parse `openspec/config.yaml` using Zod schema\n- Use config's `schema` field as default in schema resolution\n- Inject `context` into all artifact instructions\n- Inject `rules` into matching artifact instructions only\n- Gracefully handle missing or invalid config (fallback to defaults)\n\n**Non-Goals:**\n- Structural changes to schemas (`skip`, `add`, inheritance) - those belong in fork path\n- File references for context (`context: ./file.md`) - start with strings\n- Global user-level config (XDG dirs, etc.)\n- Config management commands (`openspec config init`) - manual creation for now\n- Migration from old setups (no existing config to migrate from)\n\n## Decisions\n\n### 1. Config File Format: YAML vs JSON\n\n**Decision:** Use YAML (`.yaml` extension, support `.yml` alias)\n\n**Rationale:**\n- YAML supports multi-line strings naturally (`context: |`)\n- More readable for documentation-heavy content\n- Consistent with `.openspec.yaml` used in changes\n- Easy to parse with existing `yaml` library\n\n**Alternatives considered:**\n- JSON: More strict, but poor multi-line string UX\n- TOML: Less familiar to most users\n\n### 2. Config Location: Project Root vs openspec/ Directory\n\n**Decision:** `./openspec/config.yaml` (inside openspec directory)\n\n**Rationale:**\n- Co-located with `openspec/schemas/` (project-local-schemas)\n- Keeps project root clean\n- Natural namespace for OpenSpec configuration\n- Mirrors structure used by other tools (e.g., `.github/`)\n\n**Alternatives considered:**\n- `./openspec.config.yaml` in root: Pollutes root, less clear ownership\n- XDG config directories: Out of scope, no global config yet\n\n### 3. Context Injection: XML Tags vs Markdown Sections\n\n**Decision:** Use XML-style tags `<context>` and `<rules>`\n\n**Rationale:**\n- Clear delimiters that don't conflict with Markdown\n- Agents can easily parse structure\n- Matches existing patterns in the codebase for special sections\n\n**Example:**\n```xml\n<context>\nTech stack: TypeScript, React\n</context>\n\n<rules>\n- Include rollback plan\n</rules>\n\n<template>\n## Summary\n...\n</template>\n```\n\n**Alternatives considered:**\n- Markdown headers: Conflicts with template content\n- Comments: Less visible to agents\n\n### 4. Schema Resolution: Insert Position\n\n**Decision:** Config's `schema` field goes between change metadata and hardcoded default\n\n**New resolution order:**\n1. `--schema` CLI flag (explicit override)\n2. `.openspec.yaml` in change directory (change-specific binding)\n3. **`openspec/config.yaml` schema field** (NEW - project default)\n4. `\"spec-driven\"` (hardcoded fallback)\n\n**Rationale:**\n- Preserves CLI and change-level overrides (most specific wins)\n- Makes config act as a \"project default\"\n- Backwards compatible (no existing configs to conflict with)\n\n### 5. Rules Validation: Strict vs Permissive\n\n**Decision:** Warn on unknown artifact IDs, don't error\n\n**Rationale:**\n- Future-proof: If schema adds new artifacts, old configs don't break\n- Dev experience: Typos show warnings, but don't halt workflow\n- User can fix incrementally\n\n**Example:**\n```yaml\nrules:\n  proposal: [...]\n  testplan: [...]  # Schema doesn't have this artifact → WARN, not ERROR\n```\n\n### 6. Error Handling: Config Parse Failures\n\n**Decision:** Log warning and fall back to defaults (don't halt commands)\n\n**Rationale:**\n- Syntax errors in config shouldn't break all of OpenSpec\n- User can fix config incrementally\n- Commands remain usable during config development\n\n**Warning message:**\n```\n⚠️  Failed to parse openspec/config.yaml: [error details]\n    Falling back to default schema (spec-driven)\n```\n\n## Implementation Plan\n\n### Phase 1: Core Types and Loading\n\n**File: `src/core/project-config.ts` (NEW)**\n\n```typescript\nimport { z } from 'zod';\nimport { readFileSync, existsSync } from 'fs';\nimport { parse as parseYaml } from 'yaml';\nimport { findProjectRoot } from '../utils/path-utils';\n\n/**\n * Zod schema for project configuration.\n *\n * Purpose:\n * 1. Documentation - clearly defines the config file structure\n * 2. Type safety - TypeScript infers ProjectConfig type from schema\n * 3. Runtime validation - uses safeParse() for resilient field-by-field validation\n *\n * Why Zod over manual validation:\n * - Helps understand OpenSpec's data interfaces at a glance\n * - Single source of truth for type and validation\n * - Consistent with other OpenSpec schemas\n */\nexport const ProjectConfigSchema = z.object({\n  schema: z.string().min(1).describe('The workflow schema to use (e.g., \"spec-driven\", \"tdd\")'),\n  context: z.string().optional().describe('Project context injected into all artifact instructions'),\n  rules: z.record(\n    z.string(),\n    z.array(z.string())\n  ).optional().describe('Per-artifact rules, keyed by artifact ID'),\n});\n\nexport type ProjectConfig = z.infer<typeof ProjectConfigSchema>;\n\nconst MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit\n\n/**\n * Read and parse openspec/config.yaml from project root.\n * Uses resilient parsing - validates each field independently using Zod safeParse.\n * Returns null if file doesn't exist.\n * Returns partial config if some fields are invalid (with warnings).\n */\nexport function readProjectConfig(): ProjectConfig | null {\n  const projectRoot = findProjectRoot();\n\n  // Try both .yaml and .yml, prefer .yaml\n  let configPath = path.join(projectRoot, 'openspec', 'config.yaml');\n  if (!existsSync(configPath)) {\n    configPath = path.join(projectRoot, 'openspec', 'config.yml');\n    if (!existsSync(configPath)) {\n      return null; // No config is OK\n    }\n  }\n\n  try {\n    const content = readFileSync(configPath, 'utf-8');\n    const raw = parseYaml(content);\n\n    if (!raw || typeof raw !== 'object') {\n      console.warn(`⚠️  openspec/config.yaml is not a valid YAML object`);\n      return null;\n    }\n\n    const config: Partial<ProjectConfig> = {};\n\n    // Parse schema field using Zod\n    const schemaField = z.string().min(1);\n    const schemaResult = schemaField.safeParse(raw.schema);\n    if (schemaResult.success) {\n      config.schema = schemaResult.data;\n    } else if (raw.schema !== undefined) {\n      console.warn(`⚠️  Invalid 'schema' field in config (must be non-empty string)`);\n    }\n\n    // Parse context field with size limit\n    if (raw.context !== undefined) {\n      const contextField = z.string();\n      const contextResult = contextField.safeParse(raw.context);\n\n      if (contextResult.success) {\n        const contextSize = Buffer.byteLength(contextResult.data, 'utf-8');\n        if (contextSize > MAX_CONTEXT_SIZE) {\n          console.warn(\n            `⚠️  Context too large (${(contextSize / 1024).toFixed(1)}KB, limit: ${MAX_CONTEXT_SIZE / 1024}KB)`\n          );\n          console.warn(`   Ignoring context field`);\n        } else {\n          config.context = contextResult.data;\n        }\n      } else {\n        console.warn(`⚠️  Invalid 'context' field in config (must be string)`);\n      }\n    }\n\n    // Parse rules field using Zod\n    if (raw.rules !== undefined) {\n      const rulesField = z.record(z.string(), z.array(z.string()));\n\n      // First check if it's an object structure\n      if (typeof raw.rules === 'object' && !Array.isArray(raw.rules)) {\n        const parsedRules: Record<string, string[]> = {};\n        let hasValidRules = false;\n\n        for (const [artifactId, rules] of Object.entries(raw.rules)) {\n          const rulesArrayResult = z.array(z.string()).safeParse(rules);\n\n          if (rulesArrayResult.success) {\n            // Filter out empty strings\n            const validRules = rulesArrayResult.data.filter(r => r.length > 0);\n            if (validRules.length > 0) {\n              parsedRules[artifactId] = validRules;\n              hasValidRules = true;\n            }\n            if (validRules.length < rulesArrayResult.data.length) {\n              console.warn(\n                `⚠️  Some rules for '${artifactId}' are empty strings, ignoring them`\n              );\n            }\n          } else {\n            console.warn(\n              `⚠️  Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`\n            );\n          }\n        }\n\n        if (hasValidRules) {\n          config.rules = parsedRules;\n        }\n      } else {\n        console.warn(`⚠️  Invalid 'rules' field in config (must be object)`);\n      }\n    }\n\n    // Return partial config even if some fields failed\n    return Object.keys(config).length > 0 ? (config as ProjectConfig) : null;\n\n  } catch (error) {\n    console.warn(`⚠️  Failed to parse openspec/config.yaml:`, error);\n    return null;\n  }\n}\n\n/**\n * Validate artifact IDs in rules against a schema's artifacts.\n * Called during instruction loading (when schema is known).\n * Returns warnings for unknown artifact IDs.\n */\nexport function validateConfigRules(\n  rules: Record<string, string[]>,\n  validArtifactIds: Set<string>,\n  schemaName: string\n): string[] {\n  const warnings: string[] = [];\n\n  for (const artifactId of Object.keys(rules)) {\n    if (!validArtifactIds.has(artifactId)) {\n      const validIds = Array.from(validArtifactIds).sort().join(', ');\n      warnings.push(\n        `Unknown artifact ID in rules: \"${artifactId}\". ` +\n        `Valid IDs for schema \"${schemaName}\": ${validIds}`\n      );\n    }\n  }\n\n  return warnings;\n}\n\n/**\n * Suggest valid schema names when user provides invalid schema.\n * Uses fuzzy matching to find similar names.\n */\nexport function suggestSchemas(\n  invalidSchemaName: string,\n  availableSchemas: { name: string; isBuiltIn: boolean }[]\n): string {\n  // Simple fuzzy match: Levenshtein distance\n  function levenshtein(a: string, b: string): number {\n    const matrix: number[][] = [];\n    for (let i = 0; i <= b.length; i++) {\n      matrix[i] = [i];\n    }\n    for (let j = 0; j <= a.length; j++) {\n      matrix[0][j] = j;\n    }\n    for (let i = 1; i <= b.length; i++) {\n      for (let j = 1; j <= a.length; j++) {\n        if (b.charAt(i - 1) === a.charAt(j - 1)) {\n          matrix[i][j] = matrix[i - 1][j - 1];\n        } else {\n          matrix[i][j] = Math.min(\n            matrix[i - 1][j - 1] + 1,\n            matrix[i][j - 1] + 1,\n            matrix[i - 1][j] + 1\n          );\n        }\n      }\n    }\n    return matrix[b.length][a.length];\n  }\n\n  // Find closest matches (distance <= 3)\n  const suggestions = availableSchemas\n    .map(s => ({ ...s, distance: levenshtein(invalidSchemaName, s.name) }))\n    .filter(s => s.distance <= 3)\n    .sort((a, b) => a.distance - b.distance)\n    .slice(0, 3);\n\n  const builtIn = availableSchemas.filter(s => s.isBuiltIn).map(s => s.name);\n  const projectLocal = availableSchemas.filter(s => !s.isBuiltIn).map(s => s.name);\n\n  let message = `❌ Schema '${invalidSchemaName}' not found in openspec/config.yaml\\n\\n`;\n\n  if (suggestions.length > 0) {\n    message += `Did you mean one of these?\\n`;\n    suggestions.forEach(s => {\n      const type = s.isBuiltIn ? 'built-in' : 'project-local';\n      message += `  - ${s.name} (${type})\\n`;\n    });\n    message += '\\n';\n  }\n\n  message += `Available schemas:\\n`;\n  if (builtIn.length > 0) {\n    message += `  Built-in: ${builtIn.join(', ')}\\n`;\n  }\n  if (projectLocal.length > 0) {\n    message += `  Project-local: ${projectLocal.join(', ')}\\n`;\n  } else {\n    message += `  Project-local: (none found)\\n`;\n  }\n\n  message += `\\nFix: Edit openspec/config.yaml and change 'schema: ${invalidSchemaName}' to a valid schema name`;\n\n  return message;\n}\n```\n\n### Phase 2: Schema Resolution\n\n**File: `src/utils/change-metadata.ts`**\n\nUpdate `resolveSchemaForChange()` to check config:\n\n```typescript\nexport function resolveSchemaForChange(\n  changeName: string,\n  cliSchema?: string\n): string {\n  // 1. CLI flag wins\n  if (cliSchema) {\n    return cliSchema;\n  }\n\n  // 2. Change metadata (.openspec.yaml)\n  const metadata = readChangeMetadata(changeName);\n  if (metadata?.schema) {\n    return metadata.schema;\n  }\n\n  // 3. Project config (NEW)\n  const projectConfig = readProjectConfig();\n  if (projectConfig?.schema) {\n    return projectConfig.schema;\n  }\n\n  // 4. Hardcoded default\n  return 'spec-driven';\n}\n```\n\n**File: `src/utils/change-utils.ts`**\n\nUpdate `createNewChange()` to use config schema:\n\n```typescript\nexport function createNewChange(\n  changeName: string,\n  schema?: string\n): void {\n  // Use schema from config if not specified\n  const resolvedSchema = schema ?? readProjectConfig()?.schema ?? 'spec-driven';\n\n  // ... rest of change creation logic\n}\n```\n\n### Phase 3: Instruction Injection and Validation\n\n**File: `src/core/artifact-graph/instruction-loader.ts`**\n\nUpdate `loadInstructions()` to inject context, rules, and validate artifact IDs:\n\n```typescript\n// Session-level cache for validation warnings (avoid repeating same warnings)\nconst shownWarnings = new Set<string>();\n\nexport function loadInstructions(\n  changeName: string,\n  artifactId: string\n): InstructionOutput {\n  const projectConfig = readProjectConfig();\n\n  // Load base instructions from schema\n  const baseInstructions = loadSchemaInstructions(changeName, artifactId);\n  const schema = getSchemaForChange(changeName); // Assumes we have schema loaded\n\n  // Validate rules artifact IDs (only once per session)\n  if (projectConfig?.rules) {\n    const validArtifactIds = new Set(schema.artifacts.map(a => a.id));\n    const warnings = validateConfigRules(\n      projectConfig.rules,\n      validArtifactIds,\n      schema.name\n    );\n\n    // Show each unique warning only once per session\n    for (const warning of warnings) {\n      if (!shownWarnings.has(warning)) {\n        console.warn(`⚠️  ${warning}`);\n        shownWarnings.add(warning);\n      }\n    }\n  }\n\n  // Build enriched instruction with XML sections\n  let enrichedInstruction = '';\n\n  // Add context (all artifacts)\n  if (projectConfig?.context) {\n    enrichedInstruction += `<context>\\n${projectConfig.context}\\n</context>\\n\\n`;\n  }\n\n  // Add rules (only for matching artifact)\n  const rulesForArtifact = projectConfig?.rules?.[artifactId];\n  if (rulesForArtifact && rulesForArtifact.length > 0) {\n    enrichedInstruction += `<rules>\\n`;\n    for (const rule of rulesForArtifact) {\n      enrichedInstruction += `- ${rule}\\n`;\n    }\n    enrichedInstruction += `</rules>\\n\\n`;\n  }\n\n  // Add original template\n  enrichedInstruction += `<template>\\n${baseInstructions.template}\\n</template>`;\n\n  return {\n    ...baseInstructions,\n    instruction: enrichedInstruction,\n  };\n}\n```\n\n**Note on validation timing:** Rules are validated lazily during instruction loading (not at config load time) because:\n1. Schema isn't known at config load time (circular dependency)\n2. Warnings shown when user actually uses the feature (better UX)\n3. Validation warnings cached per session to avoid spam\n\n### Phase 4: Performance and Caching\n\n**Why config is read multiple times:**\n\n```typescript\n// Example: \"openspec instructions proposal --change my-feature\"\n\n// 1. Schema resolution (to know which schema to use)\nresolveSchemaForChange('my-feature')\n  → readProjectConfig()  // Read #1\n\n// 2. Instruction loading (to inject context and rules)\nloadInstructions('my-feature', 'proposal')\n  → readProjectConfig()  // Read #2\n\n// Result: Config read twice per command\n// More complex commands may read 3-5 times\n```\n\n**Performance Strategy:**\n\nV1 approach: No caching, read config fresh each time\n- Simpler implementation\n- No cache invalidation complexity\n- Acceptable if config reads are fast enough\n\n**Benchmark targets:**\n- Typical config (1KB context, 5 artifact rules): **< 10ms** per read (imperceptible even 5x)\n- Large config (50KB context limit): **< 50ms** per read (acceptable for rare case)\n\n**If benchmarks fail:** Add simple caching:\n\n```typescript\n// Simple in-memory cache with no invalidation\nlet cachedConfig: { mtime: number; config: ProjectConfig | null } | null = null;\n\nexport function readProjectConfig(): ProjectConfig | null {\n  const projectRoot = findProjectRoot();\n  const configPath = path.join(projectRoot, 'openspec', 'config.yaml');\n\n  if (!existsSync(configPath)) {\n    return null;\n  }\n\n  const stats = statSync(configPath);\n  const mtime = stats.mtimeMs;\n\n  // Return cached config if file hasn't changed\n  if (cachedConfig && cachedConfig.mtime === mtime) {\n    return cachedConfig.config;\n  }\n\n  // Read and parse config\n  const config = parseConfigFile(configPath); // Extracted logic\n\n  // Cache result\n  cachedConfig = { mtime, config };\n  return config;\n}\n```\n\n**Performance testing task:** Add to Phase 6 (Testing)\n- Measure typical config read time (1KB context)\n- Measure large config read time (50KB context limit)\n- Measure repeated reads within single command\n- Document results, add caching only if needed\n\n## Data Flow\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│                                                              │\n│  User runs: openspec instructions proposal --change foo     │\n│                                                              │\n└────────────────────────────┬─────────────────────────────────┘\n                             │\n                             ▼\n┌──────────────────────────────────────────────────────────────┐\n│  resolveSchemaForChange(\"foo\")                               │\n│                                                              │\n│  1. Check CLI flag ✗                                         │\n│  2. Check .openspec.yaml ✗                                   │\n│  3. Check openspec/config.yaml ✓ → \"spec-driven\"             │\n│                                                              │\n└────────────────────────────┬─────────────────────────────────┘\n                             │\n                             ▼\n┌──────────────────────────────────────────────────────────────┐\n│  loadInstructions(\"foo\", \"proposal\")                         │\n│                                                              │\n│  1. Load spec-driven/artifacts/proposal.yaml                 │\n│  2. Read openspec/config.yaml                                │\n│  3. Build enriched instruction:                              │\n│     - <context>...</context>                                 │\n│     - <rules>...</rules>  (if rules.proposal exists)         │\n│     - <template>...</template>                               │\n│                                                              │\n└────────────────────────────┬─────────────────────────────────┘\n                             │\n                             ▼\n┌──────────────────────────────────────────────────────────────┐\n│  Return InstructionOutput with enriched content              │\n│                                                              │\n│  Agent sees project context + rules + schema template        │\n│                                                              │\n└──────────────────────────────────────────────────────────────┘\n```\n\n## Risks / Trade-offs\n\n**[Risk]** Config typos silently ignored (e.g., wrong artifact ID in rules)\n→ **Mitigation:** Validate and warn on unknown artifact IDs during config load. Don't error to allow forward compatibility.\n\n**[Risk]** Context grows too large, pollutes all artifact instructions\n→ **Mitigation:** Document recommended size (< 500 chars). If this becomes an issue, add per-artifact context override later.\n\n**[Risk]** YAML parsing errors break OpenSpec commands\n→ **Mitigation:** Catch parse errors, log warning, fall back to defaults. Commands remain functional.\n\n**[Risk]** Config cached incorrectly across commands\n→ **Mitigation:** Read config fresh on each `readProjectConfig()` call. No caching layer for v1 (simplicity over perf).\n\n**[Trade-off]** Context is injected into ALL artifacts\n→ **Benefit:** Consistent project knowledge across workflow\n→ **Cost:** Can't scope context to specific artifacts (yet)\n→ **Future:** Add `context: { global: \"...\", proposal: \"...\" }` if needed\n\n**[Trade-off]** Rules use artifact IDs, not human names\n→ **Benefit:** Stable identifiers (IDs don't change)\n→ **Cost:** User needs to know artifact IDs from schema\n→ **Mitigation:** Document common artifact IDs, show in `openspec status` output\n\n## Migration Plan\n\n**No migration needed** - this is a new feature with no existing state.\n\n**Rollout steps:**\n1. Deploy with config loading behind feature flag (optional, for safety)\n2. Test with internal project (this repo)\n3. Document in README with examples\n4. Remove feature flag if used\n\n**Rollback strategy:**\n- Config is additive only (doesn't break existing changes)\n- If bugs found, config parsing can be disabled with env var\n- Users can delete config file to restore old behavior\n\n## Open Questions\n\n**Q: Should context support file references (`context: ./CONTEXT.md`)?**\n**A (deferred):** Start with string-only. Add file reference later if users request it. Keeps v1 simple.\n\n**Q: Should we support `.yml` alias in addition to `.yaml`?**\n**A:** Yes, check both extensions. Prefer `.yaml` in docs, but accept `.yml` for users who prefer it.\n\n**Q: What if config's schema field references a non-existent schema?**\n**A:** Schema resolution will fail downstream. Show error when trying to load schema, suggest valid schema names.\n\n**Q: Should rules be validated against the resolved schema's artifact IDs?**\n**A:** Yes, validate and warn, but don't halt. This allows forward compatibility if schema evolves.\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-config/proposal.md",
    "content": "# Project Config\n\n## Summary\n\nAdd `openspec/config.yaml` support for project-level configuration. This enables teams to customize OpenSpec behavior without forking schemas, by providing context and rules that are injected into artifact generation.\n\n## Motivation\n\nCurrently, customizing OpenSpec requires forking entire schemas:\n- Must copy all files even to add one rule\n- Lose updates when openspec upgrades\n- High friction for simple customizations\n\nMost users don't need different workflow structure. They need to:\n- Provide project context (tech stack, conventions, constraints)\n- Add rules for specific artifacts (requirements, formatting preferences)\n\n## Design Decisions\n\n### Two-Path Model\n\nOpenSpec customization follows two distinct paths:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                                                                 │\n│   CONFIGURE (this change)         FORK (project-local-schemas)  │\n│   ─────────────────────           ────────────────────────────  │\n│                                                                 │\n│   Use a preset schema             Define your own schema        │\n│   + add context                   from scratch                  │\n│   + add rules                                                   │\n│                                                                 │\n│   openspec/config.yaml            openspec/schemas/my-flow/     │\n│                                                                 │\n│   ✓ Simple                        ✓ Full control                │\n│   ✓ Get updates                   ✗ You maintain everything     │\n│                                                                 │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Config Schema\n\n```yaml\n# openspec/config.yaml\n\n# Required: which workflow schema to use\nschema: spec-driven\n\n# Optional: project context injected into all artifact prompts\ncontext: |\n  Tech stack: TypeScript, React, Node.js, PostgreSQL\n  API style: RESTful, documented in docs/api-conventions.md\n  Testing: Jest + React Testing Library\n  We value backwards compatibility for all public APIs\n\n# Optional: per-artifact rules (additive)\nrules:\n  proposal:\n    - Include rollback plan\n    - Identify affected teams and notify in #platform-changes\n  specs:\n    - Use Given/When/Then format\n    - Reference existing patterns before inventing new ones\n  tasks:\n    - Each task should be completable in < 2 hours\n    - Include acceptance criteria\n```\n\n### What's NOT in Config\n\nThe following were explicitly excluded to keep the model simple:\n\n| Feature | Decision | Rationale |\n|---------|----------|-----------|\n| `skip: [artifact]` | Not supported | Structural changes belong in fork path |\n| `add: [{...}]` | Not supported | Structural changes belong in fork path |\n| `extends: base` | Not supported | No inheritance, fork is full copy |\n| `context: ./file.md` | Not supported (yet) | Start with string, add file reference later if needed |\n\n### Field Definitions\n\n#### `schema` (required)\n\nWhich workflow schema to use. Can be:\n- Built-in name: `spec-driven`, `tdd`\n- Project-local schema name: `my-workflow` (requires project-local-schemas change)\n\nThis becomes the default schema for:\n- New changes created without `--schema` flag\n- Commands run on changes without `.openspec.yaml` metadata\n\n#### `context` (optional)\n\nA string containing project context. Injected into ALL artifact prompts.\n\nUse cases:\n- Tech stack description\n- Link to conventions/style guides\n- Team constraints or preferences\n- Domain-specific context\n\n#### `rules` (optional)\n\nPer-artifact rules, keyed by artifact ID. Additive to schema's built-in guidance.\n\n```yaml\nrules:\n  <artifact-id>:\n    - Rule 1\n    - Rule 2\n```\n\nRules are injected into the specific artifact's prompt, not all prompts.\n\n### Injection Format\n\nWhen generating instructions for an artifact:\n\n```xml\n<context>\nTech stack: TypeScript, React, Node.js, PostgreSQL\nAPI style: RESTful, documented in docs/api-conventions.md\n...\n</context>\n\n<rules>\n- Include rollback plan\n- Identify affected teams and notify in #platform-changes\n</rules>\n\n<template>\n[Schema's built-in template content]\n</template>\n```\n\nContext appears for all artifacts. Rules only appear for the matching artifact.\n\n### Config Creation Strategy\n\n**Why integrate with `artifact-experimental-setup`?**\n\nThis feature targets **experimental workflow users**. The decision to create config during experimental setup (rather than providing standalone commands) is intentional:\n\n**Rationale:**\n1. **Single entry point** - Users setting up experimental features are already in \"configuration mode\"\n2. **Contextual timing** - Natural to configure project defaults when setting up workflow\n3. **Avoids premature API surface** - No standalone `openspec config init` until feature graduates\n4. **Experimental scope** - Keeps config as experimental feature, not stable API\n5. **Progressive disclosure** - Users can skip and create manually later if needed\n\n**Evolution path:**\n\n```\nToday (Experimental):\n  openspec artifact-experimental-setup\n    → prompts for config creation\n    → creates .claude/skills/\n    → creates openspec/config.yaml\n\nFuture (When graduating):\n  openspec init\n    → prompts for config creation\n    → creates openspec/ directory\n    → creates openspec/config.yaml\n\n  + standalone commands:\n    openspec config init\n    openspec config validate\n    openspec config set <key> <value>\n```\n\n**Why optional?**\n\nConfig is **additive**, not required:\n- OpenSpec works without config (uses defaults)\n- Users can skip during setup and add manually later\n- Teams can start simple and add config when they feel friction\n- No config file in git = no problem, everyone gets defaults\n\n**Design principle:** The system never *requires* config, but makes it easy to create when users want customization.\n\n## Scope\n\n### In Scope\n\n**Core Config System:**\n- Define `ProjectConfig` type with Zod schema\n- Add `readProjectConfig()` function with graceful error handling\n- Update instruction generation to inject context (all artifacts)\n- Update instruction generation to inject rules (per-artifact)\n- Update schema resolution to use config's `schema` field as default\n- Update `openspec new change` to use config's schema as default\n\n**Config Creation (Experimental Setup):**\n- Extend `artifact-experimental-setup` command to optionally create config\n- Interactive prompts for schema selection (with description of each schema)\n- Interactive prompts for project context (optional multi-line input)\n- Interactive prompts for per-artifact rules (optional)\n- Validate config immediately after creation\n- Show clear \"skip\" option for users who want to create config manually later\n- Display created config location and usage examples\n\n### Out of Scope\n\n- `skip` / `add` for structural changes (use fork path for structural changes)\n- File reference for context (`context: ./CONTEXT.md`) - start with string, add later if needed\n- Global user-level config (XDG directories, etc.)\n- Integration with standard `openspec init` (will add when experimental graduates)\n- Standalone `openspec config init` command (may add in future change)\n- `openspec config validate` command (may add in future change)\n- Config editing/updating commands (users edit YAML directly)\n\n## User Experience\n\n### Setting Up Config (Experimental Workflow)\n\nWhen users set up the experimental workflow, they're prompted to optionally create config:\n\n```bash\n$ openspec artifact-experimental-setup\n\nSetting up experimental artifact workflow...\n\n✓ Created .claude/skills/openspec-explore/SKILL.md\n✓ Created .claude/skills/openspec-new-change/SKILL.md\n✓ Created .claude/skills/openspec-continue-change/SKILL.md\n✓ Created .claude/skills/openspec-apply-change/SKILL.md\n✓ Created .claude/skills/openspec-ff-change/SKILL.md\n✓ Created .claude/skills/openspec-sync-specs/SKILL.md\n✓ Created .claude/skills/openspec-archive-change/SKILL.md\n\n✓ Created .claude/commands/opsx/explore.md\n✓ Created .claude/commands/opsx/new.md\n✓ Created .claude/commands/opsx/continue.md\n✓ Created .claude/commands/opsx/apply.md\n✓ Created .claude/commands/opsx/ff.md\n✓ Created .claude/commands/opsx/sync.md\n✓ Created .claude/commands/opsx/archive.md\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n📋 Project Configuration (Optional)\n\nConfigure project defaults for OpenSpec workflows.\n\n? Create openspec/config.yaml? (Y/n) Y\n\n? Default schema for new changes?\n  ❯ spec-driven (proposal → specs → design → tasks)\n    tdd (spec → tests → implementation → docs)\n\n? Add project context? (optional)\n  Context is shown to AI when creating artifacts.\n  Examples: tech stack, conventions, style guides, domain knowledge\n\n  Press Enter to skip, or type/paste context:\n  │ Tech stack: TypeScript, React, Node.js, PostgreSQL\n  │ API style: RESTful, documented in docs/api-conventions.md\n  │ Testing: Jest + React Testing Library\n  │ We value backwards compatibility for all public APIs\n  │\n  [Press Enter when done]\n\n? Add per-artifact rules? (optional) (Y/n) Y\n\n  Which artifacts should have custom rules?\n  [Space to select, Enter when done]\n  ◯ proposal\n  ◉ specs\n  ◯ design\n  ◯ tasks\n\n? Rules for specs artifact:\n  Enter rules one per line, press Enter on empty line to finish:\n  │ Use Given/When/Then format for scenarios\n  │ Reference existing patterns before inventing new ones\n  │\n  [Empty line to finish]\n\n✓ Created openspec/config.yaml\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n🎉 Setup Complete!\n\n📖 Config created at: openspec/config.yaml\n   • Default schema: spec-driven\n   • Project context: Added (4 lines)\n   • Rules: 1 artifact configured\n\nUsage:\n  • New changes automatically use 'spec-driven' schema\n  • Context injected into all artifact instructions\n  • Rules applied to matching artifacts\n\nTo share with team:\n  git add openspec/config.yaml .claude/\n  git commit -m \"Setup OpenSpec experimental workflow with project config\"\n\n[Rest of experimental setup output...]\n```\n\n**Key UX decisions:**\n\n1. **Prompted during setup** - Natural place since users are already configuring experimental features\n2. **Optional at every step** - Clear skip options, no forced configuration\n3. **Guided prompts** - Schema descriptions, example context, artifact selection\n4. **Immediate validation** - Config is validated after creation, errors shown immediately\n5. **Clear output** - Shows exactly what was created and how it affects workflow\n\n### Setting Up Config (Manual Creation)\n\nUsers can also create config manually (or skip during setup and add later):\n\n```bash\n# Create config file manually\ncat > openspec/config.yaml << 'EOF'\nschema: spec-driven\n\ncontext: |\n  Tech stack: TypeScript, React, Node.js\n  We follow REST conventions documented in docs/api.md\n  All changes require backwards compatibility consideration\n\nrules:\n  proposal:\n    - Must include rollback plan\n    - Must identify affected teams\n  specs:\n    - Use Given/When/Then format\nEOF\n```\n\n### Effect on Workflow\n\nOnce config is created, it affects the experimental workflow in three ways:\n\n**1. Default Schema Selection**\n\n```bash\n# Before config: must specify schema\n/opsx:new my-feature --schema spec-driven\n\n# After config (with schema: spec-driven): schema is automatic\n/opsx:new my-feature\n# Automatically uses spec-driven from config\n\n# Override still works\n/opsx:new my-feature --schema tdd\n# Uses tdd, ignoring config\n```\n\n**2. Context Injection (All Artifacts)**\n\n```bash\n# Get instructions for any artifact\nopenspec instructions proposal --change my-feature\n\n# Output now includes project context:\n<context>\nTech stack: TypeScript, React, Node.js, PostgreSQL\nAPI style: RESTful, documented in docs/api-conventions.md\nTesting: Jest + React Testing Library\nWe value backwards compatibility for all public APIs\n</context>\n\n<template>\n[Schema's proposal template]\n</template>\n```\n\nContext appears in instructions for **all artifacts** (proposal, specs, design, tasks).\n\n**3. Rules Injection (Per-Artifact)**\n\n```bash\n# Get instructions for artifact with rules configured\nopenspec instructions specs --change my-feature\n\n# Output includes artifact-specific rules:\n<context>\n[Project context]\n</context>\n\n<rules>\n- Use Given/When/Then format for scenarios\n- Reference existing patterns before inventing new ones\n</rules>\n\n<template>\n[Schema's specs template]\n</template>\n```\n\nRules only appear for the **specific artifact** they're configured for.\n\n**Artifacts without rules** (e.g., design, tasks) don't get a `<rules>` section:\n\n```bash\nopenspec instructions design --change my-feature\n# Output: <context> then <template> only (no rules)\n```\n\n### Team Sharing\n\n```bash\n# Commit config\ngit add openspec/config.yaml\ngit commit -m \"Add project config with context and rules\"\n\n# Everyone gets the same context and rules automatically\n```\n\n## Implementation Notes\n\n### Files to Modify/Create\n\n| File | Changes |\n|------|---------|\n| `src/core/project-config.ts` | **NEW FILE:** Types, parsing, reading, validation helpers |\n| `src/core/artifact-graph/instruction-loader.ts` | Inject context (all artifacts) and rules (per-artifact) |\n| `src/utils/change-utils.ts` | Use config schema as default in `createChange()` |\n| `src/utils/change-metadata.ts` | Update `resolveSchemaForChange()` to check config |\n| `src/commands/artifact-workflow.ts` | Extend `artifactExperimentalSetupCommand()` to prompt for config creation |\n| `src/core/config-prompts.ts` | **NEW FILE:** Interactive prompts for config creation (reusable) |\n\n### Config Location\n\nAlways at `./openspec/config.yaml` relative to project root. No XDG/global config for simplicity.\n\n### Resolution Order Update\n\nSchema selection order becomes:\n\n```\n1. --schema CLI flag                    # Explicit override\n2. .openspec.yaml in change directory   # Change-specific binding\n3. openspec/config.yaml schema field    # Project default (NEW)\n4. \"spec-driven\"                        # Hardcoded fallback\n```\n\n### Validation\n\n- `schema` must be a valid schema name (exists in resolution)\n- `context` must be string\n- `rules` must be object with string keys (artifact IDs) and array values\n- Unknown artifact IDs in `rules` should warn, not error (allows forward compat)\n\n### Experimental Setup Integration\n\n**Changes to `artifactExperimentalSetupCommand()` in `src/commands/artifact-workflow.ts`:**\n\nAfter creating skills and commands, the setup command will:\n\n1. **Display section header:**\n   ```\n   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n   📋 Project Configuration (Optional)\n   Configure project defaults for OpenSpec workflows.\n   ```\n\n2. **Prompt: Create config?**\n   - Yes/No prompt with default \"Yes\"\n   - If No → skip entire config section, show usage instructions\n   - If Yes → continue to detailed prompts\n\n3. **Prompt: Schema selection**\n   - Use `listSchemasWithInfo()` to get available schemas\n   - Display each with description and artifact flow\n   - Default to first schema (likely \"spec-driven\")\n\n4. **Prompt: Project context**\n   - Multi-line input (or editor if available)\n   - Show examples: \"tech stack, conventions, style guides\"\n   - Allow empty (skip)\n\n5. **Prompt: Per-artifact rules**\n   - Yes/No prompt, default \"No\" (rules are less common)\n   - If Yes:\n     - Show checklist of artifacts from selected schema\n     - For each selected artifact, prompt for rules (line-by-line input)\n     - Allow empty line to finish each artifact's rules\n\n6. **Create and validate config:**\n   - Build `ProjectConfig` object from inputs\n   - Validate with Zod schema\n   - Write to `openspec/config.yaml` using YAML serializer\n   - If validation fails, show error and ask to retry or skip\n\n7. **Display success summary:**\n   - Path to created config\n   - Summary: schema used, context added (line count), rules count\n   - Usage examples showing how config affects workflow\n   - Suggestion to commit config to git\n\n**Error handling:**\n- Invalid schema selection → show available schemas with fuzzy match suggestions, retry\n- Context too large (>50KB) → reject with error, ask to reduce size\n- Rules reference invalid artifact → warn but continue (forward compat)\n- File write fails → show error, suggest manual creation\n- Config already exists → show message, skip config section, continue with setup\n- User cancellation (Ctrl+C) → log \"Config creation cancelled\", continue with rest of setup (skills/commands already created)\n\n**If config already exists:**\n\nWhen `openspec/config.yaml` already exists:\n\n```bash\n$ openspec artifact-experimental-setup\n\n[Skills and commands created...]\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n📋 Project Configuration\n\nℹ️  openspec/config.yaml already exists. Skipping config creation.\n\n   To update config, edit openspec/config.yaml manually or:\n   1. Delete openspec/config.yaml\n   2. Run openspec artifact-experimental-setup again\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n[Rest of setup output...]\n```\n\nThis prevents accidentally overwriting user's config.\n\n**Implementation approach:**\n\nCreate separate `src/core/config-prompts.ts` module:\n\n```typescript\nexport interface ConfigPromptResult {\n  createConfig: boolean;\n  schema?: string;\n  context?: string;\n  rules?: Record<string, string[]>;\n}\n\nexport async function promptForConfig(): Promise<ConfigPromptResult> {\n  // Prompt logic using inquirer or similar\n  // Returns structured result for config creation\n  // Throws ExitPromptError on Ctrl+C (handled by caller)\n}\n```\n\n**Ctrl+C handling in setup command:**\n\n```typescript\ntry {\n  const configResult = await promptForConfig();\n  if (configResult.createConfig) {\n    writeConfigFile(configResult);\n    console.log('✓ Created openspec/config.yaml');\n  }\n} catch (error) {\n  if (error.name === 'ExitPromptError') {\n    console.log('\\nℹ️  Config creation cancelled');\n    console.log('   Skills and commands already created');\n    console.log('   Run setup again to create config later');\n    // Continue with rest of setup (not a fatal error)\n  } else {\n    throw error; // Re-throw unexpected errors\n  }\n}\n```\n\nThis keeps prompts reusable and testable separately from the setup command.\n\n### Dependencies\n\n**Interactive Prompting Library:**\n\nThe experimental setup command will need an interactive prompting library for the config creation flow. Options:\n\n1. **@inquirer/prompts** (recommended)\n   - Modern, tree-shakeable, TypeScript-first\n   - Individual imports: `@inquirer/input`, `@inquirer/confirm`, `@inquirer/checkbox`, `@inquirer/editor`\n   - Already used in OpenSpec (if not, lightweight addition)\n\n2. **inquirer** (classic)\n   - More established, larger ecosystem\n   - Heavier bundle size\n   - Single package with all prompt types\n\n**Prompts needed:**\n- `confirm` - \"Create config?\" \"Add rules?\"\n- `select` - Schema selection with descriptions\n- `editor` or multi-line `input` - Project context\n- `checkbox` - Artifact selection for rules\n- `input` (repeated) - Rule entry (line-by-line)\n\n**Alternative (no dependency):**\n\nUse Node's built-in `readline` for basic prompts:\n- More code to write\n- Less polished UX (no arrow key navigation, checkbox selection)\n- Zero dependency cost\n\n**Recommendation:** Use `@inquirer/prompts` for best UX. Config setup is a one-time operation where UX matters.\n\n### YAML Serialization\n\nConfig creation needs YAML serialization:\n\n- **yaml** package (already a dependency)\n- Use `yaml.stringify()` to write config\n- Preserve multi-line strings with `|` literal style\n- Format: 2-space indent, no quotes unless needed\n\nExample:\n```typescript\nimport { stringify } from 'yaml';\n\nconst config = {\n  schema: 'spec-driven',\n  context: 'Multi-line\\ncontext\\nhere',\n  rules: { proposal: ['Rule 1', 'Rule 2'] }\n};\n\nconst yamlContent = stringify(config, {\n  indent: 2,\n  defaultStringType: 'QUOTE_DOUBLE',\n  defaultKeyType: 'PLAIN',\n});\n// context will use | literal style automatically for multi-line\n```\n\n## Testing Considerations\n\n**Core Config Functionality:**\n- Create config with all fields (schema, context, rules), verify parsing\n- Create minimal config (schema only), verify parsing\n- Verify context appears in instruction output for all artifacts\n- Verify rules appear only for matching artifact (not all artifacts)\n- Verify schema from config is used for new changes\n- Verify CLI `--schema` flag overrides config\n- Verify change's `.openspec.yaml` overrides config\n- Verify graceful handling of missing config (fallback to defaults)\n- Verify graceful handling of invalid YAML syntax (warning, fallback)\n- Verify graceful handling of invalid schema (warning, show valid schemas)\n- Verify unknown artifact IDs in rules emit warnings but don't halt\n\n**Schema Resolution Precedence:**\n- Test all four levels of schema resolution:\n  1. CLI flag `--schema` (highest priority)\n  2. Change metadata `.openspec.yaml`\n  3. Project config `openspec/config.yaml`\n  4. Hardcoded default \"spec-driven\" (lowest priority)\n- Verify each level correctly overrides lower levels\n\n**Context and Rules Injection:**\n- Verify context injection uses `<context>` XML-style tags\n- Verify rules injection uses `<rules>` XML-style tags with bullets\n- Verify injection order: `<context>` → `<rules>` → `<template>`\n- Verify multi-line context is preserved\n- Verify special characters in context/rules are not escaped\n- Verify empty context/rules don't create tags\n\n**Experimental Setup Integration:**\n- Test `artifact-experimental-setup` with user skipping config creation\n- Test `artifact-experimental-setup` with minimal config (schema only)\n- Test `artifact-experimental-setup` with full config (schema + context + rules)\n- Test schema selection from available schemas\n- Test multi-line context input\n- Test per-artifact rules prompts\n- Test artifact selection (checkboxes)\n- Test validation errors during config creation\n- Test file write errors (permissions, etc.)\n- Verify created config can be parsed by `readProjectConfig()`\n- Verify success summary shows correct information\n\n**Edge Cases:**\n- Config file exists but is empty → treat as invalid, warn\n- Config has `.yml` extension instead of `.yaml` → accept both\n- Both `.yaml` and `.yml` exist → prefer `.yaml`\n- Context contains YAML-significant characters → properly escape in output\n- Rules array contains empty strings → filter out or warn\n- Schema references non-existent schema → error with suggestions\n- Config in subdirectory (not project root) → not found, use defaults\n\n**Backward Compatibility:**\n- Existing projects without config continue to work\n- Existing changes with `.openspec.yaml` metadata aren't affected by config\n- Adding config to existing project doesn't break in-progress changes\n\n**Integration Tests:**\n- Create config → create change → verify schema used\n- Create config → get instructions → verify context injected\n- Create config → get instructions → verify rules injected\n- Update config → verify changes reflected immediately (no caching)\n- Run `artifact-experimental-setup` → create config → create change → verify flow\n\n## Related Changes\n\n- **project-local-schemas**: Enables `schema: my-workflow` to reference project-local schemas\n\n## Appendix: Full Config Schema\n\n```typescript\nimport { z } from 'zod';\n\n// Zod schema serves as both runtime validation and documentation\n// Type is inferred from schema for type safety\nexport const ProjectConfigSchema = z.object({\n  // Required: which schema to use (e.g., \"spec-driven\", \"tdd\", or project-local schema name)\n  schema: z.string().min(1).describe('The workflow schema to use (e.g., \"spec-driven\", \"tdd\")'),\n\n  // Optional: project context (injected into all artifact instructions)\n  // Max size: 50KB (enforced during parsing)\n  context: z.string().optional().describe('Project context injected into all artifact instructions'),\n\n  // Optional: per-artifact rules (additive to schema's built-in guidance)\n  rules: z.record(\n    z.string(),           // artifact ID\n    z.array(z.string())   // list of rules\n  ).optional().describe('Per-artifact rules, keyed by artifact ID'),\n});\n\nexport type ProjectConfig = z.infer<typeof ProjectConfigSchema>;\n\n// Note: Parsing uses safeParse() on individual fields for resilient error handling\n// Invalid fields are warned about but don't prevent other fields from being loaded\n```\n\n## Appendix: Visual Summary\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                                                                 │\n│   User provides:                                                │\n│   ┌─────────────────────────────────────────────────────────┐   │\n│   │ openspec/config.yaml                                    │   │\n│   │                                                         │   │\n│   │ schema: spec-driven                                     │   │\n│   │ context: \"We use React, TypeScript...\"                  │   │\n│   │ rules:                                                  │   │\n│   │   proposal: [...]                                       │   │\n│   └─────────────────────────────────────────────────────────┘   │\n│                              │                                  │\n│                              ▼                                  │\n│   ┌─────────────────────────────────────────────────────────┐   │\n│   │ OpenSpec merges:                                        │   │\n│   │                                                         │   │\n│   │   Schema (spec-driven)                                  │   │\n│   │   + User's context                                      │   │\n│   │   + User's rules                                        │   │\n│   │   ─────────────────────────                             │   │\n│   │   = Enriched instructions                               │   │\n│   └─────────────────────────────────────────────────────────┘   │\n│                              │                                  │\n│                              ▼                                  │\n│   ┌─────────────────────────────────────────────────────────┐   │\n│   │ Agent sees (for proposal artifact):                     │   │\n│   │                                                         │   │\n│   │ <context>                                               │   │\n│   │ We use React, TypeScript...                             │   │\n│   │ </context>                                              │   │\n│   │                                                         │   │\n│   │ <rules>                                                 │   │\n│   │ - Include rollback plan                                 │   │\n│   │ - Identify affected teams                               │   │\n│   │ </rules>                                                │   │\n│   │                                                         │   │\n│   │ <template>                                              │   │\n│   │ [Built-in proposal template]                            │   │\n│   │ </template>                                             │   │\n│   └─────────────────────────────────────────────────────────┘   │\n│                                                                 │\n└─────────────────────────────────────────────────────────────────┘\n```\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-config/specs/config-loading/spec.md",
    "content": "# Spec: Config Loading\n\n## ADDED Requirements\n\n### Requirement: Load project config from openspec/config.yaml\n\nThe system SHALL read and parse the project configuration file located at `openspec/config.yaml` relative to the project root.\n\n#### Scenario: Valid config file exists\n- **WHEN** `openspec/config.yaml` exists with valid YAML content\n- **THEN** system parses the file and returns a ProjectConfig object\n\n#### Scenario: Config file does not exist\n- **WHEN** `openspec/config.yaml` does not exist\n- **THEN** system returns null without error\n\n#### Scenario: Config file has invalid YAML syntax\n- **WHEN** `openspec/config.yaml` contains malformed YAML\n- **THEN** system logs a warning message and returns null\n\n#### Scenario: Config file has valid YAML but invalid schema\n- **WHEN** `openspec/config.yaml` contains valid YAML that fails Zod schema validation\n- **THEN** system logs a warning message with validation details and returns null\n\n### Requirement: Support .yml file extension alias\n\nThe system SHALL accept both `.yaml` and `.yml` file extensions for the config file.\n\n#### Scenario: Config file uses .yml extension\n- **WHEN** `openspec/config.yml` exists and `openspec/config.yaml` does not exist\n- **THEN** system reads from `openspec/config.yml`\n\n#### Scenario: Both .yaml and .yml exist\n- **WHEN** both `openspec/config.yaml` and `openspec/config.yml` exist\n- **THEN** system prefers `openspec/config.yaml`\n\n### Requirement: Use resilient field-by-field parsing\n\nThe system SHALL parse each config field independently, collecting valid fields and warning about invalid ones without rejecting the entire config.\n\n#### Scenario: Schema field is valid\n- **WHEN** config contains `schema: \"spec-driven\"`\n- **THEN** schema field is included in returned config\n\n#### Scenario: Schema field is missing\n- **WHEN** config lacks the `schema` field\n- **THEN** no warning is logged (field is optional at parse level)\n\n#### Scenario: Schema field is empty string\n- **WHEN** config contains `schema: \"\"`\n- **THEN** warning is logged and schema field is not included in returned config\n\n#### Scenario: Schema field is invalid type\n- **WHEN** config contains `schema: 123` (number instead of string)\n- **THEN** warning is logged and schema field is not included in returned config\n\n#### Scenario: Context field is valid\n- **WHEN** config contains `context: \"Tech stack: TypeScript\"`\n- **THEN** context field is included in returned config\n\n#### Scenario: Context field is invalid type\n- **WHEN** config contains `context: 123` (number instead of string)\n- **THEN** warning is logged and context field is not included in returned config\n\n#### Scenario: Rules field has valid structure\n- **WHEN** config contains `rules: { proposal: [\"Rule 1\"], specs: [\"Rule 2\"] }`\n- **THEN** rules field is included in returned config with valid rules\n\n#### Scenario: Rules field has non-array value for artifact\n- **WHEN** config contains `rules: { proposal: \"not an array\", specs: [\"Valid\"] }`\n- **THEN** warning is logged for proposal, but specs rules are still included in returned config\n\n#### Scenario: Rules array contains non-string elements\n- **WHEN** config contains `rules: { proposal: [\"Valid rule\", 123, \"\"] }`\n- **THEN** only \"Valid rule\" is included, warning logged about invalid elements\n\n#### Scenario: Mix of valid and invalid fields\n- **WHEN** config contains valid schema, invalid context type, valid rules\n- **THEN** config is returned with schema and rules fields, warning logged about context\n\n### Requirement: Enforce context size limit\n\nThe system SHALL reject context fields exceeding 50KB and log a warning.\n\n#### Scenario: Context within size limit\n- **WHEN** config contains context of 1KB\n- **THEN** context is included in returned config\n\n#### Scenario: Context at size limit\n- **WHEN** config contains context of exactly 50KB\n- **THEN** context is included in returned config\n\n#### Scenario: Context exceeds size limit\n- **WHEN** config contains context of 51KB\n- **THEN** warning is logged with size and limit, context field is not included in returned config\n\n### Requirement: Defer artifact ID validation to instruction loading\n\nThe system SHALL NOT validate artifact IDs in rules during config load time. Validation happens during instruction loading when schema is known.\n\n#### Scenario: Config with rules is loaded\n- **WHEN** config contains `rules: { unknownartifact: [...] }`\n- **THEN** config is loaded successfully without validation errors\n\n#### Scenario: Validation happens at instruction load time\n- **WHEN** instructions are loaded for any artifact and config has unknown artifact IDs in rules\n- **THEN** warnings are emitted about unknown artifact IDs (see rules-injection spec for details)\n\n### Requirement: Gracefully handle config errors without halting\n\nThe system SHALL continue operation with default values when config loading or parsing fails.\n\n#### Scenario: Config parse failure during command execution\n- **WHEN** config file has syntax errors and user runs `openspec new change`\n- **THEN** command executes using default schema \"spec-driven\"\n\n#### Scenario: Warning is visible to user\n- **WHEN** config loading fails\n- **THEN** system outputs warning message to stderr with details about the failure\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-config/specs/context-injection/spec.md",
    "content": "# Spec: Context Injection\n\n## ADDED Requirements\n\n### Requirement: Inject context into all artifact instructions\n\nThe system SHALL inject the context field from project config into instructions for all artifacts, wrapped in XML-style `<context>` tags.\n\n#### Scenario: Config has context field\n- **WHEN** config contains `context: \"Tech stack: TypeScript, React\"`\n- **THEN** instruction output includes `<context>\\nTech stack: TypeScript, React\\n</context>`\n\n#### Scenario: Config has no context field\n- **WHEN** config omits the context field or context is undefined\n- **THEN** instruction output does not include `<context>` tags\n\n#### Scenario: Context is multi-line string\n- **WHEN** config contains context with multiple lines\n- **THEN** instruction output preserves line breaks within `<context>` tags\n\n#### Scenario: Context applied to all artifacts\n- **WHEN** instructions are loaded for any artifact (proposal, specs, design, tasks)\n- **THEN** context section appears in all instruction outputs\n\n### Requirement: Format context with XML-style tags\n\nThe system SHALL wrap context content in `<context>` opening and `</context>` closing tags with content on separate lines.\n\n#### Scenario: Context tag structure\n- **WHEN** context is injected into instructions\n- **THEN** format is exactly `<context>\\n{content}\\n</context>\\n\\n`\n\n#### Scenario: Context appears before template\n- **WHEN** instructions are generated with context\n- **THEN** `<context>` section appears before the `<template>` section\n\n### Requirement: Preserve context content exactly as provided\n\nThe system SHALL inject context content without modification, escaping, or interpretation.\n\n#### Scenario: Context contains special characters\n- **WHEN** context includes characters like `<`, `>`, `&`, quotes\n- **THEN** characters are preserved exactly as written in the config\n\n#### Scenario: Context contains URLs\n- **WHEN** context includes URLs like \"docs at https://example.com\"\n- **THEN** URLs are preserved exactly in the injected content\n\n#### Scenario: Context contains markdown\n- **WHEN** context includes markdown formatting like `**bold**` or `[links](url)`\n- **THEN** markdown is preserved without rendering or escaping\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-config/specs/rules-injection/spec.md",
    "content": "# Spec: Rules Injection\n\n## ADDED Requirements\n\n### Requirement: Inject rules only for matching artifact\n\nThe system SHALL inject rules from config into instructions only when the artifact ID matches a key in the rules object.\n\n#### Scenario: Rules exist for the artifact\n- **WHEN** loading instructions for \"proposal\" and config has `rules: { proposal: [\"Rule 1\", \"Rule 2\"] }`\n- **THEN** instruction output includes rules section with both rules\n\n#### Scenario: No rules for the artifact\n- **WHEN** loading instructions for \"design\" and config has `rules: { proposal: [...] }`\n- **THEN** instruction output does not include `<rules>` tags\n\n#### Scenario: Rules object is undefined\n- **WHEN** config omits the rules field or rules is undefined\n- **THEN** instruction output does not include `<rules>` tags for any artifact\n\n#### Scenario: Rules array is empty for artifact\n- **WHEN** config has `rules: { proposal: [] }`\n- **THEN** instruction output does not include `<rules>` tags\n\n### Requirement: Format rules with XML-style tags and bullet list\n\nThe system SHALL wrap rules in `<rules>` tags with each rule as a bulleted list item.\n\n#### Scenario: Single rule for artifact\n- **WHEN** config has `rules: { proposal: [\"Include rollback plan\"] }`\n- **THEN** instruction output includes `<rules>\\n- Include rollback plan\\n</rules>\\n\\n`\n\n#### Scenario: Multiple rules for artifact\n- **WHEN** config has `rules: { proposal: [\"Rule 1\", \"Rule 2\", \"Rule 3\"] }`\n- **THEN** instruction output includes each rule as separate bullet point\n\n#### Scenario: Rules appear after context and before template\n- **WHEN** instructions are generated with both context and rules\n- **THEN** order is `<context>` then `<rules>` then `<template>`\n\n### Requirement: Preserve rule text exactly as provided\n\nThe system SHALL inject rule text without modification, escaping, or interpretation.\n\n#### Scenario: Rule contains markdown\n- **WHEN** rule includes markdown like \"Use **Given/When/Then** format\"\n- **THEN** markdown is preserved in the injected content\n\n#### Scenario: Rule contains special characters\n- **WHEN** rule includes characters like `<`, `>`, quotes\n- **THEN** characters are preserved exactly as written\n\n#### Scenario: Rule is multi-line string\n- **WHEN** rule text contains line breaks\n- **THEN** line breaks are preserved within the bullet point\n\n### Requirement: Support multiple artifacts with different rules\n\nThe system SHALL allow different rule sets for different artifacts in the same config.\n\n#### Scenario: Multiple artifacts have rules\n- **WHEN** config has `rules: { proposal: [\"P1\"], specs: [\"S1\", \"S2\"], tasks: [\"T1\"] }`\n- **THEN** proposal instructions show only [\"P1\"], specs show only [\"S1\", \"S2\"], tasks show only [\"T1\"]\n\n#### Scenario: Some artifacts have rules, others do not\n- **WHEN** config has rules for proposal and specs only\n- **THEN** design and tasks instructions have no `<rules>` section\n\n### Requirement: Rules are additive to schema guidance\n\nThe system SHALL add config rules to the schema's built-in artifact instruction, not replace it.\n\n#### Scenario: Artifact has schema instruction and config rules\n- **WHEN** artifact has built-in instruction from schema and config provides rules\n- **THEN** final instruction contains both schema guidance and config rules\n\n#### Scenario: Rules provide additional constraints\n- **WHEN** schema says \"create proposal\" and config rules say \"include rollback plan\"\n- **THEN** agent sees both the schema template and the additional rule\n\n### Requirement: Validate artifact IDs during instruction loading\n\nThe system SHALL validate artifact IDs in rules against the schema when instructions are loaded and emit warnings for unknown IDs.\n\n#### Scenario: All artifact IDs are valid\n- **WHEN** instructions loaded and config has `rules: { proposal: [...], specs: [...] }` for schema with those artifacts\n- **THEN** no validation warnings are emitted\n\n#### Scenario: Unknown artifact ID in rules\n- **WHEN** instructions loaded and config has `rules: { unknownartifact: [...] }`\n- **THEN** warning emitted: \"Unknown artifact ID in rules: 'unknownartifact'. Valid IDs for schema 'spec-driven': design, proposal, specs, tasks\"\n\n#### Scenario: Multiple unknown artifact IDs\n- **WHEN** instructions loaded and config has multiple unknown artifact IDs\n- **THEN** separate warning emitted for each unknown artifact ID\n\n#### Scenario: Validation warnings shown once per session\n- **WHEN** instructions loaded multiple times in same CLI session\n- **THEN** each unique validation warning is shown only once (cached)\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-config/specs/schema-resolution/spec.md",
    "content": "# Spec: Schema Resolution with Config\n\n## ADDED Requirements\n\n### Requirement: Use config schema as default for new changes\n\nThe system SHALL use the schema field from `openspec/config.yaml` as the default when creating new changes without explicit `--schema` flag.\n\n#### Scenario: Create change without --schema flag and config exists\n- **WHEN** user runs `openspec new change foo` and config contains `schema: \"tdd\"`\n- **THEN** system creates change with schema \"tdd\"\n\n#### Scenario: Create change without --schema flag and no config\n- **WHEN** user runs `openspec new change foo` and no config file exists\n- **THEN** system creates change with default schema \"spec-driven\"\n\n#### Scenario: Create change with explicit --schema flag\n- **WHEN** user runs `openspec new change foo --schema custom` and config contains `schema: \"tdd\"`\n- **THEN** system creates change with schema \"custom\" (CLI flag overrides config)\n\n### Requirement: Resolve schema with updated precedence order\n\nThe system SHALL resolve the schema for a change using the following precedence order: CLI flag, change metadata, project config, hardcoded default.\n\n#### Scenario: CLI flag is provided\n- **WHEN** user runs command with `--schema custom`\n- **THEN** system uses \"custom\" regardless of change metadata or config\n\n#### Scenario: Change metadata specifies schema\n- **WHEN** change has `.openspec.yaml` with `schema: bound` and config has `schema: tdd`\n- **THEN** system uses \"bound\" from change metadata\n\n#### Scenario: Only project config specifies schema\n- **WHEN** no CLI flag or change metadata, but config has `schema: tdd`\n- **THEN** system uses \"tdd\" from project config\n\n#### Scenario: No schema specified anywhere\n- **WHEN** no CLI flag, change metadata, or project config\n- **THEN** system uses hardcoded default \"spec-driven\"\n\n### Requirement: Support project-local schema names in config\n\nThe system SHALL allow the config schema field to reference project-local schemas defined in `openspec/schemas/`.\n\n#### Scenario: Config references project-local schema\n- **WHEN** config contains `schema: \"my-workflow\"` and `openspec/schemas/my-workflow/` exists\n- **THEN** system resolves to the project-local schema\n\n#### Scenario: Config references non-existent schema\n- **WHEN** config contains `schema: \"nonexistent\"` and that schema does not exist\n- **THEN** system shows error when attempting to load the schema with fuzzy match suggestions and list of all valid schemas\n\n### Requirement: Provide helpful error message for invalid schema\n\nThe system SHALL display schema error with fuzzy match suggestions, list of available schemas, and fix instructions.\n\n#### Scenario: Schema name with typo (close match)\n- **WHEN** config contains `schema: \"spce-driven\"` (typo)\n- **THEN** error message includes \"Did you mean: spec-driven (built-in)\" as suggestion\n\n#### Scenario: Schema name with no close matches\n- **WHEN** config contains `schema: \"completely-wrong\"`\n- **THEN** error message shows list of all available built-in and project-local schemas\n\n#### Scenario: Error message includes fix instructions\n- **WHEN** config references invalid schema\n- **THEN** error message includes \"Fix: Edit openspec/config.yaml and change 'schema: X' to a valid schema name\"\n\n#### Scenario: Error distinguishes built-in vs project-local schemas\n- **WHEN** error lists available schemas\n- **THEN** output clearly labels each as \"built-in\" or \"project-local\"\n\n### Requirement: Maintain backwards compatibility for existing changes\n\nThe system SHALL continue to work with existing changes that do not have project config.\n\n#### Scenario: Existing change without config\n- **WHEN** change was created before config feature and no config file exists\n- **THEN** system resolves schema using existing logic (change metadata or hardcoded default)\n\n#### Scenario: Existing change with config added later\n- **WHEN** config file is added to project with existing changes\n- **THEN** existing changes continue to use their bound schema from `.openspec.yaml`\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-config/tasks.md",
    "content": "## 1. Core Config System\n\n- [x] 1.1 Create `src/core/project-config.ts` with ProjectConfigSchema using Zod (for docs and type inference)\n- [x] 1.2 Implement `readProjectConfig()` with resilient field-by-field parsing using Zod's `safeParse()`\n- [x] 1.3 Add support for both .yaml and .yml extensions (prefer .yaml)\n- [x] 1.4 Add 50KB hard limit for context field with size check and warning\n- [x] 1.5 Implement `validateConfigRules()` to validate artifact IDs against schema (called during instruction loading)\n- [x] 1.6 Implement `suggestSchemas()` with Levenshtein distance fuzzy matching for helpful error messages\n- [x] 1.7 Add unit tests for resilient parsing (partial configs, field-level errors with Zod safeParse)\n- [x] 1.8 Add unit tests for context size limit enforcement\n- [x] 1.9 Add unit tests for .yml/.yaml precedence\n- [x] 1.10 Add unit tests for fuzzy schema matching with typos\n\n## 2. Schema Resolution Integration\n\n- [x] 2.1 Update `resolveSchemaForChange()` in `src/utils/change-metadata.ts` to check project config (3rd in precedence)\n- [x] 2.2 Update `createNewChange()` in `src/utils/change-utils.ts` to use config schema as default\n- [x] 2.3 Add integration tests for schema resolution precedence (CLI → change metadata → config → default)\n- [x] 2.4 Add test for project-local schema names in config\n- [x] 2.5 Add test for non-existent schema error handling with suggestions\n\n## 3. Context and Rules Injection\n\n- [x] 3.1 Update `loadInstructions()` in `src/core/artifact-graph/instruction-loader.ts` to inject context for all artifacts\n- [x] 3.2 Add rules injection logic for matching artifacts only with XML tags and bullet formatting\n- [x] 3.3 Add validation call during instruction loading to check artifact IDs in rules\n- [x] 3.4 Implement session-level warning cache to avoid repeating same validation warnings\n- [x] 3.5 Implement proper ordering: `<context>` → `<rules>` → `<template>`\n- [x] 3.6 Preserve multi-line strings and special characters without escaping\n- [x] 3.7 Add unit tests for context injection (present, absent, multi-line, special chars)\n- [x] 3.8 Add unit tests for rules injection (matching artifact, non-matching, empty array, multiple artifacts)\n- [x] 3.9 Add unit tests for validation timing (warnings during instruction load, not config load)\n- [x] 3.10 Add unit tests for warning deduplication (same warning shown once per session)\n- [x] 3.11 Add integration test verifying full instruction output with context + rules + template\n\n## 4. Interactive Config Creation\n\n- [x] 4.1 Add @inquirer/prompts dependency to package.json\n- [x] 4.2 Create `src/core/config-prompts.ts` with ConfigPromptResult interface\n- [x] 4.3 Implement `promptForConfig()` function with schema selection prompt\n- [x] 4.4 Add multi-line context input prompt with examples and skip option\n- [x] 4.5 Add per-artifact rules prompts with checkbox selection and line-by-line input\n- [x] 4.6 Implement YAML serialization with proper multi-line string formatting\n- [x] 4.7 Add validation and retry logic for prompt errors\n\n## 5. Experimental Setup Integration\n\n- [x] 5.1 Update `artifactExperimentalSetupCommand()` in `src/commands/artifact-workflow.ts` to check for existing config\n- [x] 5.2 Add config creation section after skills/commands creation with header and description\n- [x] 5.3 Integrate `promptForConfig()` calls with proper flow control\n- [x] 5.4 Add Ctrl+C (ExitPromptError) handling - log cancellation message, continue with setup (non-fatal)\n- [x] 5.5 Write created config to `openspec/config.yaml` using YAML stringify\n- [x] 5.6 Display success summary showing path, schema, context lines, rules count\n- [x] 5.7 Show usage examples and git commit suggestion\n- [x] 5.8 Handle existing config case with skip message and manual update instructions\n- [x] 5.9 Add error handling for file write failures with fallback suggestions\n- [x] 5.10 Add test for cancellation behavior (skills/commands preserved, config not created)\n\n## 6. Testing and Documentation\n\n- [x] 6.1 Add end-to-end test: run experimental setup → create config → create change → verify schema used\n- [x] 6.2 Add end-to-end test: create config → get instructions → verify context and rules injected\n- [x] 6.3 Test backwards compatibility: existing changes work without config\n- [x] 6.4 Test config changes are reflected immediately (no stale cache)\n- [x] 6.5 Add performance benchmark: measure config read time with typical config (1KB context)\n- [x] 6.6 Add performance benchmark: measure config read time with large config (50KB context)\n- [x] 6.7 Add performance benchmark: measure repeated reads within single command\n- [x] 6.8 Document benchmark results and decide if caching is needed (target: <10ms typical, <50ms acceptable)\n- [x] 6.9 If benchmarks fail: implement mtime-based caching with cache invalidation\n- [x] 6.10 Update README or docs with config feature examples and schema\n- [x] 6.11 Document common artifact IDs for different schemas\n- [x] 6.12 Add troubleshooting section for config validation errors\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-local-schemas/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: \"2025-01-13\"\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-local-schemas/design.md",
    "content": "## Context\n\nOpenSpec currently resolves schemas from two locations:\n1. User override: `~/.local/share/openspec/schemas/<name>/`\n2. Package built-in: `<npm-package>/schemas/<name>/`\n\nThis change adds a third, highest-priority level: project-local schemas at `./openspec/schemas/<name>/`.\n\nThe resolver functions in `src/core/artifact-graph/resolver.ts` currently don't take a `projectRoot` parameter because user and package paths are absolute. To support project-local schemas, we need to pass project root context into the resolver.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Enable version-controlled custom workflow schemas\n- Allow teams to share schemas via git without per-machine setup\n- Maintain backward compatibility with existing resolver API\n- Integrate with `config.yaml`'s `schema` field (from project-config change)\n\n**Non-Goals:**\n- Schema inheritance or `extends` keyword\n- Template-level overrides (partial forks)\n- Schema management CLI commands (`openspec schema copy/which/diff/reset`)\n- Validation that project-local schema names don't conflict with built-ins (shadowing is intentional)\n\n## Decisions\n\n### Decision 1: Add optional `projectRoot` parameter to resolver functions\n\n**Choice:** Add optional `projectRoot?: string` parameter to resolver functions rather than using `process.cwd()` internally.\n\n**Alternatives considered:**\n- Use `process.cwd()` internally: Simpler API but implicit, harder to test, doesn't match existing codebase patterns\n- Create separate project-aware functions: No breaking changes but awkward API, callers must compose\n\n**Rationale:** The codebase already follows a pattern where CLI commands get project root via `process.cwd()` and pass it down to functions that need it. Adding an optional parameter maintains backward compatibility while enabling explicit, testable behavior.\n\n**Affected functions:**\n```typescript\ngetSchemaDir(name: string, projectRoot?: string): string | null\nlistSchemas(projectRoot?: string): string[]\nlistSchemasWithInfo(projectRoot?: string): SchemaInfo[]\nresolveSchema(name: string, projectRoot?: string): SchemaYaml\n```\n\n### Decision 2: Resolution order is project → user → package\n\n**Choice:** Project-local schemas have highest priority, then user overrides, then package built-ins.\n\n**Rationale:**\n- Project-local should win because it represents team intent (version controlled, shared)\n- User overrides still useful for personal experimentation without affecting team\n- Package built-ins are the fallback defaults\n\n```\n1. ./openspec/schemas/<name>/              # Project-local (highest)\n2. ~/.local/share/openspec/schemas/<name>/ # User override\n3. <npm-package>/schemas/<name>/           # Package built-in (lowest)\n```\n\n### Decision 3: Add `getProjectSchemasDir()` helper function\n\n**Choice:** Create a dedicated function to get the project schemas directory path.\n\n```typescript\nfunction getProjectSchemasDir(projectRoot: string): string {\n  return path.join(projectRoot, 'openspec', 'schemas');\n}\n```\n\n**Rationale:** Matches existing pattern with `getPackageSchemasDir()` and `getUserSchemasDir()`. Keeps path logic centralized.\n\n### Decision 4: Extend `SchemaInfo.source` to include `'project'`\n\n**Choice:** Update the source type from `'package' | 'user'` to `'project' | 'user' | 'package'`.\n\n**Rationale:** Consumers need to distinguish project-local schemas for display purposes (e.g., `schemasCommand` output).\n\n### Decision 5: No special handling for schema name conflicts\n\n**Choice:** If a project-local schema has the same name as a built-in (e.g., `spec-driven`), the project-local version wins. No warning, no error.\n\n**Rationale:** This is intentional shadowing. Teams may want to customize a built-in schema while keeping the same name for familiarity.\n\n## Risks / Trade-offs\n\n### Risk: Confusion when project schema shadows built-in\nA team could create `openspec/schemas/spec-driven/` that shadows the built-in, causing confusion when someone expects default behavior.\n\n**Mitigation:** The `openspec schemas` command shows the source of each schema. Users can see `spec-driven (project)` vs `spec-driven (package)`.\n\n### Risk: Missing projectRoot parameter\nIf callers forget to pass `projectRoot`, project-local schemas won't be found.\n\n**Mitigation:**\n- Make the change incrementally, updating call sites that need project-local support\n- Existing behavior (user + package only) is preserved when `projectRoot` is undefined\n\n### Trade-off: Optional parameter vs required\nMaking `projectRoot` optional maintains backward compatibility but means some code paths may silently skip project-local resolution.\n\n**Accepted:** Backward compatibility is more important. The main entry points (CLI commands) will always pass `projectRoot`.\n\n## Implementation Approach\n\n1. **Update `resolver.ts`:**\n   - Add `getProjectSchemasDir(projectRoot: string)` function\n   - Update `getSchemaDir()` to check project-local first when `projectRoot` provided\n   - Update `listSchemas()` to include project schemas when `projectRoot` provided\n   - Update `listSchemasWithInfo()` to return `source: 'project'` for project schemas\n   - Update `SchemaInfo` type to include `'project'` in source union\n\n2. **Update `artifact-workflow.ts`:**\n   - Update `schemasCommand` to pass `projectRoot` and display source labels\n\n3. **Update call sites:**\n   - Any existing code that needs project-local resolution should pass `projectRoot`\n   - `config.yaml` schema resolution already has access to `projectRoot`\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-local-schemas/proposal.md",
    "content": "# Project-Local Schemas\n\n## Summary\n\nAdd project-local schema resolution (`./openspec/schemas/`) as the highest priority in the schema lookup chain. This enables teams to version control custom workflow schemas with their repository.\n\n## Motivation\n\nCurrently, schema resolution is 2-level:\n1. User override: `~/.local/share/openspec/schemas/<name>/`\n2. Package built-in: `<npm-package>/schemas/<name>/`\n\nThis creates friction for teams:\n- Custom schemas must be set up per-machine via XDG paths\n- Cannot share schemas via version control\n- No single source of truth for team workflows\n\n## Design Decisions\n\n### 3-Level Resolution Order\n\n```\n1. ./openspec/schemas/<name>/                    # Project-local (NEW)\n2. ~/.local/share/openspec/schemas/<name>/       # User global (XDG)\n3. <npm-package>/schemas/<name>/                 # Package built-in\n```\n\nProject-local takes highest priority, enabling:\n- Version-controlled custom workflows\n- Automatic team sharing via git\n- No per-machine setup required\n\n### Fork Model (Not Inheritance)\n\nCustom schemas are complete definitions, not extensions. There is no `extends` keyword.\n\n**Rationale:** Simplicity. Inheritance adds complexity (conflict resolution, partial overrides, debugging \"where did this come from?\"). Users who need custom workflows can define them fully. This keeps the mental model simple:\n- Use a preset → Configure path (see project-config change)\n- Need different structure → Fork path (define your own)\n\n### Directory Structure\n\n```\nopenspec/\n├── schemas/                      # Project-local schemas\n│   └── my-workflow/\n│       ├── schema.yaml           # Full schema definition\n│       └── templates/\n│           ├── artifact1.md\n│           ├── artifact2.md\n│           └── ...\n└── changes/\n```\n\n### Schema Naming\n\nProject-local schemas are referenced by their directory name:\n- `openspec/schemas/my-workflow/` → referenced as `my-workflow`\n- Works with `--schema my-workflow` flag\n- Works with `schema: my-workflow` in config.yaml (see project-config change)\n\n## Scope\n\n### In Scope\n\n- Add `getProjectSchemasDir()` function to resolver\n- Update `getSchemaDir()` to check project-local first\n- Update `listSchemas()` to include project schemas\n- Update `listSchemasWithInfo()` to include `source: 'project'`\n- Update `schemasCommand` output to show project schemas\n\n### Out of Scope\n\n- Schema management CLI (`openspec schema copy/which/diff/reset`) - future enhancement\n- Schema inheritance/extends - explicitly not supported\n- Template-level overrides (partial fork) - explicitly not supported\n\n## User Experience\n\n### Creating a Custom Schema\n\n```bash\n# Create schema directory\nmkdir -p openspec/schemas/my-workflow/templates\n\n# Define schema\ncat > openspec/schemas/my-workflow/schema.yaml << 'EOF'\nname: my-workflow\nversion: 1\ndescription: Our team's planning workflow\n\nartifacts:\n  - id: research\n    generates: research.md\n    template: research.md\n    description: Background research\n    requires: []\n\n  - id: proposal\n    generates: proposal.md\n    template: proposal.md\n    description: Change proposal\n    requires: [research]\n\n  - id: tasks\n    generates: tasks.md\n    template: tasks.md\n    description: Implementation tasks\n    requires: [proposal]\nEOF\n\n# Create templates\necho \"# Research\\n\\n...\" > openspec/schemas/my-workflow/templates/research.md\n# ... etc\n```\n\n### Using the Custom Schema\n\n```bash\n# Via CLI flag\nopenspec new change add-feature --schema my-workflow\nopenspec status --change add-feature --schema my-workflow\n\n# Via config.yaml (requires project-config change)\n# schema: my-workflow\n```\n\n### Team Sharing\n\n```bash\n# Commit to repo\ngit add openspec/schemas/\ngit commit -m \"Add custom workflow schema\"\ngit push\n\n# Team members get it automatically\ngit pull\nopenspec status --change add-feature --schema my-workflow  # Just works\n```\n\n## Implementation Notes\n\n### Files to Modify\n\n| File | Changes |\n|------|---------|\n| `src/core/artifact-graph/resolver.ts` | Add `getProjectSchemasDir()`, update resolution order |\n| `src/commands/artifact-workflow.ts` | Update `schemasCommand` to show source |\n\n### Project Root Detection\n\nUse existing `findProjectRoot()` pattern or current working directory. The project-local schemas directory is always `./openspec/schemas/` relative to project root.\n\n### Source Indication\n\n`listSchemasWithInfo()` returns `source: 'project' | 'user' | 'package'`. Update type definition and implementation.\n\n## Testing Considerations\n\n- Create temp project with local schema, verify resolution priority\n- Verify local schema overrides user override with same name\n- Verify `listSchemas()` includes project schemas\n- Verify `schemasCommand` shows correct source labels\n\n## Related Changes\n\n- **project-config**: Adds `config.yaml` with `schema` field that can reference project-local schemas\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-local-schemas/specs/schema-resolution/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Project-local schema resolution\n\nThe system SHALL resolve schemas from the project-local directory (`./openspec/schemas/<name>/`) with highest priority when a `projectRoot` is provided.\n\n#### Scenario: Project-local schema takes precedence over user override\n- **WHEN** a schema named \"my-workflow\" exists at `./openspec/schemas/my-workflow/schema.yaml`\n- **AND** a schema named \"my-workflow\" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml`\n- **AND** `getSchemaDir(\"my-workflow\", projectRoot)` is called\n- **THEN** the system SHALL return the project-local path\n\n#### Scenario: Project-local schema takes precedence over package built-in\n- **WHEN** a schema named \"spec-driven\" exists at `./openspec/schemas/spec-driven/schema.yaml`\n- **AND** \"spec-driven\" is a package built-in schema\n- **AND** `getSchemaDir(\"spec-driven\", projectRoot)` is called\n- **THEN** the system SHALL return the project-local path\n\n#### Scenario: Falls back to user override when no project-local schema\n- **WHEN** no schema named \"my-workflow\" exists at `./openspec/schemas/my-workflow/`\n- **AND** a schema named \"my-workflow\" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml`\n- **AND** `getSchemaDir(\"my-workflow\", projectRoot)` is called\n- **THEN** the system SHALL return the user override path\n\n#### Scenario: Falls back to package built-in when no project-local or user schema\n- **WHEN** no schema named \"spec-driven\" exists at `./openspec/schemas/spec-driven/`\n- **AND** no schema named \"spec-driven\" exists at `~/.local/share/openspec/schemas/spec-driven/`\n- **AND** \"spec-driven\" is a package built-in schema\n- **AND** `getSchemaDir(\"spec-driven\", projectRoot)` is called\n- **THEN** the system SHALL return the package built-in path\n\n#### Scenario: Backward compatibility when projectRoot not provided\n- **WHEN** `getSchemaDir(\"my-workflow\")` is called without a `projectRoot` parameter\n- **THEN** the system SHALL only check user override and package built-in locations\n- **AND** the system SHALL NOT check project-local location\n\n### Requirement: Project schemas directory helper\n\nThe system SHALL provide a `getProjectSchemasDir(projectRoot)` function that returns the project-local schemas directory path.\n\n#### Scenario: Returns correct path\n- **WHEN** `getProjectSchemasDir(\"/path/to/project\")` is called\n- **THEN** the system SHALL return `/path/to/project/openspec/schemas`\n\n### Requirement: List schemas includes project-local\n\nThe system SHALL include project-local schemas when listing available schemas if `projectRoot` is provided.\n\n#### Scenario: Project-local schemas appear in list\n- **WHEN** a schema named \"team-flow\" exists at `./openspec/schemas/team-flow/schema.yaml`\n- **AND** `listSchemas(projectRoot)` is called\n- **THEN** the returned list SHALL include \"team-flow\"\n\n#### Scenario: Project-local schema shadows same-named user schema in list\n- **WHEN** a schema named \"custom\" exists at both project-local and user override locations\n- **AND** `listSchemas(projectRoot)` is called\n- **THEN** the returned list SHALL include \"custom\" exactly once\n\n#### Scenario: Backward compatibility for listSchemas\n- **WHEN** `listSchemas()` is called without a `projectRoot` parameter\n- **THEN** the system SHALL only include user override and package built-in schemas\n\n### Requirement: Schema info includes project source\n\nThe system SHALL indicate `source: 'project'` for project-local schemas in `listSchemasWithInfo()` results.\n\n#### Scenario: Project-local schema shows project source\n- **WHEN** a schema named \"team-flow\" exists at `./openspec/schemas/team-flow/schema.yaml`\n- **AND** `listSchemasWithInfo(projectRoot)` is called\n- **THEN** the schema info for \"team-flow\" SHALL have `source: 'project'`\n\n#### Scenario: User override schema shows user source\n- **WHEN** a schema named \"my-custom\" exists only at `~/.local/share/openspec/schemas/my-custom/`\n- **AND** `listSchemasWithInfo(projectRoot)` is called\n- **THEN** the schema info for \"my-custom\" SHALL have `source: 'user'`\n\n#### Scenario: Package built-in schema shows package source\n- **WHEN** \"spec-driven\" exists only as a package built-in\n- **AND** `listSchemasWithInfo(projectRoot)` is called\n- **THEN** the schema info for \"spec-driven\" SHALL have `source: 'package'`\n\n### Requirement: Schemas command shows source\n\nThe `openspec schemas` command SHALL display the source of each schema.\n\n#### Scenario: Display format includes source\n- **WHEN** user runs `openspec schemas`\n- **THEN** the output SHALL show each schema with its source label (project, user, or package)\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-project-local-schemas/tasks.md",
    "content": "## 1. Update Resolver Types and Helpers\n\n- [x] 1.1 Update `SchemaInfo.source` type to include `'project'` in `src/core/artifact-graph/resolver.ts`\n- [x] 1.2 Add `getProjectSchemasDir(projectRoot: string): string` function\n\n## 2. Update Schema Resolution Functions\n\n- [x] 2.1 Update `getSchemaDir(name, projectRoot?)` to check project-local first when projectRoot provided\n- [x] 2.2 Update `resolveSchema(name, projectRoot?)` to pass projectRoot to getSchemaDir\n- [x] 2.3 Update `listSchemas(projectRoot?)` to include project-local schemas\n- [x] 2.4 Update `listSchemasWithInfo(projectRoot?)` to include project schemas with `source: 'project'`\n\n## 3. Update CLI Commands\n\n- [x] 3.1 Update `schemasCommand` to pass projectRoot and display source labels in output\n\n## 4. Update Call Sites\n\n- [x] 4.1 Review and update call sites that need project-local schema support to pass projectRoot\n\n## 5. Testing\n\n- [x] 5.1 Add unit tests for `getProjectSchemasDir()`\n- [x] 5.2 Add unit tests for project-local schema resolution priority\n- [x] 5.3 Add unit tests for backward compatibility (no projectRoot = user + package only)\n- [x] 5.4 Add unit tests for `listSchemas()` including project schemas\n- [x] 5.5 Add unit tests for `listSchemasWithInfo()` with `source: 'project'`\n- [x] 5.6 Add integration test with temp project containing local schema\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-schema-management-cli/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-01-20\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-schema-management-cli/design.md",
    "content": "## Context\n\nOpenSpec uses workflow schemas to define artifact sequences for change proposals. Currently, schemas are resolved from three locations (project → user → package), but managing custom schemas requires manual file creation with no tooling support. The resolver infrastructure exists (`src/core/artifact-graph/resolver.ts`) but there's no CLI exposure for schema management operations.\n\nUsers who want to customize workflows must:\n1. Manually create directory structures under `openspec/schemas/<name>/`\n2. Copy and modify `schema.yaml` files without validation\n3. Debug resolution issues by inspecting the filesystem directly\n\nThis creates friction for schema customization and leads to runtime errors when schemas are malformed.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Provide CLI commands for common schema management operations\n- Enable interactive schema creation with guided prompts\n- Allow forking existing schemas as customization starting points\n- Surface schema validation errors before runtime\n- Help debug schema resolution order when shadowing occurs\n\n**Non-Goals:**\n- Schema editing (users edit YAML directly or via `$EDITOR`)\n- Schema publishing or sharing mechanisms\n- Schema versioning or migration tooling\n- Validation of template file contents (only checks existence)\n- Schema inheritance or composition beyond simple forking\n\n## Decisions\n\n### 1. Command Structure: `openspec schema <subcommand>`\n\nAdd a new command group following the existing pattern used by `openspec config` and `openspec completion`.\n\n**Rationale:** Grouping related commands under a noun (schema) matches the established CLI patterns and provides a natural namespace for future schema operations.\n\n**Alternatives considered:**\n- Flat commands (`openspec schema-init`, `openspec schema-fork`): Rejected because it pollutes the top-level namespace and doesn't scale well.\n- Extending existing commands (`openspec init --schema`): Rejected because schema management is distinct from project initialization.\n\n### 2. Implementation Location\n\nNew file `src/commands/schema.ts` with a `registerSchemaCommand(program: Command)` function that registers the `schema` command group and all subcommands.\n\n**Rationale:** Follows the pattern established by `config.ts` and matches how other command groups are organized.\n\n### 3. Schema Validation Approach\n\nValidation checks:\n1. `schema.yaml` exists and is valid YAML\n2. Parses successfully against the Zod schema in `types.ts`\n3. All referenced template files exist in the schema directory\n4. Artifact dependency graph has no cycles (use existing topological sort)\n\n**Rationale:** Reuse existing validation infrastructure (`parseSchema` from `schema.ts`) and extend with template existence checks. This catches the most common errors without duplicating validation logic.\n\n**Alternatives considered:**\n- Deep template validation (check frontmatter, syntax): Rejected as over-engineering. Template contents are free-form markdown.\n\n### 4. Interactive Prompts for `schema init`\n\nUse `@inquirer/prompts` (already a dependency) for:\n- Schema name input with kebab-case validation\n- Schema description input\n- Multi-select for artifact selection with descriptions\n- Optional: set as project default\n\n**Rationale:** Matches the UX established by `openspec init` and `openspec config reset`. Provides a guided experience while keeping the wizard lightweight.\n\n### 5. Fork Source Resolution\n\n`schema fork <source>` resolves the source schema using the existing `getSchemaDir()` function, respecting the full resolution order (project → user → package). This allows forking from any accessible schema.\n\nThe destination is always project-local: `openspec/schemas/<name>/`\n\n**Rationale:** Forking to project scope makes sense because:\n- Custom schemas are project-specific decisions\n- User-global schemas can be added manually if needed\n- Keeps the command simple with a clear default\n\n### 6. Output Format Consistency\n\nAll commands support `--json` flag for machine-readable output:\n- `schema init`: Outputs `{ \"created\": true, \"path\": \"...\", \"schema\": \"...\" }`\n- `schema fork`: Outputs `{ \"forked\": true, \"source\": \"...\", \"destination\": \"...\" }`\n- `schema validate`: Outputs validation report matching existing validate command format\n- `schema which`: Outputs `{ \"name\": \"...\", \"source\": \"project|user|package\", \"path\": \"...\" }`\n\nText output uses ora spinners for progress and clear success/error messaging.\n\n**Rationale:** Consistent with existing OpenSpec commands and enables scripting/automation.\n\n### 7. Schema `which` Command Design\n\nShows resolution details for a schema name:\n- Which location it resolves from (project/user/package)\n- Full path to the schema directory\n- Whether it shadows other schemas at lower priority levels\n\n**Rationale:** Essential for debugging \"why isn't my schema being used?\" scenarios when multiple schemas with the same name exist.\n\n## Risks / Trade-offs\n\n**[Template scaffolding may become stale]** → The `schema init` command will scaffold a default set of artifacts (proposal, specs, design, tasks). If the built-in schema patterns evolve, these templates may not reflect best practices.\n- *Mitigation*: Document that `init` creates a minimal starting point. Users can `fork` built-in schemas for the latest patterns.\n\n**[Interactive prompts in CI environments]** → `schema init` with prompts may hang in non-interactive environments.\n- *Mitigation*: Support `--name`, `--description`, and `--artifacts` flags for non-interactive use. Detect TTY and show helpful error if prompts would hang.\n\n**[Validation doesn't catch all errors]** → Schema validation checks structure but can't verify semantic correctness (e.g., a template that doesn't match its artifact purpose).\n- *Mitigation*: This is acceptable. Full semantic validation would require understanding template intent, which is out of scope.\n\n**[Fork overwrites without warning]** → If target schema already exists, `fork` could overwrite it.\n- *Mitigation*: Check for existing schema and require `--force` flag or interactive confirmation before overwriting.\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-schema-management-cli/proposal.md",
    "content": "## Why\n\nCreating and managing project-local schemas currently requires manual directory creation, copying files, and hoping the structure is correct. Users only discover structural errors at runtime when commands fail. This friction discourages schema customization and makes it harder to tailor OpenSpec workflows to specific project needs.\n\nKey pain points:\n- **Manual scaffolding**: Users must manually create `openspec/schemas/<name>/` with correct structure\n- **No validation feedback**: Schema errors aren't caught until a command tries to use the schema\n- **Starting from scratch is hard**: No easy way to base a custom schema on an existing one\n- **Debugging resolution**: When a schema doesn't resolve as expected, there's no way to see the resolution path\n\n## What Changes\n\nAdd a new `openspec schema` command group with subcommands for creating, forking, validating, and inspecting schemas.\n\n### Commands\n\n1. **`openspec schema init <name>`** - Interactive wizard to scaffold a new project schema\n   - Prompts for schema description\n   - Prompts for artifacts to include (with explanations)\n   - Creates valid directory structure with `schema.yaml` and template files\n   - Optionally sets as project default in `openspec/config.yaml`\n\n2. **`openspec schema fork <source> [name]`** - Copy an existing schema as a starting point\n   - Copies from user override or package built-in\n   - Allows renaming (defaults to `<source>-custom`)\n   - Preserves all templates and configuration\n\n3. **`openspec schema validate [name]`** - Validate schema structure and templates\n   - Checks `schema.yaml` is valid\n   - Verifies all referenced templates exist\n   - Reports missing or malformed files\n   - Run without name to validate all project schemas\n\n4. **`openspec schema which <name>`** - Show schema resolution path\n   - Displays which location the schema resolves from (project/user/package)\n   - Shows full path to schema directory\n   - Useful for debugging shadowing issues\n\n## Capabilities\n\n### New Capabilities\n- `schema-init-command`: Interactive wizard for creating new project schemas with guided prompts\n- `schema-fork-command`: Copy existing schemas to project for customization\n- `schema-validate-command`: Validate schema structure and report errors before runtime\n- `schema-which-command`: Debug schema resolution by showing which location is used\n\n### Modified Capabilities\n<!-- None - these are additive commands -->\n\n## Impact\n\n- **Code**: New command implementations in `src/commands/` using existing resolver infrastructure\n- **CLI**: New `schema` command group with 4 subcommands\n- **Dependencies**: May use `enquirer` or similar for interactive prompts in `schema init`\n- **Documentation**: Need to update CLI reference and schema customization guide\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-schema-management-cli/specs/schema-fork-command/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Schema fork copies existing schema\nThe CLI SHALL provide an `openspec schema fork <source> [name]` command that copies an existing schema to the project's `openspec/schemas/` directory.\n\n#### Scenario: Fork with explicit name\n- **WHEN** user runs `openspec schema fork spec-driven my-custom`\n- **THEN** system locates `spec-driven` schema using resolution order (project → user → package)\n- **AND** copies all files to `openspec/schemas/my-custom/`\n- **AND** updates `name` field in `schema.yaml` to `my-custom`\n- **AND** displays success message with source and destination paths\n\n#### Scenario: Fork with default name\n- **WHEN** user runs `openspec schema fork spec-driven` without specifying a name\n- **THEN** system copies to `openspec/schemas/spec-driven-custom/`\n- **AND** updates `name` field in `schema.yaml` to `spec-driven-custom`\n\n#### Scenario: Source schema not found\n- **WHEN** user runs `openspec schema fork nonexistent`\n- **THEN** system displays error that schema was not found\n- **AND** lists available schemas\n- **AND** exits with non-zero code\n\n### Requirement: Schema fork prevents accidental overwrites\nThe CLI SHALL require confirmation or `--force` flag when the destination schema already exists.\n\n#### Scenario: Destination exists without force\n- **WHEN** user runs `openspec schema fork spec-driven my-custom` and `openspec/schemas/my-custom/` exists\n- **THEN** system displays error that destination already exists\n- **AND** suggests using `--force` to overwrite\n- **AND** exits with non-zero code\n\n#### Scenario: Destination exists with force flag\n- **WHEN** user runs `openspec schema fork spec-driven my-custom --force` and destination exists\n- **THEN** system removes existing destination directory\n- **AND** copies source schema to destination\n- **AND** displays success message\n\n#### Scenario: Interactive confirmation for overwrite\n- **WHEN** user runs `openspec schema fork spec-driven my-custom` in interactive mode and destination exists\n- **THEN** system prompts for confirmation to overwrite\n- **AND** proceeds based on user response\n\n### Requirement: Schema fork preserves all schema files\nThe CLI SHALL copy the complete schema directory including templates, configuration, and any additional files.\n\n#### Scenario: Copy includes template files\n- **WHEN** user forks a schema with template files (e.g., `proposal.md`, `design.md`)\n- **THEN** all template files are copied to the destination\n- **AND** template file contents are unchanged\n\n#### Scenario: Copy includes nested directories\n- **WHEN** user forks a schema with nested directories (e.g., `templates/specs/`)\n- **THEN** nested directory structure is preserved\n- **AND** all nested files are copied\n\n### Requirement: Schema fork outputs JSON format\nThe CLI SHALL support `--json` flag for machine-readable output.\n\n#### Scenario: JSON output on success\n- **WHEN** user runs `openspec schema fork spec-driven my-custom --json`\n- **THEN** system outputs JSON with `forked: true`, `source`, `destination`, and `sourcePath` fields\n\n#### Scenario: JSON output shows source location\n- **WHEN** user runs `openspec schema fork spec-driven --json`\n- **THEN** JSON output includes `sourceLocation` field indicating \"project\", \"user\", or \"package\"\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-schema-management-cli/specs/schema-init-command/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Schema init command creates project-local schema\nThe 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.\n\n#### Scenario: Create schema with valid name\n- **WHEN** user runs `openspec schema init my-workflow`\n- **THEN** system creates directory `openspec/schemas/my-workflow/`\n- **AND** creates `schema.yaml` with name, version, description, and artifacts array\n- **AND** creates template files referenced by artifacts\n- **AND** displays success message with created path\n\n#### Scenario: Reject invalid schema name\n- **WHEN** user runs `openspec schema init \"My Workflow\"` (contains space)\n- **THEN** system displays error about invalid schema name\n- **AND** suggests using kebab-case format\n- **AND** exits with non-zero code\n\n#### Scenario: Schema name already exists\n- **WHEN** user runs `openspec schema init existing-schema` and `openspec/schemas/existing-schema/` already exists\n- **THEN** system displays error that schema already exists\n- **AND** suggests using `--force` to overwrite or `schema fork` to copy\n- **AND** exits with non-zero code\n\n### Requirement: Schema init supports interactive mode\nThe CLI SHALL prompt for schema configuration when run in an interactive terminal without explicit flags.\n\n#### Scenario: Interactive prompts for description\n- **WHEN** user runs `openspec schema init my-workflow` in an interactive terminal\n- **THEN** system prompts for schema description\n- **AND** uses provided description in generated `schema.yaml`\n\n#### Scenario: Interactive prompts for artifact selection\n- **WHEN** user runs `openspec schema init my-workflow` in an interactive terminal\n- **THEN** system displays multi-select prompt with common artifacts (proposal, specs, design, tasks)\n- **AND** each option includes a brief description\n- **AND** uses selected artifacts in generated `schema.yaml`\n\n#### Scenario: Non-interactive mode with flags\n- **WHEN** user runs `openspec schema init my-workflow --description \"My workflow\" --artifacts proposal,tasks`\n- **THEN** system creates schema without prompting\n- **AND** uses flag values for configuration\n\n### Requirement: Schema init supports setting project default\nThe CLI SHALL offer to set the newly created schema as the project default.\n\n#### Scenario: Set as default interactively\n- **WHEN** user runs `openspec schema init my-workflow` in interactive mode\n- **AND** user confirms setting as default\n- **THEN** system updates `openspec/config.yaml` with `defaultSchema: my-workflow`\n\n#### Scenario: Set as default via flag\n- **WHEN** user runs `openspec schema init my-workflow --default`\n- **THEN** system creates schema and updates `openspec/config.yaml` with `defaultSchema: my-workflow`\n\n#### Scenario: Skip setting default\n- **WHEN** user runs `openspec schema init my-workflow --no-default`\n- **THEN** system creates schema without modifying `openspec/config.yaml`\n\n### Requirement: Schema init outputs JSON format\nThe CLI SHALL support `--json` flag for machine-readable output.\n\n#### Scenario: JSON output on success\n- **WHEN** user runs `openspec schema init my-workflow --json --description \"Test\" --artifacts proposal`\n- **THEN** system outputs JSON with `created: true`, `path`, and `schema` fields\n- **AND** does not display interactive prompts or spinners\n\n#### Scenario: JSON output on error\n- **WHEN** user runs `openspec schema init \"invalid name\" --json`\n- **THEN** system outputs JSON with `error` field describing the issue\n- **AND** exits with non-zero code\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-schema-management-cli/specs/schema-validate-command/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Schema validate checks schema structure\nThe CLI SHALL provide an `openspec schema validate [name]` command that validates schema configuration and reports errors.\n\n#### Scenario: Validate specific schema\n- **WHEN** user runs `openspec schema validate my-workflow`\n- **THEN** system locates schema using resolution order\n- **AND** validates `schema.yaml` against the schema Zod type\n- **AND** displays validation result (valid or list of errors)\n\n#### Scenario: Validate all project schemas\n- **WHEN** user runs `openspec schema validate` without a name\n- **THEN** system validates all schemas in `openspec/schemas/`\n- **AND** displays results for each schema\n- **AND** exits with non-zero code if any schema is invalid\n\n#### Scenario: Schema not found\n- **WHEN** user runs `openspec schema validate nonexistent`\n- **THEN** system displays error that schema was not found\n- **AND** exits with non-zero code\n\n### Requirement: Schema validate checks YAML syntax\nThe CLI SHALL report YAML parsing errors with line numbers when possible.\n\n#### Scenario: Invalid YAML syntax\n- **WHEN** user runs `openspec schema validate my-workflow` and `schema.yaml` has syntax errors\n- **THEN** system displays YAML parse error with line number\n- **AND** exits with non-zero code\n\n#### Scenario: Valid YAML but missing required fields\n- **WHEN** `schema.yaml` is valid YAML but missing `name` field\n- **THEN** system displays Zod validation error for missing required field\n- **AND** identifies the specific missing field\n\n### Requirement: Schema validate checks template existence\nThe CLI SHALL verify that all template files referenced by artifacts exist.\n\n#### Scenario: Missing template file\n- **WHEN** artifact references `template: proposal.md` but file doesn't exist in schema directory\n- **THEN** system reports error: \"Template file 'proposal.md' not found for artifact 'proposal'\"\n- **AND** exits with non-zero code\n\n#### Scenario: All templates exist\n- **WHEN** all artifact templates exist\n- **THEN** system reports that templates are valid\n- **AND** template existence is included in validation summary\n\n### Requirement: Schema validate checks dependency graph\nThe CLI SHALL verify that artifact dependencies form a valid directed acyclic graph.\n\n#### Scenario: Valid dependency graph\n- **WHEN** artifact dependencies form a valid DAG (e.g., tasks → specs → proposal)\n- **THEN** system reports dependency graph is valid\n\n#### Scenario: Circular dependency detected\n- **WHEN** artifact A requires B and artifact B requires A\n- **THEN** system reports circular dependency error\n- **AND** identifies the artifacts involved in the cycle\n- **AND** exits with non-zero code\n\n#### Scenario: Unknown dependency reference\n- **WHEN** artifact requires `nonexistent-artifact`\n- **THEN** system reports error: \"Artifact 'x' requires unknown artifact 'nonexistent-artifact'\"\n- **AND** exits with non-zero code\n\n### Requirement: Schema validate outputs JSON format\nThe CLI SHALL support `--json` flag for machine-readable validation results.\n\n#### Scenario: JSON output for valid schema\n- **WHEN** user runs `openspec schema validate my-workflow --json` and schema is valid\n- **THEN** system outputs JSON with `valid: true`, `name`, and `path` fields\n\n#### Scenario: JSON output for invalid schema\n- **WHEN** user runs `openspec schema validate my-workflow --json` and schema has errors\n- **THEN** system outputs JSON with `valid: false` and `issues` array\n- **AND** each issue includes `level`, `path`, and `message` fields\n- **AND** format matches existing `openspec validate` output structure\n\n### Requirement: Schema validate supports verbose mode\nThe CLI SHALL support `--verbose` flag for detailed validation information.\n\n#### Scenario: Verbose output shows all checks\n- **WHEN** user runs `openspec schema validate my-workflow --verbose`\n- **THEN** system displays each validation check as it runs\n- **AND** shows pass/fail status for: YAML parsing, Zod validation, template existence, dependency graph\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-schema-management-cli/specs/schema-which-command/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Schema which shows resolution result\nThe CLI SHALL provide an `openspec schema which <name>` command that displays where a schema resolves from.\n\n#### Scenario: Schema resolves from project\n- **WHEN** user runs `openspec schema which my-workflow` and schema exists in `openspec/schemas/my-workflow/`\n- **THEN** system displays source as \"project\"\n- **AND** displays full path to schema directory\n\n#### Scenario: Schema resolves from user directory\n- **WHEN** user runs `openspec schema which my-workflow` and schema exists only in user data directory\n- **THEN** system displays source as \"user\"\n- **AND** displays full path including XDG data directory\n\n#### Scenario: Schema resolves from package\n- **WHEN** user runs `openspec schema which spec-driven` and no override exists\n- **THEN** system displays source as \"package\"\n- **AND** displays full path to package's schemas directory\n\n#### Scenario: Schema not found\n- **WHEN** user runs `openspec schema which nonexistent`\n- **THEN** system displays error that schema was not found\n- **AND** lists available schemas\n- **AND** exits with non-zero code\n\n### Requirement: Schema which shows shadowing information\nThe CLI SHALL indicate when a schema shadows another schema at a lower priority level.\n\n#### Scenario: Project schema shadows package\n- **WHEN** user runs `openspec schema which spec-driven` and both project and package have `spec-driven`\n- **THEN** system displays that project schema is active\n- **AND** indicates it shadows the package version\n- **AND** shows path to shadowed package schema\n\n#### Scenario: No shadowing\n- **WHEN** schema exists only in one location\n- **THEN** system does not display shadowing information\n\n#### Scenario: Multiple shadows\n- **WHEN** project schema shadows both user and package schemas\n- **THEN** system lists all shadowed locations in priority order\n\n### Requirement: Schema which outputs JSON format\nThe CLI SHALL support `--json` flag for machine-readable output.\n\n#### Scenario: JSON output basic\n- **WHEN** user runs `openspec schema which spec-driven --json`\n- **THEN** system outputs JSON with `name`, `source`, and `path` fields\n\n#### Scenario: JSON output with shadows\n- **WHEN** user runs `openspec schema which spec-driven --json` and schema has shadows\n- **THEN** JSON includes `shadows` array with `source` and `path` for each shadowed schema\n\n### Requirement: Schema which supports list mode\nThe CLI SHALL support listing all schemas with their resolution sources.\n\n#### Scenario: List all schemas\n- **WHEN** user runs `openspec schema which --all`\n- **THEN** system displays all available schemas grouped by source\n- **AND** indicates which schemas shadow others\n\n#### Scenario: List in JSON format\n- **WHEN** user runs `openspec schema which --all --json`\n- **THEN** system outputs JSON array with resolution info for each schema\n"
  },
  {
    "path": "openspec/changes/archive/2026-02-17-schema-management-cli/tasks.md",
    "content": "## 1. Setup and Command Structure\n\n- [x] 1.1 Create `src/commands/schema.ts` with `registerSchemaCommand(program: Command)` function\n- [x] 1.2 Register schema command in `src/cli/index.ts` (import and call `registerSchemaCommand`)\n- [x] 1.3 Add schema command group with description: \"Manage workflow schemas\"\n\n## 2. Schema Which Command\n\n- [x] 2.1 Add `schema which <name>` subcommand with `--json` and `--all` options\n- [x] 2.2 Implement resolution lookup using `getSchemaDir()` with project root\n- [x] 2.3 Implement shadow detection by checking all three locations (project, user, package)\n- [x] 2.4 Add text output: show source, path, and shadowing info\n- [x] 2.5 Add JSON output: `{ name, source, path, shadows: [] }`\n- [x] 2.6 Add `--all` mode to list all schemas with their resolution sources\n\n## 3. Schema Validate Command\n\n- [x] 3.1 Add `schema validate [name]` subcommand with `--json` and `--verbose` options\n- [x] 3.2 Implement single-schema validation using existing `parseSchema()` from `schema.ts`\n- [x] 3.3 Add template existence check for each artifact's template file\n- [x] 3.4 Add dependency graph cycle detection (reuse topological sort logic)\n- [x] 3.5 Add validate-all mode when no name provided (scan `openspec/schemas/`)\n- [x] 3.6 Add text output with pass/fail indicators and error messages\n- [x] 3.7 Add JSON output matching existing `openspec validate` format: `{ valid, issues: [] }`\n- [x] 3.8 Add verbose mode showing each validation step\n\n## 4. Schema Fork Command\n\n- [x] 4.1 Add `schema fork <source> [name]` subcommand with `--json` and `--force` options\n- [x] 4.2 Implement source resolution using `getSchemaDir()` with project root\n- [x] 4.3 Implement default destination naming: `<source>-custom`\n- [x] 4.4 Implement directory copy with recursive file copy\n- [x] 4.5 Update `name` field in copied `schema.yaml`\n- [x] 4.6 Add overwrite protection: check destination exists, require `--force` or confirmation\n- [x] 4.7 Add text output with source/destination paths\n- [x] 4.8 Add JSON output: `{ forked, source, destination, sourceLocation }`\n\n## 5. Schema Init Command\n\n- [x] 5.1 Add `schema init <name>` subcommand with `--json`, `--description`, `--artifacts`, `--default`, `--no-default`, `--force` options\n- [x] 5.2 Implement schema name validation (kebab-case, no spaces)\n- [x] 5.3 Implement interactive prompts for description using `@inquirer/prompts`\n- [x] 5.4 Implement interactive artifact selection with descriptions (multi-select)\n- [x] 5.5 Create schema directory and `schema.yaml` with selected configuration\n- [x] 5.6 Create default template files for selected artifacts\n- [x] 5.7 Add `--default` flag to update `openspec/config.yaml` with new schema as default\n- [x] 5.8 Add overwrite protection: check if schema exists, require `--force`\n- [x] 5.9 Add text output with created path and next steps\n- [x] 5.10 Add JSON output: `{ created, path, schema }`\n- [x] 5.11 Add non-interactive mode with `--description` and `--artifacts` flags\n\n## 6. Testing\n\n- [x] 6.1 Add unit tests for `schema which` command in `test/commands/schema.test.ts`\n- [x] 6.2 Add unit tests for `schema validate` command\n- [x] 6.3 Add unit tests for `schema fork` command\n- [x] 6.4 Add unit tests for `schema init` command\n- [x] 6.5 Test interactive mode mocking with `@inquirer/prompts`\n- [x] 6.6 Test JSON output format for all commands\n- [x] 6.7 Test error cases: invalid name, not found, already exists, cycle detection\n\n## 7. Documentation and Polish\n\n- [x] 7.1 Add CLI help text for all schema subcommands\n- [x] 7.2 Update shell completion to include schema commands\n- [x] 7.3 Run linting and fix any issues (`npm run lint`)\n- [x] 7.4 Run full test suite (`npm test`)\n"
  },
  {
    "path": "openspec/changes/fix-opencode-commands-directory/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-02-25\n"
  },
  {
    "path": "openspec/changes/fix-opencode-commands-directory/design.md",
    "content": "## Context\n\nThe OpenCode adapter in `src/core/command-generation/adapters/opencode.ts` currently generates command files at `.opencode/command/opsx-<id>.md` (singular `command`). OpenCode's official documentation uses `.opencode/commands/` (plural), and every other adapter in the codebase follows the plural convention for commands directories. The legacy cleanup module in `src/core/legacy-cleanup.ts` also references the singular form for detecting old artifacts.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Align the OpenCode adapter path with OpenCode's official `.opencode/commands/` convention\n- Add the old singular path `.opencode/command/` to legacy cleanup so existing installations are properly cleaned\n- Update documentation to reflect the corrected path\n- Update test assertions to match the new path\n\n**Non-Goals:**\n- Changing the OpenCode skill path (`.opencode/skills/`) — already correct\n- Modifying any other adapter's directory structure\n- Adding migration prompts or interactive upgrade flows\n\n## Decisions\n\n### 1. Direct path rename in adapter\n\n**Decision:** Change `path.join('.opencode', 'command', ...)` to `path.join('.opencode', 'commands', ...)` in the adapter's `getFilePath` method.\n\n**Rationale:** This is a single-line change that aligns with the established pattern across all other adapters. No abstraction or indirection needed.\n\n**Alternatives considered:**\n- Add a configuration option for the directory name — rejected as over-engineering for a bug fix\n- Keep singular and add plural as alias — rejected as it creates ambiguity about which is canonical\n\n### 2. Legacy cleanup via existing constant map\n\n**Decision:** Update the `LEGACY_SLASH_COMMAND_PATHS` entry for `'opencode'` from `'.opencode/command/openspec-*.md'` to `'.opencode/command/opsx-*.md'` (the old singular path becomes the legacy pattern) and ensure the new path is handled by the current command generation pipeline.\n\n**Rationale:** The existing legacy cleanup infrastructure uses `LEGACY_SLASH_COMMAND_PATHS` as an explicit lookup. The old singular-path pattern already matches the legacy format (`openspec-*` prefix from the old SlashCommandRegistry era). The current command generation uses the `opsx-*` prefix, so we also need to add a legacy pattern for `opsx-*` files in the old singular directory.\n\n**Alternatives considered:**\n- Add a separate migration script — rejected; the existing legacy cleanup mechanism handles this scenario\n\n### 3. Documentation update\n\n**Decision:** Update the `docs/supported-tools.md` table entry for OpenCode from `.opencode/command/opsx-<id>.md` to `.opencode/commands/opsx-<id>.md`.\n\n**Rationale:** Documentation must match the actual generated paths.\n\n## Risks / Trade-offs\n\n- **[Existing installations have files at old path]** → Mitigated by legacy cleanup detecting `.opencode/command/` artifacts. On next `openspec init`, old files are cleaned up and new files written to `.opencode/commands/`.\n- **[Users referencing old path in custom scripts]** → Low risk. The old path was incorrect per OpenCode's specification, so custom references were already misaligned.\n"
  },
  {
    "path": "openspec/changes/fix-opencode-commands-directory/proposal.md",
    "content": "## Why\n\nThe OpenCode adapter uses `.opencode/command/` (singular) for its commands directory, but OpenCode's official documentation specifies `.opencode/commands/` (plural). Every other adapter in the codebase also uses plural directory names (`.claude/commands/`, `.cursor/commands/`, `.factory/commands/`, etc.). This inconsistency was introduced in Oct 2025 without documented rationale. Fixes [#748](https://github.com/Fission-AI/OpenSpec/issues/748).\n\n## What Changes\n\n- OpenCode adapter path changes from `.opencode/command/` to `.opencode/commands/`\n- Legacy cleanup adds `.opencode/command/` (old singular path) for backward compatibility\n- Documentation updated to reflect the new plural path\n\n## Capabilities\n\n### New Capabilities\n\n_None._\n\n### Modified Capabilities\n\n- `command-generation`: OpenCode adapter path changes from singular `command/` to plural `commands/` to match OpenCode's official directory convention\n\n## Impact\n\n- `src/core/command-generation/adapters/opencode.ts` — adapter path\n- `src/core/legacy-cleanup.ts` — legacy cleanup pattern + add old singular path\n- `docs/supported-tools.md` — documentation table\n- `test/core/command-generation/adapters.test.ts` — test assertion\n"
  },
  {
    "path": "openspec/changes/fix-opencode-commands-directory/specs/command-generation/spec.md",
    "content": "## MODIFIED Requirements\n\n### Requirement: ToolCommandAdapter interface\n\nThe system SHALL define a `ToolCommandAdapter` interface for per-tool formatting.\n\n#### Scenario: Adapter interface structure\n\n- **WHEN** implementing a tool adapter\n- **THEN** `ToolCommandAdapter` SHALL require:\n  - `toolId`: string identifier matching `AIToolOption.value`\n  - `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex)\n  - `formatFile(content: CommandContent)`: returns complete file content with frontmatter\n\n#### Scenario: Claude adapter formatting\n\n- **WHEN** formatting a command for Claude Code\n- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields\n- **AND** file path SHALL follow pattern `.claude/commands/opsx/<id>.md`\n\n#### Scenario: Cursor adapter formatting\n\n- **WHEN** formatting a command for Cursor\n- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-<id>`, `id`, `category`, `description` fields\n- **AND** file path SHALL follow pattern `.cursor/commands/opsx-<id>.md`\n\n#### Scenario: Windsurf adapter formatting\n\n- **WHEN** formatting a command for Windsurf\n- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields\n- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-<id>.md`\n\n#### Scenario: OpenCode adapter formatting\n\n- **WHEN** formatting a command for OpenCode\n- **THEN** the adapter SHALL output YAML frontmatter with `description` field\n- **AND** file path SHALL follow pattern `.opencode/commands/opsx-<id>.md` using `path.join('.opencode', 'commands', ...)` for cross-platform compatibility\n- **AND** the adapter SHALL transform colon-based command references (`/opsx:name`) to hyphen-based (`/opsx-name`) in the body\n\n## ADDED Requirements\n\n### Requirement: Legacy cleanup for renamed OpenCode command directory\n\nThe legacy cleanup module SHALL detect and remove old OpenCode command files from the previous singular `.opencode/command/` directory path.\n\n#### Scenario: Detect old singular-path OpenCode command files\n\n- **WHEN** running legacy artifact detection on a project with files matching `.opencode/command/opsx-*.md` or `.opencode/command/openspec-*.md`\n- **THEN** the system SHALL include those files in the legacy slash command files list via `LEGACY_SLASH_COMMAND_PATHS`\n- **AND** `LegacySlashCommandPattern.pattern` SHALL accept `string | string[]` to support multiple glob patterns per tool\n\n#### Scenario: Clean up old OpenCode command files on init\n\n- **WHEN** a user runs `openspec init` in a project with old `.opencode/command/` artifacts\n- **THEN** the system SHALL remove the old files\n- **AND** generate new command files at `.opencode/commands/`\n\n#### Scenario: Auto-cleanup legacy artifacts in non-interactive mode\n\n- **WHEN** a user runs `openspec init` in non-interactive mode (e.g., CI) and legacy artifacts are detected\n- **THEN** the system SHALL auto-cleanup legacy artifacts without requiring `--force`\n- **AND** legacy slash command files (100% OpenSpec-managed) SHALL be removed\n- **AND** config file cleanup SHALL only remove OpenSpec markers (never delete user files)\n"
  },
  {
    "path": "openspec/changes/fix-opencode-commands-directory/tasks.md",
    "content": "## 1. Adapter Fix\n\n- [x] 1.1 Update `src/core/command-generation/adapters/opencode.ts`: change `path.join('.opencode', 'command', ...)` to `path.join('.opencode', 'commands', ...)` and update the JSDoc comment\n\n## 2. Legacy Cleanup\n\n- [x] 2.1 Update `src/core/legacy-cleanup.ts`: update the `'opencode'` entry in `LEGACY_SLASH_COMMAND_PATHS` to detect both `opsx-*.md` and `openspec-*.md` patterns at `.opencode/command/` for backward compatibility\n\n## 3. Documentation\n\n- [x] 3.1 Update `docs/supported-tools.md`: change OpenCode command path from `.opencode/command/opsx-<id>.md` to `.opencode/commands/opsx-<id>.md`\n\n## 4. Tests\n\n- [x] 4.1 Update `test/core/command-generation/adapters.test.ts`: change the OpenCode file path assertion from `path.join('.opencode', 'command', 'opsx-explore.md')` to `path.join('.opencode', 'commands', 'opsx-explore.md')`\n\n## 5. Changeset\n\n- [x] 5.1 Create a changeset file (`.changeset/fix-opencode-commands-directory.md`) with a patch bump describing the path fix\n"
  },
  {
    "path": "openspec/changes/graceful-status-no-changes/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-02-25\n"
  },
  {
    "path": "openspec/changes/graceful-status-no-changes/design.md",
    "content": "## Context\n\n`statusCommand` in `src/commands/workflow/status.ts` calls `validateChangeExists()` from `shared.ts` as its first operation. When no `--change` option is provided and no change directories exist, `validateChangeExists` throws: `No changes found. Create one with: openspec new change <name>`. This error propagates up as a fatal CLI error (non-zero exit code).\n\nThis is correct behavior for commands like `apply` and `show` that require a change to operate on. However, `status` is an informational command — it should report the current state, even when that state is \"no changes exist.\"\n\nThe error surfaces during onboarding (issue #714) when AI agents call `openspec status` before any change has been created.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Make `openspec status` exit with code 0 and a friendly message when no changes exist\n- Support both text and JSON output modes for the no-changes case\n- Keep all other commands' validation behavior unchanged\n\n**Non-Goals:**\n- Changing the behavior of `validateChangeExists` (keep it strict for all consumers; only extract its internal helper)\n- Changing the onboard template or skill instructions\n- Handling the case where `--change` is provided but the specific change doesn't exist (this should remain an error)\n\n## Decisions\n\n### Extract `getAvailableChanges` and check before validation\n\n**Rationale**: Extract the private `getAvailableChanges` closure from `validateChangeExists` into a public exported function in `shared.ts`. Then, in `statusCommand`, call `getAvailableChanges` *before* `validateChangeExists` to detect the no-changes case early and handle it gracefully. This avoids using try/catch for control flow and eliminates any coupling to error message strings.\n\n**Alternative considered**: Catching the error from `validateChangeExists` by matching `error.message.startsWith('No changes found')`. Rejected because string coupling is fragile — if the error message changes, the catch silently stops working.\n\n**Alternative considered**: Adding a `throwOnEmpty` parameter to `validateChangeExists`. Rejected because it adds complexity to a shared function for a single consumer's needs and mixes UX concerns into a validation utility.\n\n### Keep `validateChangeExists` strict\n\n**Rationale**: `validateChangeExists` remains unchanged in behavior — it still throws for all error cases. The graceful handling lives entirely in `statusCommand`, which is the appropriate layer for UX decisions. Other commands (`apply`, `show`, `instructions`) are unaffected.\n\n## Risks / Trade-offs\n\n- [Risk] Extra filesystem read when no `--change` is provided and changes *do* exist (`getAvailableChanges` is called first, then `validateChangeExists` performs its own read) → Mitigation: `statusCommand` returns early before reaching `validateChangeExists` when no changes exist, so the double-read only occurs when changes are present — minimal overhead.\n- [Risk] Other commands may also benefit from graceful no-changes handling in the future → Mitigation: `getAvailableChanges` is now public and reusable, making it easy to apply the same pattern elsewhere.\n"
  },
  {
    "path": "openspec/changes/graceful-status-no-changes/proposal.md",
    "content": "## Why\n\nWhen `openspec status` is called without `--change` and no changes exist (e.g., during onboarding on a freshly initialized project), the CLI throws a fatal error: `No changes found. Create one with: openspec new change <name>`. This breaks the onboarding flow because AI agents may call `openspec status` before any change has been created, causing the agent to halt or report failure. Fixes [#714](https://github.com/Fission-AI/OpenSpec/issues/714).\n\n## What Changes\n\n- `openspec status` will exit gracefully (code 0) with a friendly message when no changes exist, instead of throwing a fatal error\n- `openspec status --json` will return a valid JSON object with an empty changes array when no changes exist\n- Other commands (`apply`, `show`, etc.) retain their current strict validation behavior\n\n## Capabilities\n\n### New Capabilities\n\n- `graceful-status-empty`: Graceful handling of `openspec status` when no changes exist, covering both text and JSON output modes\n\n### Modified Capabilities\n\n_None — `validateChangeExists` was internally refactored to delegate to the newly exported `getAvailableChanges`, but its behavior and public contract are unchanged. Other consumers are unaffected._\n\n## Impact\n\n- `src/commands/workflow/shared.ts` — extract `getAvailableChanges` as a public function (validation behavior unchanged)\n- `src/commands/workflow/status.ts` — check for available changes before validation, handle empty case gracefully\n- Tests for the status command need to cover the new graceful behavior\n"
  },
  {
    "path": "openspec/changes/graceful-status-no-changes/specs/graceful-status-empty/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: Status command exits gracefully when no changes exist\nThe `statusCommand` function SHALL check for available changes via `getAvailableChanges` before calling `validateChangeExists`. When no `--change` option is provided and no change directories exist, it SHALL print a friendly informational message and exit with code 0, instead of reaching `validateChangeExists` and propagating a fatal error.\n\n#### Scenario: No changes exist, text mode\n- **WHEN** user runs `openspec status` without `--change` and no change directories exist under `openspec/changes/`\n- **THEN** the CLI prints `No active changes. Create one with: openspec new change <name>` to stdout and exits with code 0\n\n#### Scenario: No changes exist, JSON mode\n- **WHEN** user runs `openspec status --json` without `--change` and no change directories exist\n- **THEN** the CLI outputs `{\"changes\":[],\"message\":\"No active changes.\"}` as valid JSON to stdout and exits with code 0\n\n### Requirement: Existing status validation behavior is preserved\nOther error paths in `validateChangeExists` that apply to the status command SHALL continue to throw errors as before. Commands other than `status` that use `validateChangeExists` SHALL NOT be affected.\n\n#### Scenario: Changes exist but --change not specified\n- **WHEN** user runs `openspec status` without `--change` and one or more change directories exist\n- **THEN** the CLI throws an error listing available changes with the message `Missing required option --change. Available changes: ...`\n\n#### Scenario: Specified change does not exist\n- **WHEN** user runs `openspec status --change non-existent`\n- **THEN** the CLI throws an error with message `Change 'non-existent' not found`\n\n#### Scenario: Other commands unaffected\n- **WHEN** user runs `openspec show` or `openspec instructions` without `--change` and no changes exist\n- **THEN** the CLI throws the original `No changes found` error (no behavior change)\n"
  },
  {
    "path": "openspec/changes/graceful-status-no-changes/tasks.md",
    "content": "## 1. Implementation\n\n- [x] 1.1 Extract `getAvailableChanges` in `shared.ts` and use it in `statusCommand` to check for changes before calling `validateChangeExists`\n- [x] 1.2 In text mode: print `No active changes. Create one with: openspec new change <name>` and return (exit 0)\n- [x] 1.3 In JSON mode: output `{\"changes\":[],\"message\":\"No active changes.\"}` and return (exit 0)\n\n## 2. Tests\n\n- [x] 2.1 Add test: `openspec status` with no changes exits gracefully with friendly message (text mode)\n- [x] 2.2 Add test: `openspec status --json` with no changes returns valid JSON with empty changes array\n- [x] 2.3 Verify existing behavior: `openspec status` without `--change` when changes exist still throws missing option error\n- [x] 2.4 Verify cross-platform: tests use `path.join()` for any path assertions\n\n## 3. Release\n\n- [x] 3.1 Add changeset describing the fix\n"
  },
  {
    "path": "openspec/changes/schema-alias-support/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-01-20\n"
  },
  {
    "path": "openspec/changes/schema-alias-support/proposal.md",
    "content": "## Why\n\nWe want to rename `spec-driven` to `openspec-default` to better reflect that it's the standard/default workflow. However, renaming directly would break existing projects that have `schema: spec-driven` in their `openspec/config.yaml`. Adding alias support allows both names to work interchangeably, enabling a smooth transition with no breaking changes.\n\n## What Changes\n\n- Add schema alias resolution in the schema resolver\n- `openspec-default` and `spec-driven` will both resolve to the same schema\n- The physical directory remains `schemas/spec-driven/` (or could be renamed to `schemas/openspec-default/` with `spec-driven` as the alias)\n- All CLI commands and config files accept either name\n- No changes required to existing user configs\n\n## Capabilities\n\n### New Capabilities\n\n- `schema-aliases`: Support for schema name aliases so multiple names can resolve to the same schema directory\n\n### Modified Capabilities\n\n<!-- No existing spec-level behavior is changing - this is purely additive -->\n\n## Impact\n\n- `src/core/artifact-graph/resolver.ts` - Add alias resolution logic\n- `schemas/` directory - Potentially rename `spec-driven` to `openspec-default`\n- Documentation - Update to prefer `openspec-default` while noting `spec-driven` still works\n- Default schema constants - Update `DEFAULT_SCHEMA` to `openspec-default`\n"
  },
  {
    "path": "openspec/changes/simplify-skill-installation/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-02-17\n"
  },
  {
    "path": "openspec/changes/simplify-skill-installation/design.md",
    "content": "## Context\n\nOpenSpec currently installs 10 workflows (skills + commands) for every user, overwhelming new users. The init flow asks multiple questions (profile, delivery, tools) creating friction before users can experience value.\n\nCurrent architecture:\n- `src/core/init.ts` - Handles tool selection and skill/command generation\n- `src/core/config.ts` - Defines `AI_TOOLS` with `skillsDir` mappings\n- `src/core/shared/skill-generation.ts` - Generates skill files from templates\n- `src/core/templates/workflows/*.ts` - Individual workflow templates\n- `src/prompts/searchable-multi-select.ts` - Tool selection UI\n\nGlobal config exists at `~/.config/openspec/config.json` for telemetry/feature flags. Profile/delivery settings will extend this existing config.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Get new users to \"aha moment\" in under 1 minute\n- Smart defaults init with auto-detection and confirmation (core profile, both delivery)\n- Auto-detect installed tools from existing directories\n- Introduce profile system (core/custom) for workflow selection\n- Introduce delivery config (skills/commands/both) as power-user setting\n- Create new `propose` workflow combining `new` + `ff`\n- Fix tool selection UX (space to select, enter to confirm)\n- Maintain backwards compatibility for existing users\n\n**Non-Goals:**\n- Removing any existing workflows (all remain available via custom profile)\n- Per-project profile/delivery settings (user-level only)\n- Changing the artifact structure or schema system\n- Modifying how skills/commands are formatted or written\n\n## Decisions\n\n### 1. Extend Existing Global Config\n\nAdd profile/delivery settings to existing `~/.config/openspec/config.json` (via `src/core/global-config.ts`).\n\n**Rationale:** Global config already exists with XDG/APPDATA cross-platform path handling, schema evolution, and merge-with-defaults behavior. Reusing it avoids a second config file and leverages existing infrastructure.\n\n**Schema extension:**\n```json\n{\n  \"telemetry\": { ... },     // existing\n  \"featureFlags\": { ... },  // existing\n  \"profile\": \"core\",        // NEW\n  \"delivery\": \"both\",       // NEW\n  \"workflows\": [...]        // NEW (only for custom profile)\n}\n```\n\n**Alternatives considered:**\n- New `~/.openspec/config.yaml`: Creates second config file, different format, path confusion\n- Project config: Would require syncing mechanism, users edit it directly\n- Environment variables: Less discoverable, harder to persist\n\n### 2. Profile System with Two Tiers\n\n```\ncore (default):     propose, explore, apply, archive (4)\ncustom:             user-defined subset of workflows\n```\n\n**Rationale:** Core covers the essential loop (propose → explore → apply → archive). Custom allows users to pick exactly what they need via an interactive picker.\n\n**Configuration UX:**\n```\n$ openspec config profile\n\nDelivery: [skills] [commands] [both]\n                              ^^^^^^\n\nWorkflows: (space to toggle, enter to save)\n[x] propose\n[x] explore\n[x] apply\n[x] archive\n[ ] new\n[ ] ff\n...\n```\n\n**Alternatives considered:**\n- Three tiers (core/extended/custom): Extended is redundant - users who want all workflows can select them in custom\n- Separate commands for profile and delivery: Combining into one picker reduces cognitive load\n\n### 3. Propose Workflow = New + FF Combined\n\nSingle workflow that creates a change and generates all artifacts in one step.\n\n**Rationale:** Most users want to go from idea to implementation-ready. Separating `new` (creates folder) and `ff` (generates artifacts) adds unnecessary steps. Power users who want control can use `new` + `continue` via custom profile.\n\n**Implementation:** New template in `src/core/templates/workflows/propose.ts` that:\n1. Creates change directory via `openspec new change`\n2. Runs artifact generation loop (like ff does)\n3. Includes onboarding-style explanations in output\n\n### 4. Auto-Detection with Confirmation\n\nScan for existing tool directories, pre-select detected tools, ask for confirmation.\n\n**Rationale:** Reduces questions while still giving user control. Better than full auto (no confirmation) which might install unwanted tools, or no detection (always ask) which adds friction.\n\n**Detection logic:**\n```typescript\n// Use existing AI_TOOLS config to get directory mappings\n// Each tool in AI_TOOLS has a skillsDir property (e.g., '.claude', '.cursor', '.windsurf')\n// Scan cwd for existing directories matching skillsDir values, pre-select matches\nconst detectedTools = AI_TOOLS.filter(tool =>\n  fs.existsSync(path.join(cwd, tool.skillsDir))\n);\n```\n\n### 5. Delivery as Part of Profile Config\n\nDelivery preference (skills/commands/both) stored in global config, defaulting to \"both\".\n\n**Rationale:** Most users don't know or care about this distinction. Power users who have a preference can set it via `openspec config profile` interactive picker. Not worth asking during init.\n\n### 6. Filesystem as Truth for Installed Workflows\n\nWhat's installed in `.claude/skills/` (etc.) is the source of truth, not config.\n\n**Rationale:**\n- Backwards compatible with existing installs\n- User can manually add/remove skill directories\n- Config profile is a \"template\" for what to install, not a constraint\n\n**Behavior:**\n- `openspec init` sets up new projects OR re-initializes existing projects (selects tools, generates workflows)\n- `openspec update` refreshes an existing project to match current config (no tool selection)\n- `openspec config profile` updates global config only, offers to run update if in a project\n- Extra workflows (not in profile) are preserved\n- Delivery changes are applied: switching to `skills` removes commands, switching to `commands` removes skills\n\n**Why not a separate tool manifest?**\n\nTool selection (which assistants a project uses) is per-user AND per-project, but the two config locations are per-user-only (global config) or per-project-shared (checked-in project config). A separate manifest was explored and rejected:\n\n- *Path-keyed global config* (`projects: { \"/path\": { tools: [...] } }`): Fragile on directory move/rename/delete, symlink ambiguity, and project behavior depends on invisible external state.\n- *Gitignored local file* (`.openspec.local`): Lost on fresh clone, adds file management overhead.\n- *Checked-in project config* (`openspec/config.yaml` with `tools` field): Forces tool choices on the whole team — Alice uses Claude Code, Bob uses Cursor, neither wants the other's tools mandated.\n\nThe filesystem approach avoids all three problems. For teams, it's actually beneficial: checked-in skill files mean `openspec update` from any team member refreshes skills for all tools the project supports. The generated files serve as both the deliverable and the implicit tool manifest.\n\nKnown gap: a tool that stores config outside the project tree (no local directory to scan) would need tool-specific handling, since there's nothing in the project to scan. Address if/when such a tool is supported.\n\n**When to use init vs update:**\n- `init`: First time setup, or when you want to change which tools are configured\n- `update`: After changing config, or to refresh templates to latest version\n\n### 8. Existing User Migration\n\nWhen `openspec init` or `openspec update` encounters a project with existing workflows but no `profile` field in global config, it performs a one-time migration to preserve the user's current setup.\n\n**Rationale:** Without migration, existing users would default to `core` profile, causing `propose` to be added on top of their 10 workflows — making things worse, not better. Migration ensures existing users keep exactly what they have.\n\n**Triggered by:** Both `init` (re-init on existing project) and `update`. The migration check is a shared function called early in both commands, before profile resolution.\n\n**Detection logic:**\n```typescript\n// Shared migration check, called by both init and update:\nfunction migrateIfNeeded(projectPath: string, tools: AiTool[]): void {\n  const globalConfig = readGlobalConfig();\n  if (globalConfig.profile) return; // already migrated or explicitly set\n\n  const installedWorkflows = scanInstalledWorkflows(projectPath, tools);\n  if (installedWorkflows.length === 0) return; // new user, use core defaults\n\n  // Existing user — migrate to custom profile\n  writeGlobalConfig({\n    ...globalConfig,\n    profile: 'custom',\n    delivery: 'both',\n    workflows: installedWorkflows,\n  });\n}\n```\n\n**Scanning logic:**\n- Scan all tool directories (`.claude/skills/`, `.cursor/skills/`, etc.) for workflow directories/files\n- Match only against `ALL_WORKFLOWS` constant — ignore user-created custom skills/commands\n- Map directory names back to workflow IDs (e.g., `openspec-explore/` → `explore`, `opsx-explore.md` → `explore`)\n- Take the union of detected workflow names across all tools\n\n**Edge cases:**\n- **User manually deleted some workflows:** Migration scans what's actually installed, respecting their choices\n- **Multiple projects with different workflow sets:** First project to trigger migration sets global config; subsequent projects use it\n- **User has custom (non-OpenSpec) skills in the directory:** Ignored — scanner only matches known workflow IDs from `ALL_WORKFLOWS`\n- **Migration is idempotent:** If `profile` is already set in config, no re-migration occurs\n- **Non-interactive (CI):** Same migration logic, no confirmation needed — it's preserving existing state\n\n**Alternatives considered:**\n- Migrate during `init` instead of `update`: Init already has its own flow (tool selection, etc.). Mixing migration with init creates confusing UX\n- Don't migrate, just default to core: Breaks existing users by adding `propose` and showing \"extra workflows\" warnings\n- Migrate at global config read time: Too implicit, hard to show feedback to user\n\n### 9. Generic Next-Step Guidance in Templates\n\nWorkflow templates use generic, concept-based next-step guidance rather than referencing specific workflow commands. For example, instead of \"run `/opsx:propose`\", templates say \"create a change proposal\".\n\n**Rationale:** Conditional cross-referencing (where each template checks which other workflows are installed and renders different command names) adds significant complexity to template generation, testing, and maintenance. Generic guidance avoids this entirely while still being useful — users already know their installed workflows.\n\n**Note:** If we find that users consistently struggle to map concepts to commands, we can revisit this with conditional cross-references. For now, simplicity wins.\n\n### 7. Fix Multi-Select Keybindings\n\nChange from tab-to-confirm to industry-standard space/enter.\n\n**Rationale:** Tab to confirm is non-standard and confuses users. Most CLI tools use space to toggle, enter to confirm.\n\n**Implementation:** Modify `src/prompts/searchable-multi-select.ts` keybinding configuration.\n\n### 10. Update Sync Must Consider Config Drift, Not Just Version Drift\n\n`openspec update` cannot rely only on `generatedBy` version checks for deciding whether work is needed.\n\n**Rationale:** profile and delivery changes can require file add/remove operations even when existing skill templates are current. If we only check template versions, update may incorrectly return \"up to date\" and skip required sync.\n\n**Implementation:**\n- Keep version checks for template refresh decisions\n- Add file-state drift checks for profile/delivery (missing expected files or stale files from removed delivery mode)\n- Treat either version drift OR config drift as update-required\n\n### 11. Tool Configuration Detection Includes Commands-Only Installs\n\nConfigured-tool detection for update must include command files, not only skill files.\n\n**Rationale:** with `delivery: commands`, a project can be fully configured without skill files. Skill-only detection incorrectly reports \"No configured tools found.\"\n\n**Implementation:**\n- For update flows, treat a tool as configured if it has either generated skills or generated commands\n- Keep migration workflow scanning behavior unchanged (skills remain the migration source of truth)\n\n### 12. Init Profile Override Is Strictly Validated\n\n`openspec init --profile` must validate allowed values before proceeding.\n\n**Rationale:** silently accepting unknown profile values hides user errors and produces implicit fallback behavior.\n\n**Implementation:** accept only `core` and `custom`; throw a clear CLI error for invalid values.\n\n## Risks / Trade-offs\n\n**Risk: Breaking existing user workflows**\n→ Mitigation: Filesystem is truth, existing installs untouched. All workflows available via custom profile.\n\n**Risk: Propose workflow duplicates ff logic**\n→ Mitigation: Extract shared artifact generation into reusable function, both `propose` and `ff` call it.\n\n**Risk: Global config file management**\n→ Mitigation: Create directory/file on first use. Handle missing file gracefully (use defaults).\n\n**Risk: Auto-detection false positives**\n→ Mitigation: Show detected tools and ask for confirmation, don't auto-install silently.\n\n**Trade-off: Core profile has only 4 workflows**\n→ Acceptable: These cover the main loop. Users who need more can use `openspec config profile` to select additional workflows.\n\n## Migration Plan\n\n1. **Phase 1: Add infrastructure**\n   - Extend global-config.ts with profile/delivery/workflows fields\n   - Profile definitions and resolution\n   - Tool auto-detection\n\n2. **Phase 2: Create propose workflow**\n   - New template combining new + ff\n   - Enhanced UX with explanatory output\n\n3. **Phase 3: Update init flow**\n   - Smart defaults with tool confirmation\n   - Auto-detect and confirm tools\n   - Respect profile/delivery settings\n\n4. **Phase 4: Add config profile command**\n   - `openspec config profile` interactive picker\n   - `openspec config profile core` preset shortcut\n\n5. **Phase 5: Update the update command**\n   - Read global config for profile/delivery\n   - Add missing workflows from profile\n   - Delete files when delivery changes (e.g., commands removed if `skills`)\n   - Display summary of changes\n\n6. **Phase 6: Fix multi-select UX**\n   - Update keybindings in searchable-multi-select\n\n**Rollback:** All changes are additive. Existing behavior preserved via custom profile with all workflows selected.\n"
  },
  {
    "path": "openspec/changes/simplify-skill-installation/proposal.md",
    "content": "## Why\n\nUsers have complained that there are too many skills/commands (currently 10) and new users feel overwhelmed. We want to simplify the default experience while preserving power-user capabilities and backwards compatibility.\n\nThe goal: **get users to an \"aha moment\" in under a minute**.\n\n```text\n0:00  $ openspec init\n      ✓ Done. Run /opsx:propose \"your idea\"\n\n0:15  /opsx:propose \"add user authentication\"\n\n0:45  Agent creates proposal.md, design.md, tasks.md\n      \"Whoa, it planned the whole thing for me\" ← AHA\n\n1:00  /opsx:apply\n```\n\nAdditionally, users have different preferences for how workflows are delivered (skills vs commands vs both), but this should be a power-user configuration, not something new users think about.\n\n## What Changes\n\n### 1. Smart Defaults Init\n\nInit auto-detects tools and asks for confirmation:\n\n```text\n$ openspec init\n\nDetected tools:\n  [x] Claude Code\n  [x] Cursor\n  [ ] Windsurf\n\nPress Enter to confirm, or Space to toggle\n\nSetting up OpenSpec...\n✓ Done\n\nStart your first change:\n  /opsx:propose \"add dark mode\"\n```\n\n**No prompts for profile or delivery.** Defaults are:\n- Profile: core\n- Delivery: both\n\nPower users can customize via `openspec config profile`.\n\n### 2. Tool Detection Behavior\n\nInit scans for existing tool directories (`.claude/`, `.cursor/`, etc.):\n- **Tools detected (interactive):** Shows pre-selected checkboxes, user confirms or adjusts\n- **No tools detected (interactive):** Prompts for full tool selection\n- **Non-interactive (CI):** Uses detected tools automatically, fails if none detected\n\n### 3. Fix Tool Selection UX\n\nCurrent behavior confuses users:\n- Tab to confirm (unexpected)\n\nNew behavior:\n- **Space** to toggle selection\n- **Enter** to confirm\n\n### 4. Introduce Profiles\n\nProfiles define which workflows to install:\n\n- **core** (default): `propose`, `explore`, `apply`, `archive` (4 workflows)\n- **custom**: User-selected subset of workflows\n\nThe `propose` workflow is new - it combines `new` + `ff` into a single command that creates a change and generates all artifacts.\n\n### 5. Improved Propose UX\n\n`/opsx:propose` should naturally onboard users by explaining what it's doing:\n\n```text\nI'll create a change with 3 artifacts:\n- proposal.md (what & why)\n- design.md (how)\n- tasks.md (implementation steps)\n\nWhen ready to implement, run /opsx:apply\n```\n\nThis teaches as it goes - no separate onboarding needed for most users.\n\n### 6. Introduce Delivery Config\n\nDelivery controls how workflows are installed:\n\n- **both** (default): Skills and commands\n- **skills**: Skills only\n- **commands**: Commands only\n\nStored in existing global config (`~/.config/openspec/config.json`). Not prompted during init.\n\n### 7. New CLI Commands\n\n```shell\n# Profile configuration (interactive picker for delivery + workflows)\nopenspec config profile          # interactive picker\nopenspec config profile core     # preset shortcut (core workflows, preserves delivery)\n```\n\nThe interactive picker allows users to configure both delivery method and workflow selection in one place:\n\n```\n$ openspec config profile\n\nDelivery: [skills] [commands] [both]\n                              ^^^^^^\n\nWorkflows: (space to toggle, enter to save)\n[x] propose\n[x] explore\n[x] apply\n[x] archive\n[ ] new\n[ ] ff\n[ ] continue\n[ ] verify\n[ ] sync\n[ ] bulk-archive\n[ ] onboard\n```\n\n### 8. Backwards Compatibility & Migration\n\n**Existing users keep their current setup.** When `openspec update` runs on a project with existing workflows and no `profile` in global config, it performs a one-time migration:\n\n1. Scans installed workflow files across all tool directories in the project\n2. Writes `profile: \"custom\"`, `delivery: \"both\"`, `workflows: [<detected>]` to global config\n3. Refreshes templates but does NOT add or remove any workflows\n4. Displays: \"Migrated: custom profile with N existing workflows\"\n\nAfter migration, subsequent `init` and `update` commands respect the migrated config.\n\n**Key behaviors:**\n- Existing users' workflows are preserved exactly as-is (no `propose` added automatically)\n- Both `init` (re-init) and `update` trigger migration on existing projects if no profile is set\n- `openspec init` on a **new** project (no existing workflows) uses global config, defaulting to `core`\n- `init` with a custom profile applies the configured workflows directly (no profile confirmation prompt)\n- `init` validates `--profile` values (`core` or `custom`) and errors on invalid input\n- Migration message mentions `propose` and suggests `openspec config profile core` to opt in\n- After migration, users can opt into `core` profile via `openspec config profile core`\n- Workflow templates conditionally reference only installed workflows in \"next steps\" guidance\n- Delivery changes are applied: switching to `skills` removes command files, switching to `commands` removes skill files\n- Re-running `init` applies delivery cleanup on existing projects (removes files that no longer match delivery)\n- `update` treats profile/delivery drift as update-required even when template versions are already current\n- `update` treats command-only installs as configured tools\n- All workflows remain available via custom profile\n\n## Capabilities\n\n### New Capabilities\n\n- `profiles`: Workflow profiles (core, custom), delivery preferences, global config storage, interactive picker\n- `propose-workflow`: Combined workflow that creates change + generates all artifacts\n\n### Modified Capabilities\n\n- `cli-init`: Smart defaults with tool auto-detection, profile-based skill/command generation\n- `cli-update`: Profile support, delivery changes, one-time migration for existing users\n\n## Impact\n\n### New Files\n- `src/core/templates/workflows/propose.ts` - New propose workflow template\n- `src/core/profiles.ts` - Profile definitions and logic\n- `src/core/available-tools.ts` - Detect what AI tools user has from directories\n\n### Modified Files\n- `src/core/init.ts` - Smart defaults, auto-detection, tool confirmation\n- `src/core/config.ts` - Add profile and delivery types\n- `src/core/global-config.ts` - Add profile, delivery, workflows fields to schema\n- `src/core/shared/skill-generation.ts` - Filter by profile, respect delivery\n- `src/core/shared/tool-detection.ts` - Update SKILL_NAMES and COMMAND_IDS to include propose\n- `src/commands/config.ts` - Add `profile` subcommand with interactive picker\n- `src/core/update.ts` - Add profile/delivery support, file deletion for delivery changes\n- `src/prompts/searchable-multi-select.ts` - Fix keybindings (space/enter)\n\n### Global Config Schema Extension\n```json\n// ~/.config/openspec/config.json (extends existing)\n{\n  \"telemetry\": { ... },          // existing\n  \"featureFlags\": { ... },       // existing\n  \"profile\": \"core\",             // NEW: core | custom\n  \"delivery\": \"both\",            // NEW: both | skills | commands\n  \"workflows\": [\"propose\", ...]  // NEW: only if profile: custom\n}\n```\n\n## Profiles Reference\n\n| Profile | Workflows | Description |\n|---------|-----------|-------------|\n| core | propose, explore, apply, archive | Streamlined flow for most users (default) |\n| custom | user-defined | Pick exactly what you need via `openspec config profile` |\n"
  },
  {
    "path": "openspec/changes/simplify-skill-installation/specs/cli-init/spec.md",
    "content": "## Purpose\n\nThe init command SHALL provide a streamlined setup experience that auto-detects tools and uses smart defaults, getting users to their first change in under a minute.\n\n## MODIFIED Requirements\n\n### Requirement: Skill generation per tool (REPLACES fixed 9-skill mandate)\nThe init command SHALL generate skills based on the active profile, not a fixed set.\n\n#### Scenario: Core profile skill generation\n- **WHEN** user runs init with profile `core`\n- **THEN** the system SHALL generate skills for workflows in CORE_WORKFLOWS constant: propose, explore, apply, archive\n- **THEN** the system SHALL NOT generate skills for workflows outside the profile\n\n#### Scenario: Custom profile skill generation\n- **WHEN** user runs init with profile `custom`\n- **THEN** the system SHALL generate skills only for workflows listed in config `workflows` array\n\n#### Scenario: Propose workflow included in skill templates\n- **WHEN** generating skills\n- **THEN** the system SHALL include the `propose` workflow as an available skill template\n\n### Requirement: Command generation per tool (REPLACES fixed 9-command mandate)\nThe init command SHALL generate commands based on profile AND delivery settings.\n\n#### Scenario: Skills-only delivery\n- **WHEN** delivery is set to `skills`\n- **THEN** the system SHALL NOT generate any command files\n\n#### Scenario: Commands-only delivery\n- **WHEN** delivery is set to `commands`\n- **THEN** the system SHALL NOT generate any skill files\n\n#### Scenario: Both delivery\n- **WHEN** delivery is set to `both`\n- **THEN** the system SHALL generate both skill and command files for profile workflows\n\n#### Scenario: Propose workflow included in command templates\n- **WHEN** generating commands\n- **THEN** the system SHALL include the `propose` workflow as an available command template\n\n### Requirement: Tool auto-detection\nThe init command SHALL detect installed AI tools by scanning for their configuration directories in the project root.\n\n#### Scenario: Detection from directories\n- **WHEN** scanning for tools\n- **THEN** the system SHALL check for directories matching each supported AI tool's configuration directory (e.g., `.claude/`, `.cursor/`, `.windsurf/`)\n- **THEN** all tools with a matching directory SHALL be returned as detected\n\n#### Scenario: Detection covers all supported tools\n- **WHEN** scanning for tools\n- **THEN** the system SHALL check for all tools defined in the supported tools configuration that have a configuration directory\n\n#### Scenario: No tools detected\n- **WHEN** no tool configuration directories exist in project root\n- **THEN** the system SHALL return an empty list of detected tools\n\n### Requirement: Smart defaults init flow\nThe init command SHALL work with sensible defaults and tool confirmation, minimizing required user input.\n\n#### Scenario: Init with detected tools (interactive)\n- **WHEN** user runs `openspec init` interactively and tool directories are detected\n- **THEN** the system SHALL show detected tools pre-selected\n- **THEN** the system SHALL ask for confirmation (not full selection)\n- **THEN** the system SHALL use default profile (`core`) and delivery (`both`)\n\n#### Scenario: Init with no detected tools (interactive)\n- **WHEN** user runs `openspec init` interactively and no tool directories are detected\n- **THEN** the system SHALL prompt for tool selection\n- **THEN** the system SHALL use default profile (`core`) and delivery (`both`)\n\n#### Scenario: Non-interactive with detected tools\n- **WHEN** user runs `openspec init` non-interactively (e.g., in CI)\n- **AND** tool directories are detected\n- **THEN** the system SHALL use detected tools automatically without prompting\n- **THEN** the system SHALL use default profile and delivery\n\n#### Scenario: Non-interactive with no detected tools\n- **WHEN** user runs `openspec init` non-interactively\n- **AND** no tool directories are detected\n- **THEN** the system SHALL fail with exit code 1\n- **AND** display message to use `--tools` flag\n\n#### Scenario: Non-interactive with explicit tools\n- **WHEN** user runs `openspec init --tools claude`\n- **THEN** the system SHALL use specified tools\n- **THEN** the system SHALL NOT prompt for any input\n\n#### Scenario: Interactive with explicit tools\n- **WHEN** user runs `openspec init --tools claude` interactively\n- **THEN** the system SHALL use specified tools (ignoring auto-detection)\n- **THEN** the system SHALL NOT prompt for tool selection\n- **THEN** the system SHALL proceed with default profile and delivery\n\n#### Scenario: Init success message (propose installed)\n- **WHEN** init completes successfully\n- **AND** `propose` is in the active profile\n- **THEN** the system SHALL display a tool-appropriate success message\n- **THEN** for tools using colon syntax (Claude Code): \"Start your first change: /opsx:propose \\\"your idea\\\"\"\n- **THEN** for tools using hyphen syntax (Cursor, others): \"Start your first change: /opsx-propose \\\"your idea\\\"\"\n\n#### Scenario: Init success message (propose not installed, new installed)\n- **WHEN** init completes successfully\n- **AND** `propose` is NOT in the active profile\n- **AND** `new` is in the active profile\n- **THEN** for tools using colon syntax: \"Start your first change: /opsx:new \\\"your idea\\\"\"\n- **THEN** for tools using hyphen syntax: \"Start your first change: /opsx-new \\\"your idea\\\"\"\n\n#### Scenario: Init success message (neither propose nor new)\n- **WHEN** init completes successfully\n- **AND** neither `propose` nor `new` is in the active profile\n- **THEN** the system SHALL display: \"Done. Run 'openspec config profile' to configure your workflows.\"\n\n### Requirement: Init performs migration on existing projects\nThe init command SHALL perform one-time migration when re-initializing an existing project, using the same shared migration logic as the update command.\n\n#### Scenario: Re-init on existing project (no profile set)\n- **WHEN** user runs `openspec init` on a project with existing workflow files\n- **AND** global config does not contain a `profile` field\n- **THEN** the system SHALL perform one-time migration before proceeding (see `specs/cli-update/spec.md`)\n- **THEN** the system SHALL proceed with init using the migrated config\n\n#### Scenario: Init on new project (no existing workflows)\n- **WHEN** user runs `openspec init` on a project with no existing workflow files\n- **AND** global config does not contain a `profile` field\n- **THEN** the system SHALL NOT perform migration\n- **THEN** the system SHALL use `core` profile defaults\n\n### Requirement: Init respects global config\nThe init command SHALL read and apply settings from global config.\n\n#### Scenario: User has profile preference\n- **WHEN** global config contains `profile: \"custom\"` with custom workflows\n- **THEN** init SHALL install custom profile workflows\n\n#### Scenario: User has delivery preference\n- **WHEN** global config contains `delivery: \"skills\"`\n- **THEN** init SHALL install only skill files, not commands\n\n#### Scenario: Override via flags\n- **WHEN** user runs `openspec init --profile core`\n- **THEN** the system SHALL use the flag value instead of config value\n- **THEN** the system SHALL NOT update the global config\n\n#### Scenario: Invalid profile override\n- **WHEN** user runs `openspec init --profile <invalid>`\n- **AND** `<invalid>` is not one of `core` or `custom`\n- **THEN** the system SHALL exit with code 1\n- **THEN** the system SHALL display a validation error listing allowed profile values\n\n### Requirement: Init applies configured profile without confirmation\nThe init command SHALL apply the resolved profile (`--profile` override or global config) directly without prompting for confirmation.\n\n#### Scenario: Init with custom profile (interactive)\n- **WHEN** user runs `openspec init` interactively\n- **AND** global config specifies `profile: \"custom\"` with workflows\n- **THEN** the system SHALL proceed directly using the custom profile workflows\n- **AND** the system SHALL NOT show a profile confirmation prompt\n\n#### Scenario: Non-interactive init with custom profile\n- **WHEN** user runs `openspec init` non-interactively\n- **AND** global config specifies a custom profile\n- **THEN** the system SHALL proceed without confirmation\n\n#### Scenario: Init with core profile\n- **WHEN** user runs `openspec init` interactively\n- **AND** profile is `core` (default)\n- **THEN** the system SHALL proceed directly without a profile confirmation prompt\n\n### Requirement: Init preserves existing workflows\nThe init command SHALL NOT remove workflows that are already installed, but SHALL respect delivery setting.\n\n#### Scenario: Existing custom installation\n- **WHEN** user has custom profile with extra workflows and runs `openspec init` with core profile\n- **THEN** the system SHALL NOT remove extra workflows\n- **THEN** the system SHALL regenerate core workflow files, overwriting existing content with latest templates\n\n#### Scenario: Init with different delivery setting\n- **WHEN** user runs `openspec init` on existing project\n- **AND** delivery setting differs from what's installed (e.g., was `both`, now `skills`)\n- **THEN** the system SHALL generate files matching current delivery setting\n- **THEN** the system SHALL delete files that don't match delivery (e.g., commands removed if `skills`)\n- **THEN** this applies to all workflows, including extras not in profile\n\n#### Scenario: Re-init applies delivery cleanup even when templates are current\n- **WHEN** user runs `openspec init` on an existing project\n- **AND** existing files are already on current template versions\n- **AND** delivery changed since the previous init\n- **THEN** the system SHALL still remove files that no longer match delivery\n- **THEN** for example, switching from `both` to `skills` SHALL remove generated command files\n\n### Requirement: Init tool confirmation UX\nThe init command SHALL show detected tools and ask for confirmation.\n\n#### Scenario: Confirmation prompt\n- **WHEN** tools are detected in interactive mode\n- **THEN** the system SHALL display: \"Detected: Claude Code, Cursor\"\n- **THEN** the system SHALL show pre-selected checkboxes for confirmation\n- **THEN** the system SHALL allow user to deselect unwanted tools\n"
  },
  {
    "path": "openspec/changes/simplify-skill-installation/specs/cli-update/spec.md",
    "content": "## Purpose\n\nThe update command SHALL apply global configuration changes to existing projects, syncing profile and delivery preferences without requiring full re-initialization.\n\n## MODIFIED Requirements\n\n### Requirement: Update respects global profile config\nThe update command SHALL read global config and apply profile settings to the project.\n\n#### Scenario: Update adds missing workflows from config\n- **WHEN** user runs `openspec update`\n- **AND** global config specifies workflows not currently installed in the project\n- **THEN** the system SHALL generate skill/command files for missing workflows\n- **THEN** the system SHALL display: \"Added: <workflow-names>\"\n\n#### Scenario: Update refreshes existing workflows\n- **WHEN** user runs `openspec update`\n- **AND** workflows are already installed in the project\n- **THEN** the system SHALL refresh those workflow files with latest templates\n- **THEN** the system SHALL display: \"Updated: <workflow-names>\"\n\n#### Scenario: Update with no changes needed\n- **WHEN** user runs `openspec update`\n- **AND** installed workflows match global config\n- **AND** all templates are current\n- **AND** delivery setting matches installed files\n- **THEN** the system SHALL display: \"Already up to date.\"\n\n#### Scenario: Profile or delivery drift with current templates\n- **WHEN** user runs `openspec update`\n- **AND** workflow templates are current for the installed skills\n- **AND** project files do not match current profile and/or delivery config\n- **THEN** the system SHALL treat this as an update-required state (not \"Already up to date.\")\n- **THEN** the system SHALL add/remove files to match current profile and delivery settings\n\n#### Scenario: Update summary output\n- **WHEN** update completes with changes\n- **THEN** the system SHALL display a summary:\n  - \"Added: propose, explore\" (new workflows installed)\n  - \"Updated: apply, archive\" (existing workflows refreshed)\n  - \"Removed: 4 command files\" (if delivery changed)\n- **THEN** the system SHALL list affected tools: \"Tools: Claude Code, Cursor\"\n\n### Requirement: Update respects delivery setting\nThe update command SHALL add or remove files based on the delivery setting.\n\n#### Scenario: Delivery changed to skills-only\n- **WHEN** user runs `openspec update`\n- **AND** global config specifies `delivery: skills`\n- **AND** project has command files installed\n- **THEN** the system SHALL delete command files for workflows in the profile\n- **THEN** the system SHALL generate/update skill files only\n- **THEN** the system SHALL display: \"Removed: <count> command files (delivery: skills)\"\n\n#### Scenario: Delivery changed to commands-only\n- **WHEN** user runs `openspec update`\n- **AND** global config specifies `delivery: commands`\n- **AND** project has skill files installed\n- **THEN** the system SHALL delete skill directories for workflows in the profile\n- **THEN** the system SHALL generate/update command files only\n- **THEN** the system SHALL display: \"Removed: <count> skill directories (delivery: commands)\"\n\n#### Scenario: Delivery is both\n- **WHEN** user runs `openspec update`\n- **AND** global config specifies `delivery: both`\n- **THEN** the system SHALL generate/update both skill and command files\n\n### Requirement: Update detects configured tools from skills or commands\nThe update command SHALL treat a tool as configured if it has either generated skill files or generated command files.\n\n#### Scenario: Commands-only installation\n- **WHEN** user runs `openspec update`\n- **AND** a tool has generated OpenSpec command files\n- **AND** that tool has no OpenSpec skill files (commands-only delivery)\n- **THEN** the tool SHALL still be treated as configured\n- **THEN** the system SHALL apply profile and delivery sync for that tool\n\n### Requirement: One-time migration for existing users\nThe update command SHALL detect existing users (no `profile` in global config + existing workflows) and migrate them to `custom` profile before applying updates.\n\n#### Scenario: First update after upgrade (existing user)\n- **WHEN** user runs `openspec update`\n- **AND** global config does not contain a `profile` field\n- **AND** project has existing workflow files installed\n- **THEN** the system SHALL scan installed workflows across all tool directories in the project\n- **THEN** the system SHALL only match workflow names present in `ALL_WORKFLOWS` constant (ignoring user-created custom skills)\n- **THEN** the system SHALL take the union of detected workflow names across all tools\n- **THEN** the system SHALL write to global config: `profile: \"custom\"`, `delivery: \"both\"`, `workflows: [<detected>]`\n- **THEN** the system SHALL display: \"Migrated: custom profile with <count> workflows (<workflow-names>)\"\n- **THEN** the system SHALL display: \"New in this version: /opsx:propose (combines new + ff). Try 'openspec config profile core' for the streamlined 4-workflow experience.\"\n- **THEN** the system SHALL proceed with normal update logic (using the migrated config)\n- **THEN** the result SHALL be template refresh only (no workflows added or removed)\n\n#### Scenario: Migration with partial workflows (user manually removed some)\n- **WHEN** user runs `openspec update`\n- **AND** global config does not contain a `profile` field\n- **AND** project has fewer than the original 10 workflows installed\n- **THEN** the system SHALL migrate with only the workflows that are actually present\n- **THEN** the migrated `workflows` array SHALL reflect the user's current state, not the original set\n\n#### Scenario: Migration with multiple tools having different workflow sets\n- **WHEN** user runs `openspec update`\n- **AND** project has multiple tools configured (e.g., Claude Code, Cursor)\n- **AND** different tools have different workflows installed\n- **THEN** the system SHALL take the union of all detected workflows across all tools\n- **THEN** the migrated `workflows` array SHALL include any workflow that exists in at least one tool\n\n#### Scenario: No migration needed (profile already set)\n- **WHEN** user runs `openspec update`\n- **AND** global config already contains a `profile` field\n- **THEN** the system SHALL NOT perform migration\n- **THEN** the system SHALL proceed with normal update logic using existing config\n\n#### Scenario: No migration needed (no existing workflows)\n- **WHEN** user runs `openspec update`\n- **AND** global config does not contain a `profile` field\n- **AND** project has no existing workflow files\n- **THEN** the system SHALL NOT perform migration\n- **THEN** the system SHALL use `core` profile defaults\n\n#### Scenario: Migration is idempotent\n- **WHEN** user runs `openspec update` multiple times\n- **THEN** migration SHALL only occur on the first run (when `profile` field is absent)\n- **THEN** subsequent runs SHALL use the existing global config without re-scanning\n\n#### Scenario: Non-interactive migration\n- **WHEN** user runs `openspec update` non-interactively (e.g., in CI)\n- **AND** migration is triggered\n- **THEN** the system SHALL perform migration without prompting\n- **THEN** the system SHALL display the migration summary to stdout\n\n### Requirement: Update detects new tool directories\nThe update command SHALL notify the user if new AI tool directories are detected that aren't currently configured.\n\n#### Scenario: New tool directory detected\n- **WHEN** user runs `openspec update`\n- **AND** a new tool directory is detected (e.g., `.windsurf/` exists but Windsurf is not configured)\n- **THEN** the system SHALL display: \"Detected new tool: Windsurf. Run 'openspec init' to add it.\"\n- **THEN** the system SHALL NOT automatically add the new tool\n- **THEN** the system SHALL proceed with update for currently configured tools only\n\n#### Scenario: Multiple new tool directories detected\n- **WHEN** user runs `openspec update`\n- **AND** multiple new tool directories are detected (e.g., `.github/` and `.windsurf/` exist but neither tool is configured)\n- **THEN** the system SHALL display one consolidated message listing all detected tools, for example: \"Detected new tools: GitHub Copilot, Windsurf. Run 'openspec init' to add them.\"\n- **THEN** the system SHALL NOT automatically add any new tools\n- **THEN** the system SHALL proceed with update for currently configured tools only\n\n#### Scenario: No new tool directories\n- **WHEN** user runs `openspec update`\n- **AND** no new tool directories are detected\n- **THEN** the system SHALL NOT display any tool detection message\n\n### Requirement: Update requires an OpenSpec project\nThe update command SHALL only run inside an initialized OpenSpec project.\n\n#### Scenario: Update outside a project\n- **WHEN** user runs `openspec update`\n- **AND** no `openspec/` directory exists in the current working directory\n- **THEN** the system SHALL display: \"No OpenSpec project found. Run 'openspec init' to set up.\"\n- **THEN** the system SHALL exit with code 1\n\n### Requirement: Extra workflows synchronized to active profile\nThe update command SHALL remove workflow files that are no longer selected in the current profile.\n\n#### Scenario: Deselected workflows from previous profile\n- **WHEN** user runs `openspec update`\n- **AND** project has workflows not in current profile (e.g., user switched from custom to core or deselected workflows via `openspec config profile`)\n- **THEN** the system SHALL delete skill and command workflow files for deselected workflows (respecting active delivery mode)\n- **THEN** the system SHALL keep only workflows currently selected in profile\n\n#### Scenario: Delivery change with extra workflows\n- **WHEN** user runs `openspec update`\n- **AND** delivery changed (e.g., `both` → `skills`)\n- **AND** project has extra workflows not in current profile\n- **THEN** the system SHALL delete files for extra workflows that match the removed delivery type\n- **THEN** for example: if switching to `skills`, all command files are deleted (including for extra workflows)\n"
  },
  {
    "path": "openspec/changes/simplify-skill-installation/specs/profiles/spec.md",
    "content": "## Purpose\n\nProfiles SHALL define which workflows to install, enabling a streamlined core experience for new users while allowing power users to customize their workflow selection.\n\n## ADDED Requirements\n\n### Requirement: Profile definitions\nThe system SHALL support two workflow profiles: `core` and `custom`.\n\n#### Scenario: Core profile contents\n- **WHEN** profile is set to `core`\n- **THEN** the profile SHALL include workflows: `propose`, `explore`, `apply`, `archive`\n\n#### Scenario: Custom profile contents\n- **WHEN** profile is set to `custom`\n- **THEN** the profile SHALL include only the workflows specified in global config `workflows` array\n\n### Requirement: Delivery is independent of profile\nThe delivery setting SHALL control HOW workflows are installed (skills, commands, or both), separate from WHICH workflows are installed.\n\n#### Scenario: Delivery options\n- **WHEN** configuring delivery\n- **THEN** the system SHALL support three options: `both` (skills and commands), `skills` (skill files only), `commands` (command files only)\n\n#### Scenario: Both delivery\n- **WHEN** delivery is set to `both`\n- **THEN** the system SHALL install both skill files and command files for each workflow\n\n#### Scenario: Skills-only delivery\n- **WHEN** delivery is set to `skills`\n- **THEN** the system SHALL install only skill files for each workflow\n- **THEN** the system SHALL NOT install command files\n\n#### Scenario: Commands-only delivery\n- **WHEN** delivery is set to `commands`\n- **THEN** the system SHALL install only command files for each workflow\n- **THEN** the system SHALL NOT install skill files\n\n#### Scenario: Core profile with custom delivery\n- **WHEN** profile is set to `core`\n- **AND** delivery is set to `skills`\n- **THEN** the system SHALL install core workflows as skills only (no commands)\n\n#### Scenario: Delivery defaults\n- **WHEN** delivery is not set in global config\n- **THEN** the system SHALL default to `both`\n\n### Requirement: Profile configuration via interactive picker\nThe system SHALL provide an interactive picker for configuring profiles.\n\n#### Scenario: Interactive profile configuration\n- **WHEN** user runs `openspec config profile`\n- **THEN** the system SHALL display an interactive picker with:\n  - Delivery selection: `skills`, `commands`, `both`\n  - Workflow toggles for all available workflows\n- **THEN** the system SHALL pre-select current config values\n- **THEN** on confirmation, the system SHALL update global config\n- **THEN** the system SHALL set profile to `custom` if selected workflows differ from core defaults\n- **THEN** the system SHALL set profile to `core` if selected workflows match core defaults exactly (propose, explore, apply, archive), regardless of delivery setting\n- **THEN** the system SHALL NOT modify any project files\n- **THEN** the system SHALL display: \"Config updated. Run `openspec update` in your projects to apply.\"\n\n#### Scenario: Core preset shortcut\n- **WHEN** user runs `openspec config profile core`\n- **THEN** the system SHALL set profile to `core`\n- **THEN** the system SHALL set workflows to `['propose', 'explore', 'apply', 'archive']`\n- **THEN** the system SHALL NOT change the delivery setting (preserves user preference)\n- **THEN** the system SHALL NOT modify any project files\n- **THEN** the system SHALL display: \"Config updated. Run `openspec update` in your projects to apply.\"\n- **THEN** the new profile takes effect on the next `openspec init` or `openspec update` run\n\n#### Scenario: Config profile run inside a project\n- **WHEN** user runs `openspec config profile` inside an OpenSpec project directory\n- **THEN** after updating global config, the system SHALL prompt: \"Apply to this project now? (y/n)\"\n- **WHEN** user confirms\n- **THEN** the system SHALL run `openspec update` automatically\n- **THEN** the system SHALL still display: \"Run `openspec update` in your other projects to apply.\"\n\n#### Scenario: Config profile - user declines apply\n- **WHEN** user runs `openspec config profile` inside an OpenSpec project directory\n- **AND** user declines the \"Apply to this project now?\" prompt\n- **THEN** the system SHALL display: \"Config updated. Run `openspec update` in your projects to apply.\"\n- **THEN** the system SHALL exit successfully without modifying project files\n\n#### Scenario: Config profile non-interactive\n- **WHEN** user runs `openspec config profile` non-interactively (e.g., in CI, no TTY)\n- **THEN** the system SHALL display an error: \"Interactive mode required. Use `openspec config profile core` or set config via environment/flags.\"\n- **THEN** the system SHALL exit with code 1\n\n### Requirement: Profile settings stored in global config\nProfile and delivery settings SHALL be stored in the existing global config file (`~/.config/openspec/config.json`) alongside telemetry and feature flags.\n\n#### Scenario: Config schema\n- **WHEN** reading profile configuration\n- **THEN** the config SHALL contain `profile` (core|custom), `delivery` (both|skills|commands), and optionally `workflows` (array of workflow names)\n\n#### Scenario: Schema evolution\n- **WHEN** loading config without profile/delivery fields\n- **THEN** the system SHALL use defaults (profile=core, delivery=both)\n- **AND** existing config fields (telemetry, featureFlags) SHALL be preserved\n\n#### Scenario: Config list displays profile settings\n- **WHEN** user runs `openspec config list`\n- **THEN** the system SHALL display profile, delivery, and workflows settings\n- **AND** SHALL indicate which values are defaults vs explicitly set\n\n### Requirement: Config is global, projects are explicit\nConfig changes SHALL NOT automatically propagate to projects.\n\n#### Scenario: Config update does not modify projects\n- **WHEN** user updates config via `openspec config profile`\n- **THEN** the system SHALL only update global config (`~/.config/openspec/config.json`)\n- **THEN** the system SHALL NOT modify any project skill/command files\n- **THEN** existing projects retain their current workflow files until user runs `openspec update`\n\n### Requirement: Config changes applied via update command\nThe existing `openspec update` command SHALL apply the current global config to a project. See `specs/cli-update/spec.md` for detailed update behavior.\n\n#### Scenario: Config changes require explicit project sync\n- **WHEN** user updates profile or delivery via `openspec config profile`\n- **THEN** the global config SHALL be updated immediately\n- **AND** project files SHALL remain unchanged until `openspec update` is run for that project\n\n### Requirement: Profile defaults\nThe system SHALL use `core` as the default profile for new users, while preserving existing users' workflows via migration.\n\n#### Scenario: No global config exists (new user)\n- **WHEN** global config file does not exist\n- **AND** no existing workflows are installed in the project\n- **THEN** the system SHALL behave as if profile is `core`\n\n#### Scenario: Global config exists but profile field absent (new user)\n- **WHEN** global config file exists but does not contain a `profile` field\n- **AND** no existing workflows are installed in the project\n- **THEN** the system SHALL behave as if profile is `core`\n\n#### Scenario: Profile field absent with existing workflows (existing user migration)\n- **WHEN** global config does not contain a `profile` field\n- **AND** the `update` command detects existing workflow files in the project\n- **THEN** the system SHALL perform one-time migration (see `specs/cli-update/spec.md` for details)\n- **THEN** the system SHALL set profile to `custom` with the detected workflows\n- **THEN** the system SHALL NOT add or remove any workflow files during migration\n"
  },
  {
    "path": "openspec/changes/simplify-skill-installation/specs/propose-workflow/spec.md",
    "content": "## Purpose\n\nThe propose workflow SHALL combine change creation and artifact generation into a single command, reducing friction for new users while teaching them the OpenSpec workflow through embedded guidance.\n\n## ADDED Requirements\n\n### Requirement: Propose workflow creation\nThe system SHALL provide a `propose` workflow that creates a change and generates all artifacts in one step.\n\n#### Scenario: Basic propose invocation\n- **WHEN** user invokes `/opsx:propose \"add user authentication\"`\n- **THEN** the system SHALL create a change directory with kebab-case name\n- **THEN** the system SHALL create `.openspec.yaml` in the change directory (via `openspec new change`)\n- **THEN** the system SHALL generate all artifacts needed for implementation: proposal.md, design.md, specs/, tasks.md\n\n#### Scenario: Propose with existing change name\n- **WHEN** user invokes `/opsx:propose` with a name that already exists\n- **THEN** the system SHALL ask if user wants to continue existing change or create new\n- **THEN** if \"continue\": the system SHALL resume artifact generation from last completed state\n- **THEN** if \"create new\": the system SHALL prompt for a new name\n- **THEN** in non-interactive mode: the system SHALL fail with error suggesting to use a different name\n\n### Requirement: Propose workflow onboarding UX\nThe `propose` workflow SHALL include explanatory output to help new users understand the process.\n\n#### Scenario: First-time user guidance\n- **WHEN** user invokes `/opsx:propose`\n- **THEN** the system SHALL explain what artifacts will be created (proposal.md, design.md, specs/, tasks.md)\n- **THEN** the system SHALL indicate next step (`/opsx:apply` to implement)\n\n#### Scenario: Artifact creation progress\n- **WHEN** the system creates each artifact\n- **THEN** the system SHALL show progress (e.g., \"✓ Created proposal.md\")\n\n### Requirement: Propose workflow combines new and ff\nThe `propose` workflow SHALL perform the same operations as running `new` followed by `ff`.\n\n#### Scenario: Equivalent to new + ff\n- **WHEN** user invokes `/opsx:propose \"feature name\"`\n- **THEN** the result SHALL be functionally equivalent to invoking `/opsx:new \"feature-name\"` followed by `/opsx:ff feature-name`\n- **THEN** the same directory structure and artifacts SHALL be created\n- **THEN** console output MAY differ (propose includes onboarding explanations)\n"
  },
  {
    "path": "openspec/changes/simplify-skill-installation/tasks.md",
    "content": "## 1. Global Config Extension\n\n- [x] 1.1 Extend `src/core/global-config.ts` schema with `profile`, `delivery`, and `workflows` fields\n- [x] 1.2 Add TypeScript types for profile (`core` | `custom`), delivery (`both` | `skills` | `commands`), and workflows (string array)\n- [x] 1.3 Update `GlobalConfig` interface and defaults (profile=`core`, delivery=`both`)\n- [x] 1.4 Update existing `readGlobalConfig()` to handle missing new fields with defaults\n- [x] 1.5 Add tests for schema evolution (existing config without new fields)\n\n## 2. Profile System\n\n- [x] 2.1 Create `src/core/profiles.ts` with profile definitions (core, custom)\n- [x] 2.2 Define `CORE_WORKFLOWS` constant: `['propose', 'explore', 'apply', 'archive']`\n- [x] 2.3 Define `ALL_WORKFLOWS` constant with all 11 workflows\n- [x] 2.4 Add `COMMAND_IDS` constant to `src/core/shared/tool-detection.ts` (parallel to existing SKILL_NAMES)\n- [x] 2.5 Implement `getProfileWorkflows(profile, customWorkflows?)` resolver function\n- [x] 2.6 Add tests for profile resolution\n\n## 3. Config Profile Command (Interactive Picker)\n\n- [x] 3.1 Add `config profile` subcommand to `src/commands/config.ts`\n- [x] 3.2 Implement interactive picker UI with delivery selection (skills/commands/both)\n- [x] 3.3 Implement interactive picker UI with workflow toggles\n- [x] 3.4 Pre-select current config values in picker\n- [x] 3.5 Update global config on confirmation (config-only, no file regeneration)\n- [x] 3.6 Display post-update message: \"Config updated. Run `openspec update` in your projects to apply.\"\n- [x] 3.7 Detect if running inside an OpenSpec project and offer to run update automatically\n- [x] 3.8 Implement `config profile core` preset shortcut (preserves delivery setting)\n- [x] 3.9 Handle non-interactive mode: error with helpful message\n- [x] 3.10 Update `openspec config list` to display profile, delivery, and workflows settings (indicate defaults vs explicit)\n- [x] 3.11 Add tests for config profile command and config list output\n\n## 4. Available Tools Detection\n\n- [x] 4.1 Create `src/core/available-tools.ts` (separate from existing `tool-detection.ts`)\n- [x] 4.2 Implement `getAvailableTools(projectPath)` that scans for AI tool directories (`.claude/`, `.cursor/`, etc.)\n- [x] 4.3 Use `AI_TOOLS` config to map directory names to tool IDs\n- [x] 4.4 Add tests for available tools detection including cross-platform paths\n\n## 5. Propose Workflow Template\n\n- [x] 5.1 Create `src/core/templates/workflows/propose.ts`\n- [x] 5.2 Implement skill template that combines new + ff behavior\n- [x] 5.3 Ensure propose creates `.openspec.yaml` via `openspec new change` before generating artifacts\n- [x] 5.4 Add onboarding-style explanatory output to template\n- [x] 5.5 Implement command template for propose\n- [x] 5.6 Export templates from `src/core/templates/skill-templates.ts`\n- [x] 5.7 Add `openspec-propose` to `SKILL_NAMES` in `src/core/shared/tool-detection.ts`\n- [x] 5.8 Add `propose` to command templates in `src/core/shared/skill-generation.ts`\n- [x] 5.9 Add `propose` to `COMMAND_IDS` in `src/core/shared/tool-detection.ts`\n- [x] 5.10 Add tests for propose template (creates change, generates artifacts, equivalent to new + ff)\n\n## 6. Conditional Skill/Command Generation\n\n- [x] 6.1 Update `getSkillTemplates()` to accept profile filter parameter\n- [x] 6.2 Update `getCommandTemplates()` to accept profile filter parameter\n- [x] 6.3 Update `generateSkillsAndCommands()` in init.ts to respect delivery setting\n- [x] 6.4 Add logic to skip skill generation when delivery is 'commands'\n- [x] 6.5 Add logic to skip command generation when delivery is 'skills'\n- [x] 6.6 Add tests for conditional generation\n\n## 7. Init Flow Updates\n\n- [x] 7.1 Update init to call `getAvailableTools()` first\n- [x] 7.2 Update init to read global config for profile/delivery defaults\n- [x] 7.3 Add migration check to init: call shared `migrateIfNeeded()` before profile resolution\n- [x] 7.4 Change tool selection to show pre-selected detected tools\n- [x] 7.5 Apply configured profile directly in init (no profile confirmation prompt)\n- [x] 7.6 Update success message to show `/opsx:propose` prompt (only if propose is in the active profile)\n- [x] 7.7 Add `--profile` flag to override global config\n- [x] 7.8 Update non-interactive mode to use defaults without prompting\n- [x] 7.9 Add tests for init flow with various scenarios (including migration on re-init and custom profile behavior)\n\n## 8. Update Command (Profile Support + Migration)\n\n- [x] 8.1 Modify existing `src/commands/update.ts` to read global config for profile/delivery/workflows\n- [x] 8.2 Implement shared `scanInstalledWorkflows(projectPath, tools)` — scan tool directories, match only against `ALL_WORKFLOWS` constant, return union across tools\n- [x] 8.3 Implement shared `migrateIfNeeded(projectPath, tools)` — one-time migration logic used by both `init` and `update`\n- [x] 8.4 Display migration message: \"Migrated: custom profile with N workflows\" + \"New in this version: /opsx:propose. Try 'openspec config profile core' for the streamlined experience.\"\n- [x] 8.5 Add project check: exit with error if no `openspec/` directory exists\n- [x] 8.6 Add logic to detect which workflows are in config but not installed (to add)\n- [x] 8.7 Add logic to detect which workflows are installed and need refresh (to update)\n- [x] 8.8 Respect delivery setting: generate only skills if `skills`, only commands if `commands`\n- [x] 8.9 Delete files when delivery changes: remove commands if `skills`, remove skills if `commands`\n- [x] 8.10 Generate new workflow files for missing workflows in profile\n- [x] 8.11 Display summary: \"Added: X, Y\" / \"Updated: Z\" / \"Removed: N files\" / \"Already up to date.\"\n- [x] 8.12 List affected tools in output: \"Tools: Claude Code, Cursor\"\n- [x] 8.13 Detect new tool directories not currently configured and display hint to re-init\n- [x] 8.14 Add tests for migration scenarios (existing user, partial workflows, multiple tools, idempotent, custom skills ignored)\n- [x] 8.15 Add tests for update command with profile scenarios (including delivery changes, outside-project error, new tool detection)\n\n## 9. Tool Selection UX Fix\n\n- [x] 9.1 Update `src/prompts/searchable-multi-select.ts` keybindings\n- [x] 9.2 Change Space to toggle selection\n- [x] 9.3 Change Enter to confirm selection\n- [x] 9.4 Remove Tab-to-confirm behavior\n- [x] 9.5 Add hint text \"Space to toggle, Enter to confirm\"\n- [x] 9.6 Add tests for keybinding behavior\n\n## 10. Scaffolding Verification\n\n- [x] 10.1 Verify `openspec new change` creates `.openspec.yaml` with schema and created fields\n\n<!-- Note: 10.2 and 10.3 below are potential follow-up work, not core to this change -->\n<!-- - [ ] 10.2 Update ff skill to verify `.openspec.yaml` exists after `openspec new change` -->\n<!-- - [ ] 10.3 Add guardrail to skills: \"Never manually create files in openspec/changes/ - use openspec new change\" -->\n\n## 11. Template Next-Step Guidance\n\n- [x] 11.1 Audit all templates for hardcoded cross-workflow command references (e.g., `/opsx:propose`)\n- [x] 11.2 Replace any specific command references with generic concept-based guidance (e.g., \"create a change proposal\")\n- [x] 11.3 Review explore → propose transition UX (see `openspec/explorations/explore-workflow-ux.md` for open questions)\n\n## 12. Integration & Manual Testing\n\n- [x] 12.1 Run full test suite and fix any failures\n- [x] 12.2 Test on Windows (or verify CI passes on Windows)\n- [x] 12.3 Test end-to-end flow: init → propose → apply → archive\n- [x] 12.4 Update CLI help text for new commands\n- [x] 12.5 Manual: interactive init — verify detected tools are pre-selected, confirm prompt works, success message is correct\n- [x] 12.6 Manual: `openspec config profile` picker — verify delivery toggle, workflow toggles, pre-selection of current values, core preset shortcut\n- [x] 12.7 Manual: init with custom profile — verify init proceeds without profile confirmation prompt\n- [x] 12.8 Manual: delivery change via update — verify correct files are deleted/created when switching between skills/commands/both\n- [x] 12.9 Manual: migration flow — run update on a pre-existing project with no profile in config, verify migration message and resulting config\n\n## 13. Post-Implementation Hardening (Review Follow-up)\n\n- [x] 13.1 Ensure `update` treats profile/delivery drift as update-required even when templates are current\n- [x] 13.2 Ensure `update` recognizes command-only installations as configured tools\n- [x] 13.3 Ensure `init` validates `--profile` values and errors on invalid overrides\n- [x] 13.4 Ensure re-running `init` applies delivery cleanup (removes files not matching current delivery mode)\n- [x] 13.5 Add/adjust regression tests for config drift sync, command-only detection, invalid profile override, and re-init delivery cleanup\n"
  },
  {
    "path": "openspec/changes/unify-template-generation-pipeline/.openspec.yaml",
    "content": "schema: spec-driven\ncreated: 2026-02-16\n"
  },
  {
    "path": "openspec/changes/unify-template-generation-pipeline/design.md",
    "content": "## Context\n\nOpenSpec currently has strong building blocks (workflow templates, command adapters, generation helpers), but orchestration concerns are distributed:\n\n- Workflow definitions and projection lists are maintained separately\n- Tool support is represented in multiple places with partial overlap\n- Transforms can happen at template rendering time and inside individual adapters\n- `init`/`update`/legacy-upgrade each run similar write pipelines with slight differences\n\nThe design goal is to preserve current behavior while making extension points explicit and deterministic.\n\n## Goals / Non-Goals\n\n**Goals:**\n- Define one canonical source for workflow content and metadata\n- Make tool/agent-specific behavior explicit and centrally discoverable\n- Keep command adapters as the formatting boundary for tool syntax differences\n- Consolidate artifact generation/write orchestration into one reusable engine\n- Improve correctness with enforceable validation and parity tests\n\n**Non-Goals:**\n- Redesigning command semantics or workflow instruction content\n- Changing user-facing CLI command names/flags in this proposal\n- Merging unrelated legacy cleanup behavior beyond artifact generation reuse\n\n## Decisions\n\n### 1. Canonical `WorkflowManifest`\n\n**Decision**: Represent each workflow once in a manifest entry containing canonical skill and command definitions plus metadata defaults.\n\nSuggested shape:\n\n```ts\ninterface WorkflowManifestEntry {\n  workflowId: string; // e.g. 'explore', 'ff', 'onboard'\n  skillDirName: string; // e.g. 'openspec-explore'\n  skill: SkillTemplate;\n  command?: CommandTemplate;\n  commandId?: string;\n  tags: string[];\n  compatibility: string;\n}\n```\n\n**Rationale**:\n- Eliminates drift between multiple hand-maintained arrays\n- Makes workflow completeness testable in one place\n- Keeps split workflow modules while centralizing registration\n\n### 2. `ToolProfileRegistry` for capability wiring\n\n**Decision**: Add a tool profile layer that maps tool IDs to generation capabilities and behavior.\n\nSuggested shape:\n\n```ts\ninterface ToolProfile {\n  toolId: string;\n  skillsDir?: string;\n  commandAdapterId?: string;\n  transforms: string[];\n}\n```\n\n**Rationale**:\n- Prevents capability drift between `AI_TOOLS`, adapter registry, and detection logic\n- Allows intentional \"skills-only\" tools without implicit special casing\n- Provides one place to answer \"what does this tool support?\"\n\n### 3. First-class transform pipeline\n\n**Decision**: Model transforms as ordered plugins with scope + phase + applicability.\n\nSuggested shape:\n\n```ts\ninterface ArtifactTransform {\n  id: string;\n  scope: 'skill' | 'command' | 'both';\n  phase: 'preAdapter' | 'postAdapter';\n  priority: number;\n  applies(ctx: GenerationContext): boolean;\n  transform(content: string, ctx: GenerationContext): string;\n}\n```\n\nExecution order:\n1. Render canonical content from manifest\n2. Apply matching `preAdapter` transforms\n3. For commands, run adapter formatting\n4. Apply matching `postAdapter` transforms\n5. Validate and write\n\n**Rationale**:\n- Keeps adapters focused on tool formatting, not scattered behavioral rewrites\n- Makes agent-specific modifications explicit and testable\n- Replaces ad-hoc transform calls in `init`/`update`\n\n### 4. Shared `ArtifactSyncEngine`\n\n**Decision**: Introduce a single orchestration engine used by all generation entry points.\n\nResponsibilities:\n- Build generation plan from `(workflows × selected tools × artifact kinds)`\n- Run render/transform/adapter pipeline\n- Validate outputs\n- Write files and return result summary\n\n**Rationale**:\n- Removes duplicated loops and divergent behavior across init/update paths\n- Enables dry-run and future preview features without re-implementing logic\n- Improves reliability of updates and legacy migrations\n\n### 5. Validation + parity guardrails\n\n**Decision**: Add strict checks in tests (and optional runtime assertions in dev builds) for:\n\n- Required skill metadata fields (`license`, `compatibility`, `metadata`) present for all manifest entries\n- Projection consistency (skills, commands, detection names derived from manifest)\n- Tool profile consistency (adapter existence, expected capabilities)\n- Golden/parity output for key workflows/tools\n\n**Rationale**:\n- Converts prior review issues into enforced invariants\n- Preserves output fidelity while enabling internal refactors\n- Makes regressions obvious during CI\n\n## Risks / Trade-offs\n\n**Risk: Migration complexity**\nA broad refactor can destabilize generation paths.\n→ Mitigation: introduce in phases with parity tests before cutover.\n\n**Risk: Over-abstraction**\nToo many layers can obscure simple flows.\n→ Mitigation: keep interfaces minimal and colocate registries with generation code.\n\n**Trade-off: More upfront structure**\nAdding manifest/profile/transform registries increases conceptual surface area.\n→ Accepted: this cost is offset by reduced drift and easier extension.\n\n## Implementation Approach\n\n1. Build manifest + profile + transform types and registries behind current public API\n2. Rewire `getSkillTemplates`/`getCommandContents` to derive from manifest\n3. Introduce `ArtifactSyncEngine` and switch `init` to use it with parity checks\n4. Switch `update` and legacy upgrade flows to same engine\n5. Remove duplicate/hardcoded lists after parity is green\n"
  },
  {
    "path": "openspec/changes/unify-template-generation-pipeline/proposal.md",
    "content": "## Why\n\nThe recent split of `skill-templates.ts` into workflow modules improved readability, but the generation pipeline is still fragmented across multiple layers:\n\n- Workflow definitions are split from projection logic (`getSkillTemplates`, `getCommandTemplates`, `getCommandContents`)\n- Tool capability and compatibility are spread across `AI_TOOLS`, `CommandAdapterRegistry`, and hardcoded lists like `SKILL_NAMES`\n- Agent/tool-specific transformations (for example OpenCode command reference rewrites) are applied in different places (`init`, `update`, and adapter code)\n- Artifact writing logic is duplicated across `init`, `update`, and legacy-upgrade flow\n\nThis fragmentation creates drift risk (missing exports, missing metadata parity, mismatched counts/support) and makes future workflow/tool additions slower and less predictable.\n\n## What Changes\n\n- Introduce a canonical `WorkflowManifest` as the single source of truth for all workflow artifacts\n- Introduce a `ToolProfileRegistry` to centralize tool capabilities (skills path, command adapter, transforms)\n- Introduce a first-class transform pipeline with explicit phases (`preAdapter`, `postAdapter`) and scopes (`skill`, `command`, `both`)\n- Introduce a shared `ArtifactSyncEngine` used by `init`, `update`, and legacy upgrade paths\n- Add strict validation and test guardrails to preserve fidelity during migration and future changes\n\n## Capabilities\n\n### New Capabilities\n\n- `template-artifact-pipeline`: Unified workflow manifest, tool profile registry, transform pipeline, and sync engine for skill/command generation\n\n### Modified Capabilities\n\n- `command-generation`: Extended to support ordered transform phases around adapter rendering\n- `cli-init`: Uses shared artifact sync orchestration instead of bespoke loops\n- `cli-update`: Uses shared artifact sync orchestration instead of bespoke loops\n\n## Impact\n\n- **Primary refactor area**:\n  - `src/core/templates/*`\n  - `src/core/shared/skill-generation.ts`\n  - `src/core/command-generation/*`\n  - `src/core/init.ts`\n  - `src/core/update.ts`\n  - `src/core/shared/tool-detection.ts`\n- **Testing additions**:\n  - Manifest completeness tests (workflows, required metadata, projection parity)\n  - Transform ordering and applicability tests\n  - End-to-end parity tests for generated skill/command outputs across tools\n- **User-facing behavior**:\n  - No new CLI surface area required\n  - Existing generated artifacts remain behaviorally equivalent unless explicitly changed in future deltas\n"
  },
  {
    "path": "openspec/changes/unify-template-generation-pipeline/specs/template-artifact-pipeline/spec.md",
    "content": "# template-artifact-pipeline Specification\n\n## Purpose\n\nDefine a unified architecture for workflow template generation that centralizes workflow definitions, tool capability wiring, transform execution, and artifact synchronization while preserving output fidelity.\n\n## ADDED Requirements\n\n### Requirement: Canonical Workflow Manifest\n\nThe system SHALL define a canonical workflow manifest as the single source of truth for generated skill and command artifacts.\n\n#### Scenario: Register workflow once\n\n- **WHEN** a workflow (for example `explore`, `ff`, or `onboard`) is added or modified\n- **THEN** its canonical definition SHALL be registered once in the workflow manifest\n- **AND** skill/command projections SHALL be derived from that manifest\n- **AND** duplicate hand-maintained lists SHALL NOT be required\n\n#### Scenario: Required skill metadata\n\n- **WHEN** defining a workflow skill entry in the manifest\n- **THEN** it SHALL include required metadata fields (`license`, `compatibility`, and `metadata`)\n- **AND** generation SHALL use those values or explicit defaults in a consistent way for all workflows\n\n### Requirement: Tool Profile Registry\n\nThe system SHALL define a tool profile registry that captures generation capabilities per tool.\n\n#### Scenario: Resolve tool capabilities\n\n- **WHEN** generating artifacts for a selected tool\n- **THEN** the system SHALL resolve a tool profile that declares skill path capability, command adapter linkage, and transform set\n- **AND** tools with skills support but no command adapter SHALL be handled explicitly without implicit fallback behavior\n\n#### Scenario: Capability consistency validation\n\n- **WHEN** running validation checks\n- **THEN** the system SHALL detect mismatches between configured tools, profile definitions, and registered adapters\n- **AND** fail with actionable errors in development/CI\n\n### Requirement: Ordered Transform Pipeline\n\nThe system SHALL support ordered artifact transforms with explicit scope and phase semantics.\n\n#### Scenario: Execute pre-adapter and post-adapter transforms\n\n- **WHEN** generating an artifact\n- **THEN** matching transforms SHALL execute in deterministic order based on phase and priority\n- **AND** `preAdapter` transforms SHALL run before command adapter formatting\n- **AND** `postAdapter` transforms SHALL run after adapter formatting\n\n#### Scenario: Apply tool-specific rewrites declaratively\n\n- **WHEN** a tool requires instruction rewrites (for example command reference syntax changes)\n- **THEN** those rewrites SHALL be implemented as registered transforms with explicit applicability predicates\n- **AND** generation entry points SHALL NOT implement ad-hoc rewrite logic\n\n### Requirement: Shared Artifact Sync Engine\n\nThe system SHALL provide a shared artifact sync engine used by all generation entry points.\n\n#### Scenario: Init and update use same engine\n\n- **WHEN** `openspec init` or `openspec update` writes skills/commands\n- **THEN** both flows SHALL use the same orchestration engine for planning, rendering, validating, and writing artifacts\n- **AND** behavior differences SHALL be configuration-driven rather than separate duplicated loops\n\n#### Scenario: Legacy upgrade path reuses engine\n\n- **WHEN** legacy cleanup triggers artifact regeneration\n- **THEN** the regeneration path SHALL use the same shared engine\n- **AND** generated outputs SHALL follow the same transform and validation rules\n\n### Requirement: Fidelity Guardrails\n\nThe system SHALL enforce guardrails that prevent output drift during refactors.\n\n#### Scenario: Projection parity checks\n\n- **WHEN** CI runs template generation tests\n- **THEN** it SHALL verify manifest-derived projections remain consistent (workflows, command IDs, skill directories)\n- **AND** detect missing exports or missing workflow registration\n\n#### Scenario: Output parity checks\n\n- **WHEN** running parity tests for representative workflow/tool combinations\n- **THEN** generated artifacts SHALL remain behaviorally equivalent to approved baselines unless intentionally changed\n- **AND** intentional changes SHALL be captured in explicit spec/proposal updates\n"
  },
  {
    "path": "openspec/changes/unify-template-generation-pipeline/tasks.md",
    "content": "## 1. Manifest Foundation\n\n- [ ] 1.1 Create canonical workflow manifest registry under `src/core/templates/`\n- [ ] 1.2 Define shared manifest types for workflow IDs, skill metadata, and optional command descriptors\n- [ ] 1.3 Migrate existing workflow registration (`getSkillTemplates`, `getCommandTemplates`, `getCommandContents`) to derive from the manifest\n- [ ] 1.4 Preserve existing external exports/API compatibility for `src/core/templates/skill-templates.ts`\n\n## 2. Tool Profile Layer\n\n- [ ] 2.1 Add `ToolProfile` types and `ToolProfileRegistry`\n- [ ] 2.2 Map all currently supported tools to explicit profile entries\n- [ ] 2.3 Wire profile lookups to command adapter resolution and skills path resolution\n- [ ] 2.4 Replace hardcoded detection arrays (for example `SKILL_NAMES`) with manifest-derived values\n\n## 3. Transform Pipeline\n\n- [ ] 3.1 Introduce transform interfaces (`scope`, `phase`, `priority`, `applies`, `transform`)\n- [ ] 3.2 Implement transform runner with deterministic ordering\n- [ ] 3.3 Migrate OpenCode command reference rewrite to transform pipeline\n- [ ] 3.4 Remove ad-hoc transform invocation from `init` and `update`\n\n## 4. Artifact Sync Engine\n\n- [ ] 4.1 Create shared artifact sync engine for generation planning + rendering + writing\n- [ ] 4.2 Integrate engine into `init` flow\n- [ ] 4.3 Integrate engine into `update` flow\n- [ ] 4.4 Integrate engine into legacy-upgrade artifact generation path\n\n## 5. Validation and Tests\n\n- [ ] 5.1 Add manifest completeness tests (metadata required fields, command IDs, dir names)\n- [ ] 5.2 Add tool-profile consistency tests (skillsDir support and adapter/profile alignment)\n- [ ] 5.3 Add transform applicability/order tests\n- [ ] 5.4 Expand parity tests for representative workflow/tool matrix\n- [ ] 5.5 Run full test suite and verify generated artifacts remain stable\n\n## 6. Cleanup and Documentation\n\n- [ ] 6.1 Remove superseded helper code and duplicate write loops after cutover\n- [ ] 6.2 Update internal developer docs for template generation architecture\n- [ ] 6.3 Document migration guardrails for future workflow/tool additions\n"
  },
  {
    "path": "openspec/config.yaml",
    "content": "schema: spec-driven\n\ncontext: |\n  Tech stack: TypeScript, Node.js (≥20.19.0), ESM modules\n  Package manager: pnpm\n  CLI framework: Commander.js\n\n  Cross-platform requirements:\n  - This tool runs on macOS, Linux, AND Windows\n  - Always use path.join() or path.resolve() for file paths - never hardcode slashes\n  - Never assume forward-slash path separators\n  - Tests must use path.join() for expected path values, not hardcoded strings\n  - Consider case sensitivity differences in file systems\n\nrules:\n  specs:\n    - Include scenarios for Windows path handling when dealing with file paths\n    - Requirements involving paths must specify cross-platform behavior\n    - Be explicit about mechanisms, not just outcomes (say HOW, not just WHAT)\n    - If we generate artifacts, specify deletion/modification by explicit list lookup, not pattern matching\n  tasks:\n    - Add Windows CI verification as a task when changes involve file paths\n    - Include cross-platform testing considerations\n  design:\n    - Document any platform-specific behavior or limitations\n    - Prefer Node.js path module over string manipulation for paths\n    - Use existing constants and lists - don't invent detection mechanisms\n    - Prefer explicit lookups over pattern matching or regex\n    - If we generate it, we track it by name in a constant\n"
  },
  {
    "path": "openspec/explorations/explore-workflow-ux.md",
    "content": "# Explore Workflow UX\n\n## Context\n\nThe explore workflow is part of the core loop (`propose`, `explore`, `apply`, `archive`). Users should be able to think through ideas in explore mode, then smoothly transition into a formal change proposal.\n\nCurrently, explore references `/opsx:new` and `/opsx:ff` which are being replaced with `/opsx:propose`. But beyond just updating references, there are deeper UX questions about how explore should work.\n\n## Open Questions\n\n### Exploration Artifacts\n\n1. **Should exploration be exportable to .md?**\n   - Currently explorations are ephemeral (just conversation)\n   - Would users benefit from saving exploration notes?\n\n2. **Where should exploration files live?**\n   - `openspec/explorations/<name>.md`?\n   - `openspec/changes/<change>/explorations/`?\n   - Somewhere else?\n\n3. **What should the format be?**\n   - Free-form markdown?\n   - Structured template (problem, insights, open questions)?\n   - Conversation transcript?\n\n### Multiple Explorations\n\n4. **Can a user have multiple explorations related to a change?**\n   - e.g., exploring auth approaches separately from UI approaches\n   - How would these relate to each other?\n\n5. **How do explorations relate to changes?**\n   - Before change exists: standalone exploration\n   - After change exists: exploration linked to change?\n\n### Lifecycle & Transitions\n\n6. **What happens before a change proposal exists?**\n   - Exploration is standalone\n   - When ready, user runs `/opsx:propose`\n   - Should exploration context automatically seed the proposal?\n\n7. **What happens after a change proposal exists?**\n   - Exploration can reference existing artifacts\n   - Should exploration be able to update artifacts directly?\n   - Or just inform the user what to update?\n\n8. **How does explore → propose transition work?**\n   - Manual: user copies insights and runs propose separately\n   - Semi-auto: explore offers \"Create proposal from this exploration?\"\n   - Auto: explore detects crystallization and proactively starts propose\n\n### Context Handoff\n\n9. **How do exploration insights flow into proposals?**\n   - User manually incorporates insights\n   - Exploration summary becomes input to propose prompt\n   - Exploration file linked/referenced in proposal\n\n10. **Should propose be able to read exploration files?**\n    - \"I see you explored authentication approaches. Using those insights...\"\n\n## Potential Approaches\n\n### Approach A: Ephemeral Explorations (Status Quo+)\n- Explorations remain conversational only\n- Just update references to `/opsx:propose`\n- User manually carries insights forward\n- **Pro:** Simple, no new artifacts\n- **Con:** Insights can be lost, no audit trail\n\n### Approach B: Optional Export\n- Add \"save exploration\" option at end\n- Saves to `openspec/explorations/<name>.md`\n- Propose can optionally read these for context\n- **Pro:** Opt-in complexity, preserves insights\n- **Con:** Another artifact type to manage\n\n### Approach C: Exploration as Proposal Seed\n- Exploration automatically saves structured notes\n- When transitioning to propose, notes become proposal input\n- **Pro:** Seamless handoff, context preserved\n- **Con:** More complexity, tight coupling\n\n### Approach D: Explorations Within Changes\n- Before change: standalone exploration\n- After change created: exploration notes live in `changes/<name>/explorations/`\n- Artifacts can reference exploration notes\n- **Pro:** Clear relationship to changes\n- **Con:** Where do pre-change explorations go?\n\n## Next Steps\n\n- [ ] User research: How do people actually use explore today?\n- [ ] Prototype: Try saving explorations and see if propose benefits\n- [ ] Decide: Pick an approach based on findings\n- [ ] Implement: Update explore workflow accordingly\n\n## Related\n\n- `openspec/changes/simplify-skill-installation/` - current change updating core workflows\n- `src/core/templates/workflows/explore.ts` - explore workflow template\n"
  },
  {
    "path": "openspec/explorations/workspace-architecture.md",
    "content": "# Workspace Exploration\n\n## Context\n\nWhile simplifying skill installation, we identified deeper questions about how profiles, config, and workspaces should work together. This doc captures what we've decided, what's open, and what needs research.\n\n**Update:** Initial exploration revealed that \"workspaces\" isn't primarily about config layering—it's about a more fundamental question: **where do specs and changes live when work spans multiple modules or repositories?**\n\n---\n\n## Part 1: Profile & Config (Original Scope)\n\n### What We've Decided\n\n#### Profile UX (Simplified)\n\n**Before (original proposal):**\n```\nopenspec profile set core|extended\nopenspec profile install <workflow>\nopenspec profile uninstall <workflow>\nopenspec profile list\nopenspec profile show\nopenspec config set delivery skills|commands|both\nopenspec config get delivery\nopenspec config list\n```\n8 subcommands, two concepts (profile + config)\n\n**After (simplified):**\n```\nopenspec config profile          # interactive picker (delivery + workflows)\nopenspec config profile core     # preset shortcut\nopenspec config profile extended # preset shortcut\n```\n1 command with presets, one concept\n\n#### Interactive Picker\n\n```\n$ openspec config profile\n\nDelivery: [skills] [commands] [both]\n                              ^^^^^^\n\nWorkflows: (space to toggle, enter to save)\n[x] propose\n[x] explore\n[x] apply\n[x] archive\n[ ] new\n[ ] ff\n[ ] continue\n[ ] verify\n[ ] sync\n[ ] bulk-archive\n[ ] onboard\n```\n\nOne place to configure both delivery method and workflow selection.\n\n#### Why \"Profile\" (Not \"Workflows\")\n\nProfiles as an abstraction allow for future extensibility:\n- Methodology bundles (spec-driven, test-driven)\n- User-created profiles\n- Shareable profiles\n- Different skill/command sets for different approaches\n\n### Config Layering Research\n\nWe researched how similar tools handle config layering:\n\n| Tool | Model | Key Pattern |\n|------|-------|-------------|\n| **VSCode** | User → Workspace → Folder | Objects merge, primitives override. Workspace = committed `.vscode/` in repo |\n| **ESLint (flat)** | Single root config | *Deliberately killed cascading* - \"complexity exploded exponentially\" |\n| **Turborepo** | Root + package extends | Per-package `turbo.json` with `extends: [\"//\"]` for overrides |\n| **Nx** | Integrated vs Package-based | Two modes - shared root OR per-package. Hard to migrate from integrated. |\n| **pnpm** | Workspace root defines scope | `pnpm-workspace.yaml` at root. Dependencies can be shared or per-package |\n| **Claude Code** | Global + Project | `~/.claude/` for global, `.claude/` per-project. No workspace tracking. |\n| **Kiro** | Distributed per-root | Each folder has `.kiro/`. Aggregated display, no inheritance. |\n\n**Key insight from ESLint:** The ESLint team explicitly removed cascading in flat config because cascading was a complexity nightmare. Their new model: one config at root, use glob patterns to target subdirectories.\n\n**Recommendation for profiles/config:** Two layers is enough.\n- **Global** = user's defaults (`~/.config/openspec/`)\n- **Project** = repo-level config (`.openspec/` or committed to repo)\n\nNo \"workspace\" layer needed for config. This matches Claude Code's model.\n\n### Config Decision (For This Change)\n\nKeep it simple:\n1. Global profile as default for `openspec init`\n2. `openspec init` applies current profile to project\n3. No workspace tracking (yet)\n4. No auto-sync of existing projects\n\nThis is explicit and doesn't prevent future features.\n\n---\n\n## Part 2: The Deeper Problem (Spec & Change Organization)\n\n### The Real Question\n\nThe workspace question isn't about config—it's about **where specs and changes live** when:\n\n1. **Monorepos**: A spec or change might span multiple packages/apps\n2. **Multi-repo**: A change might span multiple repositories entirely\n3. **Cross-functional work**: A feature affects multiple teams (backend, web, iOS, Android)\n\n### Current OpenSpec Architecture\n\nOpenSpec currently assumes:\n- One `openspec/` per repo, always at root\n- CLI doesn't walk up directories—expects you're at root\n- Changes can touch ANY spec (no scoping)\n- Single config applies to everything\n- No notion of \"scope\" or \"boundary\" within a project\n\n```\nopenspec/\n├── specs/\n│   ├── auth/spec.md           # Domain-organized specs\n│   ├── payments/spec.md\n│   └── checkout/spec.md\n├── changes/\n│   └── add-oauth/\n│       ├── proposal.md\n│       ├── design.md\n│       ├── tasks.md\n│       └── specs/             # Delta specs (can touch multiple)\n│           ├── auth/spec.md\n│           └── checkout/spec.md\n└── config.yaml\n```\n\n**This works well for single-project repos.** But what about:\n- Large monorepos with 50+ packages?\n- Multi-repo microservices?\n- Cross-functional features spanning multiple teams?\n\n### The Checkout/Payment Example\n\nImagine a payment system with:\n- **Backend billing team**: Owns payment processing\n- **Web team**: Owns web checkout UX\n- **iOS team**: Owns iOS checkout UX\n- **Android team**: Owns Android checkout UX\n- **Cross-cutting**: The payment *contract* all clients must follow\n\n**Questions:**\n- Where does the shared payment contract spec live?\n- Where do platform-specific checkout specs live?\n- If iOS spec \"extends\" the shared contract, how is that expressed?\n- When the contract changes, how do downstream specs get updated?\n- Who owns what?\n\n### The Core Tension\n\n```\n                    SCOPE\n                      │\n         Narrow       │       Broad\n    (team/module)     │    (cross-cutting)\n                      │\n    ┌─────────────────┼─────────────────┐\n    │                 │                 │\n    │  \"Our team's    │   \"Shared       │\n    │   checkout      │    checkout     │\n    │   behavior\"     │    contract\"    │\n    │                 │                 │\n────┼─────────────────┼─────────────────┼──── OWNERSHIP\n    │                 │                 │\n    │  Easy:          │   Hard:         │\n    │  One team,      │   Multiple      │\n    │  one spec       │   stakeholders  │\n    │                 │                 │\n    └─────────────────┴─────────────────┘\n```\n\n---\n\n## Part 3: How Other Domains Solve This\n\n### Patterns from Research\n\n| Domain | Shared Stuff | Specific Stuff | How They Connect |\n|--------|-------------|----------------|------------------|\n| **Protobuf** | `common/` at root | `domain/service/` per service | Imports from common |\n| **Design Systems** | Design tokens, component names, APIs | Platform implementations | \"Same properties, different rendering\" |\n| **DDD** | Shared Kernel | Bounded Contexts | Context mapping defines relationships |\n| **RFCs** | Cross-cutting RFCs | Team-scoped RFCs | Different review processes |\n| **OpenAPI** | Base schemas | Per-service specs | `$ref` to shared definitions |\n\n### Protobuf Monorepo Pattern\n\n```\nproto/\n├── common/              # Shared, low-churn types\n│   └── money.proto\n│   └── address.proto\n├── billing/             # Domain-specific\n│   └── service.proto\n└── checkout/\n    └── service.proto    # Imports from common/\n```\n\n**Key insight:** \"Most engineering organizations should keep their proto files in one repo. The mental overhead stays constant instead of scaling with organization size.\"\n\n### Design Systems Pattern (Booking.com, Uber)\n\n> \"Components can look quite different between iOS and Android, as they use native app design standards, but still share the **same exact properties in code**. This is what makes properties so powerful—it's the **one source of truth** for every component.\"\n\n**Key insight:** Shared spec defines the *contract* (properties, behavior). Platform specs define *implementation details* (how it looks/works on that platform).\n\n### DDD Bounded Contexts\n\n> \"One context, one team. Clear ownership avoids miscommunication.\"\n\n**Key insight:** Specs should have clear ownership. Cross-cutting concerns use a \"Shared Kernel\" pattern—explicitly shared code/specs that require coordination to change.\n\n---\n\n## Part 4: Three Models for OpenSpec\n\n### Model A: Flat Root (Current)\n\n```\nopenspec/\n├── specs/\n│   ├── checkout-contract/    # Shared contract\n│   ├── checkout-web/         # Web-specific\n│   ├── checkout-ios/         # iOS-specific\n│   ├── checkout-android/     # Android-specific\n│   ├── billing/              # Backend\n│   └── ... (50+ specs at root level)\n└── changes/\n```\n\n**Pros:**\n- Simple mental model\n- All specs in one place\n- No nesting complexity\n\n**Cons:**\n- Gets unwieldy at scale (50+ directories)\n- No clear ownership signals\n- Hard to see which specs are related\n- Naming conventions become critical (`checkout-*`)\n\n### Model B: Nested Specs (Domain → Platform)\n\n```\nopenspec/\n├── specs/\n│   ├── checkout/\n│   │   ├── spec.md              # Shared contract (the \"interface\")\n│   │   ├── web/spec.md          # Web implementation spec\n│   │   ├── ios/spec.md          # iOS implementation spec\n│   │   └── android/spec.md      # Android implementation spec\n│   └── billing/\n│       └── spec.md\n└── changes/\n```\n\n**Pros:**\n- Clear hierarchy (shared at top, specific nested)\n- Related specs are co-located\n- Scales better visually\n- Ownership can follow structure\n\n**Cons:**\n- More complex spec references (`checkout/web` vs `checkout`)\n- Need to define inheritance/extension semantics\n- Does iOS spec \"extend\" base spec, or just reference it?\n\n**Open question:** What does \"extends\" mean?\n```yaml\n# checkout/ios/spec.md\nextends: ../spec.md   # Inherits all requirements?\nrequirements:\n  - System SHALL support Apple Pay  # Adds to base?\n```\n\n### Model C: Distributed Specs (Near the Code)\n\n```\nmonorepo/\n├── services/\n│   └── billing/\n│       └── openspec/specs/billing/spec.md\n├── clients/\n│   ├── web/\n│   │   └── openspec/specs/checkout/spec.md\n│   ├── ios/\n│   │   └── openspec/specs/checkout/spec.md\n│   └── android/\n│       └── openspec/specs/checkout/spec.md\n└── openspec/           # Root-level for cross-cutting\n    ├── specs/\n    │   └── checkout-contract/spec.md   # Shared contract\n    └── changes/        # Where do cross-cutting changes live?\n```\n\n**Pros:**\n- Specs live near the code they describe\n- Teams own their specs naturally\n- Works for multi-repo too (each repo has its own `openspec/`)\n\n**Cons:**\n- Cross-cutting specs are awkward (where do they go?)\n- Changes that span multiple `openspec/` directories = ???\n- Need a \"workspace\" concept to aggregate\n- Multiple `openspec/` roots to manage\n\n### Model D: Hybrid (Model B Inside Each Project + Model C Across Projects)\n\nUse one `openspec/` root per project, but allow nested specs within that root for clear ownership and shared contracts.\nFor multi-repo work, use a workspace manifest to coordinate multiple projects without duplicating canonical specs.\n\n**Monorepo shape (single project, nested specs):**\n```\nrepo/\n└── openspec/\n    ├── specs/\n    │   ├── contracts/\n    │   │   └── checkout/spec.md\n    │   ├── billing/\n    │   │   └── spec.md\n    │   └── checkout/\n    │       ├── web/spec.md\n    │       ├── ios/spec.md\n    │       └── android/spec.md\n    └── changes/\n        └── add-3ds/\n            ├── proposal.md\n            ├── design.md\n            ├── tasks.md\n            └── specs/\n                ├── contracts/checkout/spec.md\n                ├── billing/spec.md\n                ├── checkout/web/spec.md\n                ├── checkout/ios/spec.md\n                └── checkout/android/spec.md\n```\n\n**Multi-repo shape (multiple projects + workspace orchestration):**\n```\n~/work/\n├── contracts/\n│   └── openspec/\n│       ├── specs/checkout/spec.md\n│       └── changes/add-3ds-contract/\n├── billing-service/\n│   └── openspec/\n│       ├── specs/billing/spec.md\n│       └── changes/add-3ds-billing/\n├── web-client/\n│   └── openspec/\n│       ├── specs/checkout/spec.md\n│       └── changes/add-3ds-web/\n├── ios-client/\n│   └── openspec/\n│       ├── specs/checkout/spec.md\n│       └── changes/add-3ds-ios/\n└── payments-workspace/\n    └── .openspec-workspace/\n        ├── workspace.yaml\n        └── initiatives/add-3ds/links.yaml\n```\n\n`workspace.yaml` lists projects/roots. `links.yaml` maps one cross-cutting initiative to per-project changes.\nCanonical specs stay in owning repos; workspace data is coordination metadata only.\n\n**Pros:**\n- Clear ownership boundaries (one project owns its specs and changes)\n- Shared contracts can have a dedicated owner repo (no duplication as source of truth)\n- Works for monorepo and multi-repo with one mental model\n- Avoids inheritance complexity (relationships can start as explicit references)\n- Incremental migration path from current model\n\n**Cons:**\n- Requires new workspace UX for cross-repo coordination\n- Cross-repo feature work creates multiple change IDs to manage\n- Needs conventions for contracts ownership and initiative linking\n- Some users may expect one global \"mega change\" instead of linked per-project changes\n- Tooling must support nested spec paths in both main specs and change deltas\n\n---\n\n## Part 5: Multi-Repo Considerations\n\nFor multi-repo setups, Model C (or the coordination half of Model D) is almost forced:\n\n```\n~/work/\n├── billing-service/\n│   └── openspec/specs/billing/\n├── web-client/\n│   └── openspec/specs/checkout/\n├── ios-client/\n│   └── openspec/specs/checkout/\n└── contracts/                    # Dedicated repo for shared specs?\n    └── openspec/specs/\n        └── checkout-contract/\n```\n\n### Questions for Multi-Repo\n\n1. **Where do shared specs live?**\n   - Dedicated \"contracts\" repo?\n   - Duplicated in each repo (drift risk)?\n   - One repo is \"source of truth\" and others reference it?\n\n2. **Where do cross-repo changes live?**\n   - In one of the repos? (feels wrong—biased ownership)\n   - In a separate \"workspace\" repo?\n   - In `~/.config/openspec/workspaces/my-platform/changes/`?\n\n3. **How do changes propagate?**\n   - Change to `checkout-contract` affects all client repos\n   - Do we need explicit dependency tracking?\n   - Or is this \"out of band\" (teams coordinate manually)?\n\n### What \"Workspace\" Might Mean for Multi-Repo\n\nIf we add workspace support, it could be:\n\n> **A workspace is a collection of OpenSpec roots that can be operated on together.**\n\n```yaml\n# ~/.config/openspec/workspaces.yaml (or similar)\nworkspaces:\n  my-platform:\n    roots:\n      - ~/work/billing-service\n      - ~/work/web-client\n      - ~/work/ios-client\n      - ~/work/contracts\n    shared_context: |\n      All services use TypeScript.\n      API contracts follow OpenAPI 3.1.\n```\n\nThis would enable:\n1. **Cross-repo changes**: Create a change that tracks deltas across multiple roots\n2. **Aggregated spec view**: See all specs across workspace\n3. **Shared context**: Context/rules that apply to all roots\n\n---\n\n## Part 6: Key Design Questions\n\n### 1. Should specs be hierarchical (with inheritance)?\n\n**Option A: No inheritance, just organization**\n- Nested directories are purely organizational\n- Each spec is independent\n- Relationships are implicit (naming) or documented manually\n\n**Option B: Explicit inheritance**\n```yaml\n# checkout/ios/spec.md\nextends: ../spec.md\nrequirements:\n  - System SHALL support Apple Pay  # Adds to base\n```\n- Child specs inherit parent requirements\n- Can add, override, or extend\n- More powerful but more complex\n\n**Option C: References without inheritance**\n```yaml\n# checkout/ios/spec.md\nreferences:\n  - ../spec.md  # \"See also\" but no inheritance\nrequirements:\n  - System SHALL implement checkout per checkout-contract\n  - System SHALL support Apple Pay\n```\n- Explicit references for documentation\n- No automatic inheritance\n- Simpler semantics\n\n### 2. Where does the \"shared kernel\" live?\n\n**Option A: Root level (Model B)**\n- `openspec/specs/checkout/spec.md` is the shared kernel\n- Platform specs nest under it\n\n**Option B: Dedicated area**\n- `openspec/specs/_shared/checkout-contract/spec.md`\n- Or `openspec/specs/_contracts/checkout/spec.md`\n- Explicit \"shared\" namespace\n\n**Option C: Separate repo (Model C for multi-repo)**\n- A dedicated `contracts` or `specs` repo\n- Other repos reference it\n\n### 3. What's a \"workspace\" vs a \"project\"?\n\nIf we introduce workspaces:\n\n| Concept | Definition |\n|---------|------------|\n| **Project** | Single OpenSpec root (one `openspec/` directory) |\n| **Workspace** | Collection of projects that can be operated on together |\n\nA workspace would enable:\n- Aggregated spec viewing across projects\n- Cross-project changes\n- Shared context across projects\n\n**Question:** Do we need explicit workspace tracking, or just ad-hoc multi-root (like Claude Code's `/add-dir`)?\n\n### 4. Does OpenSpec need to understand dependencies?\n\nIf `checkout-web` depends on `checkout-contract`:\n- Should OpenSpec know this relationship?\n- Should a change to `checkout-contract` warn about downstream specs?\n- Or is dependency tracking \"out of scope\"?\n\n**Trade-off:**\n- With dependency tracking: More powerful, automatic propagation warnings\n- Without: Simpler, teams manage dependencies themselves\n\n### 5. How should changes work for cross-cutting work?\n\n**For monorepos (Model B):**\n- One change, multiple delta specs in `specs/`\n- Already works today\n\n**For multi-repo (Model C):**\n- Option A: One \"workspace change\" that references multiple repo changes\n- Option B: Separate changes in each repo that reference each other\n- Option C: Changes always live in one repo, reference specs in others\n\n---\n\n## Part 7: What Would \"Amazing\" Look Like?\n\nBased on research, teams love:\n\n1. **One place to look** (Protobuf: \"mental overhead stays constant\")\n2. **Clear ownership** (DDD: \"one context, one team\")\n3. **Shared contracts with local extensions** (Design Systems: \"same properties, different rendering\")\n4. **Automatic consistency** (Design Systems: \"design tokens as foundation\")\n5. **Low cognitive load** (shouldn't have to think about organization too much)\n\n### Possible North Stars\n\n**Ambitious:**\n> OpenSpec automatically understands your repo structure, detects cross-cutting specs, and helps you create changes that flow to the right places.\n\n**Simpler:**\n> You organize specs however you want. OpenSpec just works.\n\n**Practical:**\n> Nested specs for organization. Explicit dependencies for cross-cutting. No magic.\n\n---\n\n## Part 8: Possible Paths Forward\n\n### For This Change (simplify-skill-installation)\n\nDon't solve spec organization now. Keep scope to:\n1. Profile UX simplification\n2. `openspec init` improvements\n3. No workspace tracking yet\n\n### Future: Spec Organization Change\n\nA separate change to explore and implement:\n\n1. **Decide on Model A, B, C, or D (hybrid)**\n2. **Decide on inheritance semantics** (or none)\n3. **Update spec resolution** to handle nesting\n4. **Update change deltas** to handle nested specs\n\n### Future: Multi-Repo / Workspace Change\n\nIf needed, a separate change for:\n\n1. **Define workspace concept**\n2. **Implement workspace tracking** (or ad-hoc multi-root)\n3. **Cross-repo changes**\n4. **Shared context across repos**\n\n---\n\n## Part 9: Spec Philosophy (Behavior First, Lightweight, Agent-Aligned)\n\n### What is a spec in OpenSpec?\n\nFor OpenSpec, a spec should be treated as a **verifiable behavior contract at a boundary**:\n- What users, integrators, or operators can observe and rely on\n- What can be validated with tests, checks, or explicit review\n- What should remain stable even if internal implementation changes\n\n### What should and should not be in specs\n\n**Include:**\n- Observable behavior and outcomes\n- Interface/data contracts (inputs, outputs, error conditions)\n- Non-functional constraints that matter externally (privacy, security, reliability)\n- Compatibility guarantees that downstream consumers depend on\n\n**Avoid:**\n- Internal implementation details (class names, library choices, control flow)\n- Tooling mechanics that can change without affecting behavior\n- Step-by-step execution plans (belongs in tasks/design)\n\n### Keep rigor proportional (to avoid bureaucracy)\n\nUse progressive rigor:\n\n1. **Lite spec (default for most changes)**\n   - Short behavior bullets, clear scope, and acceptance checks\n2. **Full spec (only for high-risk or cross-boundary work)**\n   - Deeper contract detail for API breaks, migrations, security/privacy, or cross-team/repo changes\n\nThis keeps day-to-day usage lightweight while preserving clarity where failures are expensive.\n\n### Human exploration -> agent-authored specs\n\nOpenSpec is often agent-authored from human exploration. To make that reliable:\n\n- Humans provide intent, constraints, and examples from exploration\n- Agents convert that into concise, behavior-first requirements and scenarios\n- Agents keep implementation detail in design/tasks, not specs\n- Validation checks enforce structure and testability\n\nIn short: humans shape intent; agents produce consistent, verifiable contracts.\n\n### Where this philosophy should live\n\nTo avoid losing this in exploration notes, codify it in:\n1. `docs/concepts.md` for human-facing framing\n2. `openspec/specs/openspec-conventions/spec.md` for normative spec conventions\n3. `openspec/specs/docs-agent-instructions/spec.md` for agent-instruction authoring rules\n\n---\n\n## Summary\n\n| Question | Status | Notes |\n|----------|--------|-------|\n| Profile UX | Decided | `openspec config profile` with presets |\n| Config layering | Decided | Two layers: global + project (no workspace layer) |\n| Spec organization | Open | Four models under consideration (including hybrid Model D) |\n| Spec philosophy | Direction set | Behavior-first contracts, progressive rigor, and agent-aligned authoring |\n| Spec inheritance | Open | Inheritance vs references vs none |\n| Multi-repo support | Open | Workspace concept TBD |\n| Dependency tracking | Open | Probably out of scope initially |\n\n### Key Insight\n\nThe \"workspace\" question is really two separate questions:\n1. **Config/profile scope** → Solved with global + project (no workspace needed)\n2. **Spec/change organization** → Unsolved, needs deeper design work\n\nThese should be separate changes with separate explorations.\n\n---\n\n## References\n\n- [VSCode Settings Precedence](https://code.visualstudio.com/docs/configure/settings)\n- [ESLint Flat Config in Monorepos Discussion](https://github.com/eslint/eslint/discussions/16960)\n- [Turborepo Package Configurations](https://turborepo.dev/docs/reference/package-configurations)\n- [pnpm Workspaces](https://pnpm.io/workspaces)\n- [Claude Code Settings](https://code.claude.com/docs/en/settings)\n- [Kiro Multi-Root Workspaces](https://kiro.dev/docs/editor/multi-root-workspaces/)\n- [DDD Bounded Context](https://martinfowler.com/bliki/BoundedContext.html)\n- [Protobuf Monorepo Patterns](https://www.lesswrong.com/posts/xts8dC3NeTHwqYgCG/keep-your-protos-in-one-repo)\n- [Booking.com Multi-Platform Design System](https://booking.design/how-we-built-our-multi-platform-design-system-at-booking-com-d7b895399d40)\n- [InnerSource RFC Patterns](https://patterns.innersourcecommons.org/p/transparent-cross-team-decision-making-using-rfcs)\n"
  },
  {
    "path": "openspec/specs/ai-tool-paths/spec.md",
    "content": "# ai-tool-paths Specification\n\n## Purpose\nDefine AI tool path metadata used to generate OpenSpec skills and commands in tool-specific directories.\n\n## Requirements\n### Requirement: AIToolOption skillsDir field\n\nThe `AIToolOption` interface SHALL include an optional `skillsDir` field for skill generation path configuration.\n\n#### Scenario: Interface includes skillsDir field\n\n- **WHEN** a tool entry is defined in `AI_TOOLS` that supports skill generation\n- **THEN** it SHALL include a `skillsDir` field specifying the project-local base directory (e.g., `.claude`)\n\n#### Scenario: Skills path follows Agent Skills spec\n\n- **WHEN** generating skills for a tool with `skillsDir: '.claude'`\n- **THEN** skills SHALL be written to `<projectRoot>/<skillsDir>/skills/`\n- **AND** the `/skills` suffix is appended per Agent Skills specification\n\n### Requirement: Path configuration for supported tools\n\nThe `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent Skills specification.\n\n#### Scenario: Claude Code paths defined\n\n- **WHEN** looking up the `claude` tool\n- **THEN** `skillsDir` SHALL be `.claude`\n\n#### Scenario: Cursor paths defined\n\n- **WHEN** looking up the `cursor` tool\n- **THEN** `skillsDir` SHALL be `.cursor`\n\n#### Scenario: Windsurf paths defined\n\n- **WHEN** looking up the `windsurf` tool\n- **THEN** `skillsDir` SHALL be `.windsurf`\n\n#### Scenario: Tools without skillsDir\n\n- **WHEN** a tool has no `skillsDir` defined\n- **THEN** skill generation SHALL error with message indicating the tool is not supported\n\n### Requirement: Cross-platform path handling\n\nThe system SHALL handle paths correctly across operating systems.\n\n#### Scenario: Path construction on Windows\n\n- **WHEN** constructing skill paths on Windows\n- **THEN** the system SHALL use `path.join()` for all path construction\n- **AND** SHALL NOT hardcode forward slashes\n\n#### Scenario: Path construction on Unix\n\n- **WHEN** constructing skill paths on macOS or Linux\n- **THEN** the system SHALL use `path.join()` for consistency\n\n"
  },
  {
    "path": "openspec/specs/artifact-graph/spec.md",
    "content": "# artifact-graph Specification\n\n## Purpose\nDefine the artifact graph model, dependency validation, and completion-state logic used by schema-driven workflows.\n\n## Requirements\n### Requirement: Schema Loading\nThe system SHALL load artifact graph definitions from YAML schema files within schema directories.\n\n#### Scenario: Valid schema loaded\n- **WHEN** a schema directory contains a valid `schema.yaml` file\n- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies\n\n#### Scenario: Invalid schema rejected\n- **WHEN** a schema YAML file is missing required fields\n- **THEN** the system throws an error with a descriptive message\n\n#### Scenario: Cyclic dependencies detected\n- **WHEN** a schema contains cyclic artifact dependencies\n- **THEN** the system throws an error listing the artifact IDs in the cycle\n\n#### Scenario: Invalid dependency reference\n- **WHEN** an artifact's `requires` array references a non-existent artifact ID\n- **THEN** the system throws an error identifying the invalid reference\n\n#### Scenario: Duplicate artifact IDs rejected\n- **WHEN** a schema contains multiple artifacts with the same ID\n- **THEN** the system throws an error identifying the duplicate\n\n#### Scenario: Schema directory not found\n- **WHEN** resolving a schema name that has no corresponding directory\n- **THEN** the system throws an error listing available schemas\n\n### Requirement: Build Order Calculation\nThe system SHALL compute a valid topological build order for artifacts.\n\n#### Scenario: Linear dependency chain\n- **WHEN** artifacts form a linear chain (A → B → C)\n- **THEN** getBuildOrder() returns [A, B, C]\n\n#### Scenario: Diamond dependency\n- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)\n- **THEN** getBuildOrder() returns A before B and C, and D last\n\n#### Scenario: Independent artifacts\n- **WHEN** artifacts have no dependencies\n- **THEN** getBuildOrder() returns them in a stable order\n\n### Requirement: State Detection\nThe system SHALL detect artifact completion state by scanning the filesystem.\n\n#### Scenario: Simple file exists\n- **WHEN** an artifact generates \"proposal.md\" and the file exists\n- **THEN** the artifact is marked as completed\n\n#### Scenario: Simple file missing\n- **WHEN** an artifact generates \"proposal.md\" and the file does not exist\n- **THEN** the artifact is not marked as completed\n\n#### Scenario: Glob pattern with files\n- **WHEN** an artifact generates \"specs/*.md\" and the specs/ directory contains .md files\n- **THEN** the artifact is marked as completed\n\n#### Scenario: Glob pattern empty\n- **WHEN** an artifact generates \"specs/*.md\" and the specs/ directory is empty or missing\n- **THEN** the artifact is not marked as completed\n\n#### Scenario: Missing change directory\n- **WHEN** the change directory does not exist\n- **THEN** all artifacts are marked as not completed (empty state)\n\n### Requirement: Ready Artifact Query\nThe system SHALL identify which artifacts are ready to be created based on dependency completion.\n\n#### Scenario: Root artifacts ready initially\n- **WHEN** no artifacts are completed\n- **THEN** getNextArtifacts() returns artifacts with no dependencies\n\n#### Scenario: Dependent artifact becomes ready\n- **WHEN** an artifact's dependencies are all completed\n- **THEN** getNextArtifacts() includes that artifact\n\n#### Scenario: Blocked artifacts excluded\n- **WHEN** an artifact has uncompleted dependencies\n- **THEN** getNextArtifacts() does not include that artifact\n\n### Requirement: Completion Check\nThe system SHALL determine when all artifacts in a graph are complete.\n\n#### Scenario: All complete\n- **WHEN** all artifacts in the graph are in the completed set\n- **THEN** isComplete() returns true\n\n#### Scenario: Partially complete\n- **WHEN** some artifacts in the graph are not completed\n- **THEN** isComplete() returns false\n\n### Requirement: Blocked Query\nThe system SHALL identify which artifacts are blocked and return all their unmet dependencies.\n\n#### Scenario: Artifact blocked by single dependency\n- **WHEN** artifact B requires artifact A and A is not complete\n- **THEN** getBlocked() returns `{ B: ['A'] }`\n\n#### Scenario: Artifact blocked by multiple dependencies\n- **WHEN** artifact C requires A and B, and only A is complete\n- **THEN** getBlocked() returns `{ C: ['B'] }`\n\n#### Scenario: Artifact blocked by all dependencies\n- **WHEN** artifact C requires A and B, and neither is complete\n- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`\n\n### Requirement: Schema Directory Structure\nThe system SHALL support self-contained schema directories with co-located templates.\n\n#### Scenario: Schema with templates\n- **WHEN** a schema directory contains `schema.yaml` and `templates/` subdirectory\n- **THEN** artifacts can reference templates relative to the schema's templates directory\n\n#### Scenario: User schema override\n- **WHEN** a schema directory exists at `${XDG_DATA_HOME}/openspec/schemas/<name>/`\n- **THEN** the system uses that directory instead of the built-in\n\n#### Scenario: Built-in schema fallback\n- **WHEN** no user override exists for a schema\n- **THEN** the system uses the package built-in schema directory\n\n#### Scenario: List available schemas\n- **WHEN** listing schemas\n- **THEN** the system returns schema names from both user and package directories\n\n"
  },
  {
    "path": "openspec/specs/change-creation/spec.md",
    "content": "# change-creation Specification\n\n## Purpose\nProvide programmatic utilities for creating and validating OpenSpec change directories.\n## Requirements\n### Requirement: Change Creation\nThe system SHALL provide a function to create new change directories programmatically.\n\n#### Scenario: Create change\n- **WHEN** `createChange(projectRoot, 'add-auth')` is called\n- **THEN** the system creates `openspec/changes/add-auth/` directory\n\n#### Scenario: Duplicate change rejected\n- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists\n- **THEN** the system throws an error indicating the change already exists\n\n#### Scenario: Creates parent directories if needed\n- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist\n- **THEN** the system creates the full path including parent directories\n\n#### Scenario: Invalid change name rejected\n- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name\n- **THEN** the system throws a validation error\n\n### Requirement: Change Name Validation\nThe system SHALL validate change names follow kebab-case conventions.\n\n#### Scenario: Valid kebab-case name accepted\n- **WHEN** a change name like `add-user-auth` is validated\n- **THEN** validation returns `{ valid: true }`\n\n#### Scenario: Numeric suffixes accepted\n- **WHEN** a change name like `add-feature-2` is validated\n- **THEN** validation returns `{ valid: true }`\n\n#### Scenario: Single word accepted\n- **WHEN** a change name like `refactor` is validated\n- **THEN** validation returns `{ valid: true }`\n\n#### Scenario: Uppercase characters rejected\n- **WHEN** a change name like `Add-Auth` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Spaces rejected\n- **WHEN** a change name like `add auth` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Underscores rejected\n- **WHEN** a change name like `add_auth` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Special characters rejected\n- **WHEN** a change name like `add-auth!` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Leading hyphen rejected\n- **WHEN** a change name like `-add-auth` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Trailing hyphen rejected\n- **WHEN** a change name like `add-auth-` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n#### Scenario: Consecutive hyphens rejected\n- **WHEN** a change name like `add--auth` is validated\n- **THEN** validation returns `{ valid: false, error: \"...\" }`\n\n"
  },
  {
    "path": "openspec/specs/ci-nix-validation/spec.md",
    "content": "# ci-nix-validation Specification\n\n## Purpose\n\nValidates Nix flake builds and maintenance scripts in CI to ensure Nix users can reliably install and use OpenSpec. Prevents regressions in Nix support by testing builds and the update-flake.sh script on every pull request and push to main.\n## Requirements\n### Requirement: Nix Flake Build Validation\n\nThe CI system SHALL validate that the Nix flake builds successfully on every pull request and push to main.\n\n#### Scenario: Successful flake build\n\n- **WHEN** a pull request or push to main is made\n- **THEN** the CI SHALL execute `nix build` and verify it completes with exit code 0\n- **AND** the build output SHALL contain the openspec binary\n\n#### Scenario: Flake build failure\n\n- **WHEN** the Nix flake configuration is broken\n- **THEN** the CI job SHALL fail with a non-zero exit code\n- **AND** the CI SHALL prevent merging of the pull request\n\n#### Scenario: Multi-platform support check\n\n- **WHEN** the flake declares support for multiple systems\n- **THEN** the CI SHALL validate the flake builds on at least Linux (x86_64-linux)\n\n### Requirement: Update Script Validation\n\nThe CI system SHALL validate that the update-flake.sh script executes successfully and produces valid output.\n\n#### Scenario: Update script execution\n\n- **WHEN** the CI runs the update script validation\n- **THEN** the script SHALL execute without errors\n- **AND** the script SHALL correctly read the version from package.json\n- **AND** the script SHALL validate that flake.nix uses dynamic version from package.json\n\n#### Scenario: Update script with mock hash\n\n- **WHEN** validating the update script in CI\n- **THEN** the script SHALL be able to detect and extract the correct pnpm dependency hash\n- **AND** the flake.nix SHALL be updated with a valid sha256 hash\n\n### Requirement: CI Job Integration\n\nThe Nix validation jobs SHALL be integrated into the existing GitHub Actions workflow and required for merge.\n\n#### Scenario: PR merge requirements\n\n- **WHEN** a pull request is created\n- **THEN** the Nix validation job SHALL be included in required checks\n- **AND** the PR SHALL NOT be mergeable until Nix validation passes\n\n#### Scenario: Job execution triggers\n\n- **WHEN** code is pushed to a pull request OR pushed to main OR manually triggered\n- **THEN** the Nix validation job SHALL execute automatically\n\n### Requirement: Local Testing Support\n\nThe CI workflow SHALL be testable locally using the `act` tool to enable rapid iteration.\n\n#### Scenario: Local CI execution with act\n\n- **WHEN** a developer runs `act` with the Nix validation workflow\n- **THEN** the workflow SHALL execute in the local Docker environment\n- **AND** the developer SHALL receive feedback on Nix build status without pushing to GitHub\n\n#### Scenario: Act configuration compatibility\n\n- **WHEN** the workflow is designed\n- **THEN** it SHALL use standard GitHub Actions syntax compatible with `act`\n- **AND** any Nix-specific setup SHALL work in the act Docker environment\n\n### Requirement: Nix Installation in CI\n\nThe CI environment SHALL have Nix properly installed and configured before running validation.\n\n#### Scenario: Nix installation step\n\n- **WHEN** the Nix validation job starts\n- **THEN** Nix SHALL be installed using the official Nix installer or determinatesystems/nix-installer-action\n- **AND** the Nix installation SHALL be cached for subsequent runs to improve performance\n\n#### Scenario: Nix configuration for CI\n\n- **WHEN** Nix is installed in CI\n- **THEN** it SHALL be configured to work in the GitHub Actions environment\n- **AND** experimental features (flakes, nix-command) SHALL be enabled\n\n### Requirement: CI Performance Optimization\n\nThe Nix validation SHALL be optimized to minimize CI runtime impact.\n\n#### Scenario: Acceptable runtime\n\n- **WHEN** the Nix validation job runs\n- **THEN** it SHALL complete in under 5 minutes on a clean run\n- **AND** with caching, it SHALL complete in under 3 minutes on subsequent runs\n\n#### Scenario: Parallel execution\n\n- **WHEN** multiple CI jobs are running\n- **THEN** the Nix validation job SHALL run in parallel with other validation jobs (tests, lint)\n- **AND** SHALL NOT block other independent checks\n\n"
  },
  {
    "path": "openspec/specs/cli-archive/spec.md",
    "content": "# CLI Archive Command Specification\n\n## Purpose\nThe archive command moves completed changes from the active changes directory to the archive folder with date-based naming, following OpenSpec conventions.\n\n## Command Syntax\n```bash\nopenspec archive [change-name] [--yes|-y]\n```\n\nOptions:\n- `--yes`, `-y`: Skip confirmation prompts (for automation)\n## Requirements\n### Requirement: Change Selection\n\nThe command SHALL support both interactive and direct change selection methods.\n\n#### Scenario: Interactive selection\n\n- **WHEN** no change-name is provided\n- **THEN** display interactive list of available changes (excluding archive/)\n- **AND** allow user to select one\n\n#### Scenario: Direct selection\n\n- **WHEN** change-name is provided\n- **THEN** use that change directly\n- **AND** validate it exists\n\n### Requirement: Task Completion Check\n\nThe command SHALL verify task completion status before archiving to prevent premature archival.\n\n#### Scenario: Incomplete tasks found\n\n- **WHEN** incomplete tasks are found (marked with `- [ ]`)\n- **THEN** display all incomplete tasks to the user\n- **AND** prompt for confirmation to continue\n- **AND** default to \"No\" for safety\n\n#### Scenario: All tasks complete\n\n- **WHEN** all tasks are complete OR no tasks.md exists\n- **THEN** proceed with archiving without prompting\n\n### Requirement: Archive Process\n\nThe archive operation SHALL follow a structured process to safely move changes to the archive.\n\n#### Scenario: Performing archive\n\n- **WHEN** archiving a change\n- **THEN** execute these steps:\n  1. Create archive/ directory if it doesn't exist\n  2. Generate target name as `YYYY-MM-DD-[change-name]` using current date\n  3. Check if target directory already exists\n  4. Update main specs from the change's future state specs (see Spec Update Process below)\n  5. Move the entire change directory to the archive location\n\n#### Scenario: Archive already exists\n\n- **WHEN** target archive already exists\n- **THEN** fail with error message\n- **AND** do not overwrite existing archive\n\n#### Scenario: Successful archive\n\n- **WHEN** move succeeds\n- **THEN** display success message with archived name and list of updated specs\n\n### Requirement: Spec Update Process\n\nBefore moving the change to archive, the command SHALL apply delta changes to main specs to reflect the deployed reality.\n\n#### Scenario: Applying delta changes\n\n- **WHEN** archiving a change with delta-based specs\n- **THEN** parse and apply delta changes as defined in openspec-conventions\n- **AND** validate all operations before applying\n\n#### Scenario: Validating delta changes\n\n- **WHEN** processing delta changes\n- **THEN** perform validations as specified in openspec-conventions\n- **AND** if validation fails, show specific errors and abort\n\n#### Scenario: Conflict detection\n\n- **WHEN** applying deltas would create duplicate requirement headers\n- **THEN** abort with error message showing the conflict\n- **AND** suggest manual resolution\n\n### Requirement: Confirmation Behavior\n\nThe spec update confirmation SHALL provide clear visibility into changes before they are applied.\n\n#### Scenario: Displaying confirmation\n\n- **WHEN** prompting for confirmation\n- **THEN** display a clear summary showing:\n  - Which specs will be created (new capabilities)\n  - Which specs will be updated (existing capabilities)\n  - The source path for each spec\n- **AND** format the confirmation prompt as:\n  ```\n  The following specs will be updated:\n  \n  NEW specs to be created:\n    - cli-archive (from changes/add-archive-command/specs/cli-archive/spec.md)\n  \n  EXISTING specs to be updated:\n    - cli-init (from changes/update-init-command/specs/cli-init/spec.md)\n  \n  Update 2 specs and archive 'add-archive-command'? [y/N]:\n  ```\n#### Scenario: Handling confirmation response\n\n- **WHEN** waiting for user confirmation\n- **THEN** default to \"No\" for safety (require explicit \"y\" or \"yes\")\n- **AND** skip confirmation when `--yes` or `-y` flag is provided\n\n#### Scenario: User declines confirmation\n\n- **WHEN** user declines the confirmation\n- **THEN** abort the entire archive operation\n- **AND** display message: \"Archive cancelled. No changes were made.\"\n- **AND** exit with non-zero status code\n\n### Requirement: Error Conditions\n\nThe command SHALL handle various error conditions gracefully.\n\n#### Scenario: Handling errors\n\n- **WHEN** errors occur\n- **THEN** handle the following conditions:\n  - Missing openspec/changes/ directory\n  - Change not found\n  - Archive target already exists\n  - File system permissions issues\n\n### Requirement: Skip Specs Option\n\nThe archive command SHALL support a `--skip-specs` flag that skips all spec update operations and proceeds directly to archiving.\n\n#### Scenario: Skipping spec updates with flag\n\n- **WHEN** executing `openspec archive <change> --skip-specs`\n- **THEN** skip spec discovery and update confirmation\n- **AND** proceed directly to moving the change to archive\n- **AND** display a message indicating specs were skipped\n\n### Requirement: Non-blocking confirmation\n\nThe archive operation SHALL proceed when the user declines spec updates instead of cancelling the entire operation.\n\n#### Scenario: User declines spec update confirmation\n\n- **WHEN** the user declines spec update confirmation\n- **THEN** skip spec updates\n- **AND** continue with the archive operation\n- **AND** display a success message indicating specs were not updated\n\n### Requirement: Display Output\n\nThe command SHALL provide clear feedback about delta operations.\n\n#### Scenario: Showing delta application\n\n- **WHEN** applying delta changes\n- **THEN** display for each spec:\n  - Number of requirements added\n  - Number of requirements modified\n  - Number of requirements removed\n  - Number of requirements renamed\n- **AND** use standard output symbols (+ ~ - →) as defined in openspec-conventions:\n  ```\n  Applying changes to specs/user-auth/spec.md:\n    + 2 added\n    ~ 3 modified\n    - 1 removed\n    → 1 renamed\n  ```\n\n### Requirement: Archive Validation\n\nThe archive command SHALL validate changes before applying them to ensure data integrity.\n\n#### Scenario: Pre-archive validation\n\n- **WHEN** executing `openspec archive change-name`\n- **THEN** validate the change structure first\n- **AND** only proceed if validation passes\n- **AND** show validation errors if it fails\n\n#### Scenario: Force archive without validation\n\n- **WHEN** executing `openspec archive change-name --no-validate`\n- **THEN** skip validation (unsafe mode)\n- **AND** show warning about skipping validation\n\n## Why These Decisions\n\n**Interactive selection**: Reduces typing and helps users see available changes\n**Task checking**: Prevents accidental archiving of incomplete work\n**Date prefixing**: Maintains chronological order and prevents naming conflicts\n**No overwrite**: Preserves historical archives and prevents data loss\n**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\n**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified\n**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use"
  },
  {
    "path": "openspec/specs/cli-artifact-workflow/spec.md",
    "content": "# cli-artifact-workflow Specification\n\n## Purpose\nDefine artifact workflow CLI behavior (`status`, `instructions`, `templates`, and setup flows) for scaffolded and active changes.\n\n## Requirements\n### Requirement: Status Command\n\nThe system SHALL display artifact completion status for a change, including scaffolded (empty) changes.\n\n> **Fixes bug**: Previously required `proposal.md` to exist via `getActiveChangeIds()`.\n\n#### Scenario: Show status with all states\n\n- **WHEN** user runs `openspec status --change <id>`\n- **THEN** the system displays each artifact with status indicator:\n  - `[x]` for completed artifacts\n  - `[ ]` for ready artifacts\n  - `[-]` for blocked artifacts (with missing dependencies listed)\n\n#### Scenario: Status shows completion summary\n\n- **WHEN** user runs `openspec status --change <id>`\n- **THEN** output includes completion percentage and count (e.g., \"2/4 artifacts complete\")\n\n#### Scenario: Status JSON output\n\n- **WHEN** user runs `openspec status --change <id> --json`\n- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array\n\n#### Scenario: Status JSON includes apply requirements\n\n- **WHEN** user runs `openspec status --change <id> --json`\n- **THEN** the system outputs JSON with:\n  - `changeName`, `schemaName`, `isComplete`, `artifacts` array\n  - `applyRequires`: array of artifact IDs needed for apply phase\n\n#### Scenario: Status on scaffolded change\n\n- **WHEN** user runs `openspec status --change <id>` on a change with no artifacts\n- **THEN** system displays all artifacts with their status\n- **AND** root artifacts (no dependencies) show as ready `[ ]`\n- **AND** dependent artifacts show as blocked `[-]`\n\n#### Scenario: Missing change parameter\n\n- **WHEN** user runs `openspec status` without `--change`\n- **THEN** the system displays an error with list of available changes\n- **AND** includes scaffolded changes (directories without proposal.md)\n\n#### Scenario: Unknown change\n\n- **WHEN** user runs `openspec status --change unknown-id`\n- **AND** directory `openspec/changes/unknown-id/` does not exist\n- **THEN** the system displays an error listing all available change directories\n\n### Requirement: Next Artifact Discovery\n\nThe workflow SHALL use `openspec status` output to determine what can be created next, rather than a separate next-command surface.\n\n#### Scenario: Discover next artifacts from status output\n\n- **WHEN** a user needs to know which artifact to create next\n- **THEN** `openspec status --change <id>` identifies ready artifacts with `[ ]`\n- **AND** no dedicated \"next command\" is required to continue the workflow\n\n### Requirement: Instructions Command\n\nThe system SHALL output enriched instructions for creating an artifact, including for scaffolded changes.\n\n#### Scenario: Show enriched instructions\n\n- **WHEN** user runs `openspec instructions <artifact> --change <id>`\n- **THEN** the system outputs:\n  - Artifact metadata (ID, output path, description)\n  - Template content\n  - Dependency status (done/missing)\n  - Unlocked artifacts (what becomes available after completion)\n\n#### Scenario: Instructions JSON output\n\n- **WHEN** user runs `openspec instructions <artifact> --change <id> --json`\n- **THEN** the system outputs JSON matching ArtifactInstructions interface\n\n#### Scenario: Unknown artifact\n\n- **WHEN** user runs `openspec instructions unknown-artifact --change <id>`\n- **THEN** the system displays an error listing valid artifact IDs for the schema\n\n#### Scenario: Artifact with unmet dependencies\n\n- **WHEN** user requests instructions for a blocked artifact\n- **THEN** the system displays instructions with a warning about missing dependencies\n\n#### Scenario: Instructions on scaffolded change\n\n- **WHEN** user runs `openspec instructions proposal --change <id>` on a scaffolded change\n- **THEN** system outputs template and metadata for creating the proposal\n- **AND** does not require any artifacts to already exist\n\n### Requirement: Templates Command\nThe system SHALL show resolved template paths for all artifacts in a schema.\n\n#### Scenario: List template paths with default schema\n- **WHEN** user runs `openspec templates`\n- **THEN** the system displays each artifact with its resolved template path using the default schema\n\n#### Scenario: List template paths with custom schema\n- **WHEN** user runs `openspec templates --schema tdd`\n- **THEN** the system displays template paths for the specified schema\n\n#### Scenario: Templates JSON output\n- **WHEN** user runs `openspec templates --json`\n- **THEN** the system outputs JSON mapping artifact IDs to template paths\n\n#### Scenario: Template resolution source\n- **WHEN** displaying template paths\n- **THEN** the system indicates whether each template is from user override or package built-in\n\n### Requirement: New Change Command\nThe system SHALL create new change directories with validation.\n\n#### Scenario: Create valid change\n- **WHEN** user runs `openspec new change add-feature`\n- **THEN** the system creates `openspec/changes/add-feature/` directory\n\n#### Scenario: Invalid change name\n- **WHEN** user runs `openspec new change \"Add Feature\"` with invalid name\n- **THEN** the system displays validation error with guidance\n\n#### Scenario: Duplicate change name\n- **WHEN** user runs `openspec new change existing-change` for an existing change\n- **THEN** the system displays an error indicating the change already exists\n\n#### Scenario: Create with description\n- **WHEN** user runs `openspec new change add-feature --description \"Add new feature\"`\n- **THEN** the system creates the change directory with description in README.md\n\n### Requirement: Schema Selection\nThe system SHALL support custom schema selection for workflow commands.\n\n#### Scenario: Default schema\n- **WHEN** user runs workflow commands without `--schema`\n- **THEN** the system uses the \"spec-driven\" schema\n\n#### Scenario: Custom schema\n- **WHEN** user runs `openspec status --change <id> --schema tdd`\n- **THEN** the system uses the specified schema for artifact graph\n\n#### Scenario: Unknown schema\n- **WHEN** user specifies an unknown schema\n- **THEN** the system displays an error listing available schemas\n\n### Requirement: Output Formatting\nThe system SHALL provide consistent output formatting.\n\n#### Scenario: Color output\n- **WHEN** terminal supports colors\n- **THEN** status indicators use colors: green (done), yellow (ready), red (blocked)\n\n#### Scenario: No color output\n- **WHEN** `--no-color` flag is used or NO_COLOR environment variable is set\n- **THEN** output uses text-only indicators without ANSI colors\n\n#### Scenario: Progress indication\n- **WHEN** loading change state takes time\n- **THEN** the system displays a spinner during loading\n\n### Requirement: Experimental Isolation\nThe system SHALL implement artifact workflow commands in isolation for easy removal.\n\n#### Scenario: Single file implementation\n- **WHEN** artifact workflow feature is implemented\n- **THEN** all commands are in `src/commands/artifact-workflow.ts`\n\n#### Scenario: Help text marking\n- **WHEN** user runs `--help` on any artifact workflow command\n- **THEN** help text indicates the command is experimental\n\n### Requirement: Schema Apply Block\n\nThe system SHALL support an `apply` block in schema definitions that controls when and how implementation begins.\n\n#### Scenario: Schema with apply block\n\n- **WHEN** a schema defines an `apply` block\n- **THEN** the system uses `apply.requires` to determine which artifacts must exist before apply\n- **AND** uses `apply.tracks` to identify the file for progress tracking (or null if none)\n- **AND** uses `apply.instruction` for guidance shown to the agent\n\n#### Scenario: Schema without apply block\n\n- **WHEN** a schema has no `apply` block\n- **THEN** the system requires all artifacts to exist before apply is available\n- **AND** uses default instruction: \"All artifacts complete. Proceed with implementation.\"\n\n### Requirement: Apply Instructions Command\n\nThe system SHALL generate schema-aware apply instructions via `openspec instructions apply`.\n\n#### Scenario: Generate apply instructions\n\n- **WHEN** user runs `openspec instructions apply --change <id>`\n- **AND** all required artifacts (per schema's `apply.requires`) exist\n- **THEN** the system outputs:\n  - Context files from all existing artifacts\n  - Schema-specific instruction text\n  - Progress tracking file path (if `apply.tracks` is set)\n\n#### Scenario: Apply blocked by missing artifacts\n\n- **WHEN** user runs `openspec instructions apply --change <id>`\n- **AND** required artifacts are missing\n- **THEN** the system indicates apply is blocked\n- **AND** lists which artifacts must be created first\n\n#### Scenario: Apply instructions JSON output\n\n- **WHEN** user runs `openspec instructions apply --change <id> --json`\n- **THEN** the system outputs JSON with:\n  - `contextFiles`: array of paths to existing artifacts\n  - `instruction`: the apply instruction text\n  - `tracks`: path to progress file or null\n  - `applyRequires`: list of required artifact IDs\n\n### Requirement: Tool selection flag\n\nThe `artifact-experimental-setup` command SHALL accept a `--tool <tool-id>` flag to specify the target AI tool.\n\n#### Scenario: Specify tool via flag\n\n- **WHEN** user runs `openspec artifact-experimental-setup --tool cursor`\n- **THEN** skill files are generated in `.cursor/skills/`\n- **AND** command files are generated using Cursor's frontmatter format\n\n#### Scenario: Missing tool flag\n\n- **WHEN** user runs `openspec artifact-experimental-setup` without `--tool`\n- **THEN** the system displays an error requiring the `--tool` flag\n- **AND** lists valid tool IDs in the error message\n\n#### Scenario: Unknown tool ID\n\n- **WHEN** user runs `openspec artifact-experimental-setup --tool unknown-tool`\n- **AND** the tool ID is not in `AI_TOOLS`\n- **THEN** the system displays an error listing valid tool IDs\n\n#### Scenario: Tool without skillsDir\n\n- **WHEN** user specifies a tool that has no `skillsDir` configured\n- **THEN** the system displays an error indicating skill generation is not supported for that tool\n\n#### Scenario: Tool without command adapter\n\n- **WHEN** user specifies a tool that has `skillsDir` but no command adapter registered\n- **THEN** skill files are generated successfully\n- **AND** command generation is skipped with informational message\n\n### Requirement: Output messaging\n\nThe setup command SHALL display clear output about what was generated.\n\n#### Scenario: Show target tool in output\n\n- **WHEN** setup command runs successfully\n- **THEN** output includes the target tool name (e.g., \"Setting up for Cursor...\")\n\n#### Scenario: Show generated paths\n\n- **WHEN** setup command completes\n- **THEN** output lists all generated skill file paths\n- **AND** lists all generated command file paths (if applicable)\n\n#### Scenario: Show skipped commands message\n\n- **WHEN** command generation is skipped due to missing adapter\n- **THEN** output includes message: \"Command generation skipped - no adapter for <tool>\"\n"
  },
  {
    "path": "openspec/specs/cli-change/spec.md",
    "content": "# cli-change Specification\n\n## Purpose\nDefine `openspec change` command behavior for showing, listing, and validating change proposals and deltas.\n\n## Requirements\n### Requirement: Change Command\n\nThe system SHALL provide a `change` command with subcommands for displaying, listing, and validating change proposals.\n\n#### Scenario: Show change as JSON\n\n- **WHEN** executing `openspec change show update-error --json`\n- **THEN** parse the markdown change file\n- **AND** extract change structure and deltas\n- **AND** output valid JSON to stdout\n\n#### Scenario: List all changes\n\n- **WHEN** executing `openspec change list`\n- **THEN** scan the openspec/changes directory\n- **AND** return list of all pending changes\n- **AND** support JSON output with `--json` flag\n\n#### Scenario: Show only requirement changes\n\n- **WHEN** executing `openspec change show update-error --requirements-only`\n- **THEN** display only the requirement changes (ADDED/MODIFIED/REMOVED/RENAMED)\n- **AND** exclude why and what changes sections\n\n#### Scenario: Validate change structure\n\n- **WHEN** executing `openspec change validate update-error`\n- **THEN** parse the change file\n- **AND** validate against Zod schema\n- **AND** ensure deltas are well-formed\n\n### Requirement: Legacy Compatibility\n\nThe system SHALL maintain backward compatibility with the existing `list` command while showing deprecation notices.\n\n#### Scenario: Legacy list command\n\n- **WHEN** executing `openspec list`\n- **THEN** display current list of changes (existing behavior)\n- **AND** show deprecation notice: \"Note: 'openspec list' is deprecated. Use 'openspec change list' instead.\"\n\n#### Scenario: Legacy list with --all flag\n\n- **WHEN** executing `openspec list --all`\n- **THEN** display all changes (existing behavior)\n- **AND** show same deprecation notice\n\n### Requirement: Interactive show selection\n\nThe change show command SHALL support interactive selection when no change name is provided.\n\n#### Scenario: Interactive change selection for show\n\n- **WHEN** executing `openspec change show` without arguments\n- **THEN** display an interactive list of available changes\n- **AND** allow the user to select a change to show\n- **AND** display the selected change content\n- **AND** maintain all existing show options (--json, --deltas-only)\n\n#### Scenario: Non-interactive fallback keeps current behavior\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec change show` without a change name\n- **THEN** do not prompt interactively\n- **AND** print the existing hint including available change IDs\n- **AND** set `process.exitCode = 1`\n\n### Requirement: Interactive validation selection\n\nThe change validate command SHALL support interactive selection when no change name is provided.\n\n#### Scenario: Interactive change selection for validation\n\n- **WHEN** executing `openspec change validate` without arguments\n- **THEN** display an interactive list of available changes\n- **AND** allow the user to select a change to validate\n- **AND** validate the selected change\n\n#### Scenario: Non-interactive fallback keeps current behavior\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec change validate` without a change name\n- **THEN** do not prompt interactively\n- **AND** print the existing hint including available change IDs\n- **AND** set `process.exitCode = 1`\n\n"
  },
  {
    "path": "openspec/specs/cli-completion/spec.md",
    "content": "# cli-completion Specification\n\n## Purpose\nProvide shell completion scripts for the OpenSpec CLI, enabling tab-completion for commands, flags, and dynamic values (change IDs, spec IDs) across multiple shells. Supports Zsh, Bash, Fish, and PowerShell.\n## Requirements\n### Requirement: Native Shell Behavior Integration\n\nThe completion system SHALL respect and integrate with each supported shell's native completion patterns and user interaction model.\n\n#### Scenario: Zsh native completion\n\n- **WHEN** generating Zsh completion scripts\n- **THEN** use Zsh completion system with `_arguments`, `_describe`, and `compadd`\n- **AND** completions SHALL trigger on single TAB (standard Zsh behavior)\n- **AND** display as an interactive menu that users navigate with TAB/arrow keys\n- **AND** support Oh My Zsh's enhanced menu styling automatically\n\n#### Scenario: Bash native completion\n\n- **WHEN** generating Bash completion scripts\n- **THEN** use Bash completion with `complete` builtin and `COMPREPLY` array\n- **AND** completions SHALL trigger on double TAB (standard Bash behavior)\n- **AND** display as space-separated list or column format\n- **AND** support both bash-completion v1 and v2 patterns\n\n#### Scenario: Fish native completion\n\n- **WHEN** generating Fish completion scripts\n- **THEN** use Fish's `complete` command with conditions\n- **AND** completions SHALL trigger on single TAB with auto-suggestion preview\n- **AND** display with Fish's native coloring and description alignment\n- **AND** leverage Fish's built-in caching automatically\n\n#### Scenario: PowerShell native completion\n\n- **WHEN** generating PowerShell completion scripts\n- **THEN** use `Register-ArgumentCompleter` with scriptblock\n- **AND** completions SHALL trigger on TAB with cycling behavior\n- **AND** display with PowerShell's native completion UI\n- **AND** support both Windows PowerShell 5.1 and PowerShell Core 7+\n\n#### Scenario: No custom UX patterns\n\n- **WHEN** implementing completion for any shell\n- **THEN** do NOT attempt to customize completion trigger behavior\n- **AND** do NOT override shell-specific navigation patterns\n- **AND** ensure completions feel native to experienced users of that shell\n\n### Requirement: Command Structure\n\nThe completion command SHALL follow a subcommand pattern for generating and managing completion scripts.\n\n#### Scenario: Available subcommands\n\n- **WHEN** user executes `openspec completion --help`\n- **THEN** display available subcommands:\n  - `generate [shell]` - Generate completion script for a shell (outputs to stdout)\n  - `install [shell]` - Install completion for Zsh (auto-detects or requires explicit shell)\n  - `uninstall [shell]` - Remove completion for Zsh (auto-detects or requires explicit shell)\n\n### Requirement: Shell Detection\n\nThe completion system SHALL automatically detect the user's current shell environment.\n\n#### Scenario: Detecting Zsh from environment\n\n- **WHEN** no shell is explicitly specified\n- **THEN** read the `$SHELL` environment variable\n- **AND** extract the shell name from the path (e.g., `/bin/zsh` → `zsh`)\n- **AND** validate the shell is one of: `zsh`, `bash`, `fish`, `powershell`\n- **AND** throw an error if the shell is not supported\n\n#### Scenario: Detecting Bash from environment\n\n- **WHEN** `$SHELL` contains `bash` in the path\n- **THEN** detect shell as `bash`\n- **AND** proceed with bash-specific completion logic\n\n#### Scenario: Detecting Fish from environment\n\n- **WHEN** `$SHELL` contains `fish` in the path\n- **THEN** detect shell as `fish`\n- **AND** proceed with fish-specific completion logic\n\n#### Scenario: Detecting PowerShell from environment\n\n- **WHEN** `$PSModulePath` environment variable is present\n- **THEN** detect shell as `powershell`\n- **AND** proceed with PowerShell-specific completion logic\n\n#### Scenario: Unsupported shell detection\n\n- **WHEN** shell path indicates an unsupported shell\n- **THEN** throw error: \"Shell '<name>' is not supported. Supported shells: zsh, bash, fish, powershell\"\n\n### Requirement: Completion Generation\n\nThe completion command SHALL generate completion scripts for all supported shells on demand.\n\n#### Scenario: Generating Zsh completion\n\n- **WHEN** user executes `openspec completion generate zsh`\n- **THEN** output a complete Zsh completion script to stdout\n- **AND** include completions for all commands: init, list, show, validate, archive, view, update, change, spec, completion\n- **AND** include all command-specific flags and options\n- **AND** use Zsh's `_arguments` and `_describe` built-in functions\n- **AND** support dynamic completion for change and spec IDs\n\n#### Scenario: Generating Bash completion\n\n- **WHEN** user executes `openspec completion generate bash`\n- **THEN** output a complete Bash completion script to stdout\n- **AND** include completions for all commands and subcommands\n- **AND** use `complete -F` with custom completion function\n- **AND** populate `COMPREPLY` with appropriate suggestions\n- **AND** support dynamic completion for change and spec IDs via `openspec __complete`\n\n#### Scenario: Generating Fish completion\n\n- **WHEN** user executes `openspec completion generate fish`\n- **THEN** output a complete Fish completion script to stdout\n- **AND** use `complete -c openspec` with conditions\n- **AND** include command-specific completions with `--condition` predicates\n- **AND** support dynamic completion for change and spec IDs via `openspec __complete`\n- **AND** include descriptions for each completion option\n\n#### Scenario: Generating PowerShell completion\n\n- **WHEN** user executes `openspec completion generate powershell`\n- **THEN** output a complete PowerShell completion script to stdout\n- **AND** use `Register-ArgumentCompleter -CommandName openspec`\n- **AND** implement scriptblock that handles command context\n- **AND** support dynamic completion for change and spec IDs via `openspec __complete`\n- **AND** return `[System.Management.Automation.CompletionResult]` objects\n\n### Requirement: Dynamic Completions\n\nThe completion system SHALL provide context-aware dynamic completions for project-specific values.\n\n#### Scenario: Completing change IDs\n\n- **WHEN** completing arguments for commands that accept change names (show, validate, archive)\n- **THEN** discover active changes from `openspec/changes/` directory\n- **AND** exclude archived changes in `openspec/changes/archive/`\n- **AND** return change IDs as completion suggestions\n- **AND** only provide suggestions when inside an OpenSpec-enabled project\n\n#### Scenario: Completing spec IDs\n\n- **WHEN** completing arguments for commands that accept spec names (show, validate)\n- **THEN** discover specs from `openspec/specs/` directory\n- **AND** return spec IDs as completion suggestions\n- **AND** only provide suggestions when inside an OpenSpec-enabled project\n\n#### Scenario: Completion caching\n\n- **WHEN** dynamic completions are requested\n- **THEN** cache discovered change and spec IDs for 2 seconds\n- **AND** reuse cached values for subsequent requests within cache window\n- **AND** automatically refresh cache after expiration\n\n#### Scenario: Project detection\n\n- **WHEN** user requests completions outside an OpenSpec project\n- **THEN** skip dynamic change/spec ID completions\n- **AND** only suggest static commands and flags\n\n### Requirement: Installation Automation\n\nThe completion command SHALL automatically install completion scripts into shell configuration files for all supported shells.\n\n#### Scenario: Installing for Oh My Zsh\n\n- **WHEN** user executes `openspec completion install zsh`\n- **THEN** detect if Oh My Zsh is installed by checking for `$ZSH` environment variable or `~/.oh-my-zsh/` directory\n- **AND** create custom completions directory at `~/.oh-my-zsh/custom/completions/` if it doesn't exist\n- **AND** write completion script to `~/.oh-my-zsh/custom/completions/_openspec`\n- **AND** ensure `~/.oh-my-zsh/custom/completions` is in `$fpath` by updating `~/.zshrc` if needed\n- **AND** display success message with instruction to run `exec zsh` or restart terminal\n\n#### Scenario: Installing for standard Zsh\n\n- **WHEN** user executes `openspec completion install zsh` and Oh My Zsh is not detected\n- **THEN** create completions directory at `~/.zsh/completions/` if it doesn't exist\n- **AND** write completion script to `~/.zsh/completions/_openspec`\n- **AND** add `fpath=(~/.zsh/completions $fpath)` to `~/.zshrc` if not already present\n- **AND** add `autoload -Uz compinit && compinit` to `~/.zshrc` if not already present\n- **AND** display success message with instruction to run `exec zsh` or restart terminal\n\n#### Scenario: Installing for Bash with bash-completion\n\n- **WHEN** user executes `openspec completion install bash`\n- **THEN** detect if bash-completion is installed by checking for `/usr/share/bash-completion` or `/etc/bash_completion.d`\n- **AND** if bash-completion is available, write to `/etc/bash_completion.d/openspec` (with sudo) or `~/.local/share/bash-completion/completions/openspec`\n- **AND** if bash-completion is not available, write to `~/.bash_completion.d/openspec` and source it from `~/.bashrc`\n- **AND** add sourcing line to `~/.bashrc` using marker-based updates if needed\n- **AND** display success message with instruction to run `exec bash` or restart terminal\n\n#### Scenario: Installing for Fish\n\n- **WHEN** user executes `openspec completion install fish`\n- **THEN** create Fish completions directory at `~/.config/fish/completions/` if it doesn't exist\n- **AND** write completion script to `~/.config/fish/completions/openspec.fish`\n- **AND** Fish automatically loads completions from this directory (no config file modification needed)\n- **AND** display success message indicating completions are immediately available\n\n#### Scenario: Installing for PowerShell\n\n- **WHEN** user executes `openspec completion install powershell`\n- **THEN** detect PowerShell profile location via `$PROFILE` environment variable or default paths\n- **AND** create profile directory if it doesn't exist\n- **AND** add completion script import to profile using marker-based updates\n- **AND** write completion script to PowerShell modules directory or alongside profile\n- **AND** display success message with instruction to restart PowerShell or run `. $PROFILE`\n\n#### Scenario: Auto-detecting shell for installation\n\n- **WHEN** user executes `openspec completion install` without specifying a shell\n- **THEN** detect current shell using shell detection logic\n- **AND** install completion for the detected shell (zsh, bash, fish, or powershell)\n- **AND** display which shell was detected\n\n#### Scenario: Already installed\n\n- **WHEN** completion is already installed for the target shell\n- **THEN** display message indicating completion is already installed\n- **AND** offer to reinstall/update by overwriting existing files\n- **AND** exit with code 0\n\n### Requirement: Uninstallation\n\nThe completion command SHALL remove installed completion scripts and configuration for all supported shells.\n\n#### Scenario: Uninstalling Zsh completion\n\n- **WHEN** user executes `openspec completion uninstall zsh`\n- **THEN** prompt for confirmation before proceeding (unless `--yes` flag provided)\n- **AND** if user declines, cancel uninstall and display \"Uninstall cancelled.\"\n- **AND** if user confirms, remove `~/.oh-my-zsh/custom/completions/_openspec` if Oh My Zsh is detected\n- **AND** remove `~/.zsh/completions/_openspec` if standard Zsh setup is detected\n- **AND** remove fpath modifications from `~/.zshrc` using marker-based removal\n- **AND** display success message\n\n#### Scenario: Uninstalling Bash completion\n\n- **WHEN** user executes `openspec completion uninstall bash`\n- **THEN** prompt for confirmation (unless `--yes` flag provided)\n- **AND** if user confirms, remove completion file from bash-completion directory or `~/.bash_completion.d/`\n- **AND** remove sourcing lines from `~/.bashrc` using marker-based removal\n- **AND** display success message\n\n#### Scenario: Uninstalling Fish completion\n\n- **WHEN** user executes `openspec completion uninstall fish`\n- **THEN** prompt for confirmation (unless `--yes` flag provided)\n- **AND** if user confirms, remove `~/.config/fish/completions/openspec.fish`\n- **AND** display success message (no config file modification needed)\n\n#### Scenario: Uninstalling PowerShell completion\n\n- **WHEN** user executes `openspec completion uninstall powershell`\n- **THEN** prompt for confirmation (unless `--yes` flag provided)\n- **AND** if user confirms, remove completion import from PowerShell profile using marker-based removal\n- **AND** remove completion script file\n- **AND** display success message\n\n#### Scenario: Auto-detecting shell for uninstallation\n\n- **WHEN** user executes `openspec completion uninstall` without specifying a shell\n- **THEN** detect current shell and uninstall completion for that shell\n\n#### Scenario: Not installed\n\n- **WHEN** attempting to uninstall completion that isn't installed\n- **THEN** display error message indicating completion is not installed\n- **AND** exit with code 1\n\n### Requirement: Architecture Patterns\n\nThe completion implementation SHALL follow clean architecture principles with TypeScript best practices, supporting multiple shells through a plugin-based pattern.\n\n#### Scenario: Shell-specific generators\n\n- **WHEN** implementing completion generators\n- **THEN** create generator classes for each shell: `ZshGenerator`, `BashGenerator`, `FishGenerator`, `PowerShellGenerator`\n- **AND** implement a common `CompletionGenerator` interface with method:\n  - `generate(commands: CommandDefinition[]): string` - Returns complete shell script\n- **AND** each generator handles shell-specific syntax, escaping, and patterns\n- **AND** all generators consume the same `CommandDefinition[]` from the command registry\n\n#### Scenario: Shell-specific installers\n\n- **WHEN** implementing completion installers\n- **THEN** create installer classes for each shell: `ZshInstaller`, `BashInstaller`, `FishInstaller`, `PowerShellInstaller`\n- **AND** implement a common `CompletionInstaller` interface with methods:\n  - `install(script: string): Promise<InstallationResult>` - Installs completion script\n  - `uninstall(): Promise<{ success: boolean; message: string }>` - Removes completion\n- **AND** each installer handles shell-specific paths, config files, and installation patterns\n\n#### Scenario: Factory pattern for shell selection\n\n- **WHEN** selecting shell-specific implementation\n- **THEN** use `CompletionFactory` class with static methods:\n  - `createGenerator(shell: SupportedShell): CompletionGenerator`\n  - `createInstaller(shell: SupportedShell): CompletionInstaller`\n- **AND** factory uses switch statements with TypeScript exhaustiveness checking\n- **AND** adding new shell requires updating `SupportedShell` type and factory cases\n\n#### Scenario: Dynamic completion providers\n\n- **WHEN** implementing dynamic completions\n- **THEN** create a `CompletionProvider` class that encapsulates project discovery logic\n- **AND** implement methods:\n  - `getChangeIds(): Promise<string[]>` - Discovers active change IDs\n  - `getSpecIds(): Promise<string[]>` - Discovers spec IDs\n  - `isOpenSpecProject(): boolean` - Checks if current directory is OpenSpec-enabled\n- **AND** implement caching with 2-second TTL using class properties\n\n#### Scenario: Command registry\n\n- **WHEN** defining completable commands\n- **THEN** create a centralized `CommandDefinition` type with properties:\n  - `name: string` - Command name\n  - `description: string` - Help text\n  - `flags: FlagDefinition[]` - Available flags\n  - `acceptsPositional: boolean` - Whether command takes positional arguments\n  - `positionalType: string` - Type of positional (change-id, spec-id, path, shell)\n  - `subcommands?: CommandDefinition[]` - Nested subcommands\n- **AND** export a `COMMAND_REGISTRY` constant with all command definitions\n- **AND** all generators consume this registry to ensure consistency across shells\n\n#### Scenario: Type-safe shell detection\n\n- **WHEN** implementing shell detection\n- **THEN** define a `SupportedShell` type as literal type: `'zsh' | 'bash' | 'fish' | 'powershell'`\n- **AND** implement `detectShell()` function in `src/utils/shell-detection.ts`\n- **AND** return detected shell or throw error with supported shells list\n\n### Requirement: Error Handling\n\nThe completion command SHALL provide clear error messages for common failure scenarios.\n\n#### Scenario: Unsupported shell\n\n- **WHEN** user requests completion for unsupported shell (e.g., ksh, csh, tcsh)\n- **THEN** display error message: \"Shell '<name>' is not supported yet. Currently supported: zsh, bash, fish, powershell\"\n- **AND** exit with code 1\n\n#### Scenario: Permission errors during installation\n\n- **WHEN** installation fails due to file permission issues\n- **THEN** display clear error message indicating permission problem\n- **AND** suggest using appropriate permissions or alternative installation method\n- **AND** exit with code 1\n\n#### Scenario: Missing shell configuration directory\n\n- **WHEN** expected shell configuration directory doesn't exist\n- **THEN** create the directory automatically (with user notification)\n- **AND** proceed with installation\n\n#### Scenario: Shell not detected\n\n- **WHEN** `openspec completion install` cannot detect current shell\n- **THEN** display error: \"Could not auto-detect shell. Please specify shell explicitly.\"\n- **AND** display usage hint: \"Usage: openspec completion <operation> [shell]\"\n- **AND** exit with code 1\n\n### Requirement: Output Format\n\nThe completion command SHALL provide machine-parseable and human-readable output.\n\n#### Scenario: Script generation output\n\n- **WHEN** generating completion script to stdout\n- **THEN** output only the completion script content (no extra messages)\n- **AND** allow redirection to files: `openspec completion generate zsh > /path/to/_openspec`\n\n#### Scenario: Installation success output\n\n- **WHEN** installation completes successfully\n- **THEN** display formatted success message with:\n  - Checkmark indicator\n  - Installation location\n  - Next steps (shell reload instructions)\n- **AND** use colors when terminal supports it (unless `--no-color` is set)\n\n#### Scenario: Verbose installation output\n\n- **WHEN** user provides `--verbose` flag during installation\n- **THEN** display detailed steps:\n  - Shell detection result\n  - Target file paths\n  - Configuration modifications\n  - File creation confirmations\n\n### Requirement: Testing Support\n\nThe completion implementation SHALL be testable with unit and integration tests for all supported shells.\n\n#### Scenario: Mock shell environment\n\n- **WHEN** writing tests for shell detection\n- **THEN** allow overriding `$SHELL` and `$PSModulePath` environment variables\n- **AND** use dependency injection for file system operations\n- **AND** test detection for all four shells independently\n\n#### Scenario: Generator output verification\n\n- **WHEN** testing completion generators\n- **THEN** create test suite for each shell generator (zsh, bash, fish, powershell)\n- **AND** verify generated scripts contain expected patterns for that shell\n- **AND** test that command registry is properly consumed\n- **AND** ensure dynamic completion placeholders are present\n- **AND** verify shell-specific syntax and escaping\n\n#### Scenario: Installer simulation\n\n- **WHEN** testing installation logic\n- **THEN** create test suite for each shell installer\n- **AND** use temporary test directories instead of actual home directories\n- **AND** verify file creation without modifying real shell configurations\n- **AND** test path resolution logic independently\n- **AND** mock file system operations to avoid side effects\n\n#### Scenario: Cross-shell consistency\n\n- **WHEN** testing completion behavior\n- **THEN** verify all shells support the same commands and flags\n- **AND** verify dynamic completions work consistently across shells\n- **AND** ensure error messages are consistent across shells\n\n"
  },
  {
    "path": "openspec/specs/cli-config/spec.md",
    "content": "# cli-config Specification\n\n## Purpose\nProvide a user-friendly CLI interface for viewing and modifying global OpenSpec configuration settings without manually editing JSON files.\n## Requirements\n### Requirement: Command Structure\n\nThe config command SHALL provide subcommands for all configuration operations.\n\n#### Scenario: Available subcommands\n\n- **WHEN** user executes `openspec config --help`\n- **THEN** display available subcommands:\n  - `path` - Show config file location\n  - `list` - Show all current settings\n  - `get <key>` - Get a specific value\n  - `set <key> <value>` - Set a value\n  - `unset <key>` - Remove a key (revert to default)\n  - `reset` - Reset configuration to defaults\n  - `edit` - Open config in editor\n\n### Requirement: Config Path\n\nThe config command SHALL display the config file location.\n\n#### Scenario: Show config path\n\n- **WHEN** user executes `openspec config path`\n- **THEN** print the absolute path to the config file\n- **AND** exit with code 0\n\n### Requirement: Config List\n\nThe config command SHALL display all current configuration values.\n\n#### Scenario: List config in human-readable format\n\n- **WHEN** user executes `openspec config list`\n- **THEN** display all config values in YAML-like format\n- **AND** show nested objects with indentation\n\n#### Scenario: List config as JSON\n\n- **WHEN** user executes `openspec config list --json`\n- **THEN** output the complete config as valid JSON\n- **AND** output only JSON (no additional text)\n\n### Requirement: Config Get\n\nThe config command SHALL retrieve specific configuration values.\n\n#### Scenario: Get top-level key\n\n- **WHEN** user executes `openspec config get <key>` with a valid top-level key\n- **THEN** print the raw value only (no labels or formatting)\n- **AND** exit with code 0\n\n#### Scenario: Get nested key with dot notation\n\n- **WHEN** user executes `openspec config get featureFlags.someFlag`\n- **THEN** traverse the nested structure using dot notation\n- **AND** print the value at that path\n\n#### Scenario: Get non-existent key\n\n- **WHEN** user executes `openspec config get <key>` with a key that does not exist\n- **THEN** print nothing (empty output)\n- **AND** exit with code 1\n\n#### Scenario: Get object value\n\n- **WHEN** user executes `openspec config get <key>` where the value is an object\n- **THEN** print the object as JSON\n\n### Requirement: Config Set\n\nThe config command SHALL set configuration values with automatic type coercion.\n\n#### Scenario: Set string value\n\n- **WHEN** user executes `openspec config set <key> <value>`\n- **AND** value does not match boolean or number patterns\n- **THEN** store value as a string\n- **AND** display confirmation message\n\n#### Scenario: Set boolean value\n\n- **WHEN** user executes `openspec config set <key> true` or `openspec config set <key> false`\n- **THEN** store value as boolean (not string)\n- **AND** display confirmation message\n\n#### Scenario: Set numeric value\n\n- **WHEN** user executes `openspec config set <key> <value>`\n- **AND** value is a valid number (integer or float)\n- **THEN** store value as number (not string)\n\n#### Scenario: Force string with --string flag\n\n- **WHEN** user executes `openspec config set <key> <value> --string`\n- **THEN** store value as string regardless of content\n- **AND** this allows storing literal \"true\" or \"123\" as strings\n\n#### Scenario: Set nested key\n\n- **WHEN** user executes `openspec config set featureFlags.newFlag true`\n- **THEN** create intermediate objects if they don't exist\n- **AND** set the value at the nested path\n\n### Requirement: Config Unset\n\nThe config command SHALL remove configuration overrides.\n\n#### Scenario: Unset existing key\n\n- **WHEN** user executes `openspec config unset <key>`\n- **AND** the key exists in the config\n- **THEN** remove the key from the config file\n- **AND** the value reverts to its default\n- **AND** display confirmation message\n\n#### Scenario: Unset non-existent key\n\n- **WHEN** user executes `openspec config unset <key>`\n- **AND** the key does not exist in the config\n- **THEN** display message indicating key was not set\n- **AND** exit with code 0\n\n### Requirement: Config Reset\n\nThe config command SHALL reset configuration to defaults.\n\n#### Scenario: Reset all with confirmation\n\n- **WHEN** user executes `openspec config reset --all`\n- **THEN** prompt for confirmation before proceeding\n- **AND** if confirmed, delete the config file or reset to defaults\n- **AND** display confirmation message\n\n#### Scenario: Reset all with -y flag\n\n- **WHEN** user executes `openspec config reset --all -y`\n- **THEN** reset without prompting for confirmation\n\n#### Scenario: Reset without --all flag\n\n- **WHEN** user executes `openspec config reset` without `--all`\n- **THEN** display error indicating `--all` is required\n- **AND** exit with code 1\n\n### Requirement: Config Edit\n\nThe config command SHALL open the config file in the user's editor.\n\n#### Scenario: Open editor successfully\n\n- **WHEN** user executes `openspec config edit`\n- **AND** `$EDITOR` or `$VISUAL` environment variable is set\n- **THEN** open the config file in that editor\n- **AND** create the config file with defaults if it doesn't exist\n- **AND** wait for the editor to close before returning\n\n#### Scenario: No editor configured\n\n- **WHEN** user executes `openspec config edit`\n- **AND** neither `$EDITOR` nor `$VISUAL` is set\n- **THEN** display error message suggesting to set `$EDITOR`\n- **AND** exit with code 1\n\n### Requirement: Profile Configuration Flow\n\nThe `openspec config profile` command SHALL provide an action-first interactive flow that allows users to modify delivery and workflow settings independently.\n\n#### Scenario: Current profile summary appears first\n\n- **WHEN** user runs `openspec config profile` in an interactive terminal\n- **THEN** display a current-state header with:\n  - current delivery value\n  - workflow count with profile label (core or custom)\n\n#### Scenario: Action-first menu offers skippable paths\n\n- **WHEN** user runs `openspec config profile` interactively\n- **THEN** the first prompt SHALL offer:\n  - `Change delivery + workflows`\n  - `Change delivery only`\n  - `Change workflows only`\n  - `Keep current settings (exit)`\n\n#### Scenario: Delivery prompt marks current selection\n\n- **WHEN** delivery selection is shown in `openspec config profile`\n- **THEN** the currently configured delivery option SHALL include `[current]` in its label\n- **AND** that value SHALL be preselected by default\n\n#### Scenario: No-op exits without saving or apply prompt\n\n- **WHEN** user chooses `Keep current settings (exit)` OR makes selections that do not change effective config values\n- **THEN** the command SHALL print `No config changes.`\n- **AND** SHALL NOT write config changes\n- **AND** SHALL NOT ask to apply updates to the current project\n\n#### Scenario: No-op warns when current project is out of sync\n\n- **WHEN** `openspec config profile` exits with `No config changes.` inside an OpenSpec project\n- **AND** project files are out of sync with the current global profile/delivery\n- **THEN** display a non-blocking warning that global config is not yet applied to this project\n- **AND** include guidance to run `openspec update` to sync project files\n\n#### Scenario: Apply prompt is gated on actual changes\n\n- **WHEN** config values were changed and saved\n- **AND** current directory is an OpenSpec project\n- **THEN** prompt `Apply changes to this project now?`\n- **AND** if confirmed, run `openspec update` for the current project\n\n### Requirement: Key Naming Convention\n\nThe config command SHALL use camelCase keys matching the JSON structure.\n\n#### Scenario: Keys match JSON structure\n\n- **WHEN** accessing configuration keys via CLI\n- **THEN** use camelCase matching the actual JSON property names\n- **AND** support dot notation for nested access (e.g., `featureFlags.someFlag`)\n\n### Requirement: Schema Validation\n\nThe config command SHALL validate configuration writes against the config schema using zod, while rejecting unknown keys for `config set` unless explicitly overridden.\n\n#### Scenario: Unknown key rejected by default\n\n- **WHEN** user executes `openspec config set someFutureKey 123`\n- **THEN** display a descriptive error message indicating the key is invalid\n- **AND** do not modify the config file\n- **AND** exit with code 1\n\n#### Scenario: Unknown key accepted with override\n\n- **WHEN** user executes `openspec config set someFutureKey 123 --allow-unknown`\n- **THEN** the value is saved successfully\n- **AND** exit with code 0\n\n#### Scenario: Invalid feature flag value rejected\n\n- **WHEN** user executes `openspec config set featureFlags.someFlag notABoolean`\n- **THEN** display a descriptive error message\n- **AND** do not modify the config file\n- **AND** exit with code 1\n\n### Requirement: Reserved Scope Flag\n\nThe config command SHALL reserve the `--scope` flag for future extensibility.\n\n#### Scenario: Scope flag defaults to global\n\n- **WHEN** user executes any config command without `--scope`\n- **THEN** operate on global configuration (default behavior)\n\n#### Scenario: Project scope not yet implemented\n\n- **WHEN** user executes `openspec config --scope project <subcommand>`\n- **THEN** display error message: \"Project-local config is not yet implemented\"\n- **AND** exit with code 1\n"
  },
  {
    "path": "openspec/specs/cli-feedback/spec.md",
    "content": "# cli-feedback Specification\n\n## Purpose\nDefine `openspec feedback` behavior for creating GitHub issues safely via `gh`, with a manual fallback when automation is unavailable.\n\n## Requirements\n### Requirement: Feedback command\n\nThe system SHALL provide an `openspec feedback` command that creates a GitHub Issue in the openspec repository using the `gh` CLI. The system SHALL use `execFileSync` with argument arrays to prevent shell injection vulnerabilities.\n\n#### Scenario: Simple feedback submission\n\n- **WHEN** user executes `openspec feedback \"Great tool!\"`\n- **THEN** the system executes `gh issue create` with title \"Feedback: Great tool!\"\n- **AND** the issue is created in the openspec repository\n- **AND** the issue has the `feedback` label\n- **AND** the system displays the created issue URL\n\n#### Scenario: Safe command execution\n\n- **WHEN** submitting feedback via `gh` CLI\n- **THEN** the system uses `execFileSync` with separate arguments array\n- **AND** user input is NOT passed through a shell\n- **AND** shell metacharacters (quotes, backticks, $(), etc.) are treated as literal text\n\n#### Scenario: Feedback with body\n\n- **WHEN** user executes `openspec feedback \"Title here\" --body \"Detailed description...\"`\n- **THEN** the system creates a GitHub Issue with the specified title\n- **AND** the issue body contains the detailed description\n- **AND** the issue body includes metadata (OpenSpec version, platform, timestamp)\n\n### Requirement: GitHub CLI dependency\n\nThe system SHALL use `gh` CLI for automatic feedback submission when available, and provide a manual submission fallback when `gh` is not installed or not authenticated. The system SHALL use platform-appropriate commands to detect `gh` CLI availability.\n\n#### Scenario: Missing gh CLI with fallback\n\n- **WHEN** user runs `openspec feedback \"message\"`\n- **AND** `gh` CLI is not installed (not found in PATH)\n- **THEN** the system displays warning: \"GitHub CLI not found. Manual submission required.\"\n- **AND** outputs structured feedback content with delimiters:\n  - \"--- FORMATTED FEEDBACK ---\"\n  - Title line\n  - Labels line\n  - Body content with metadata\n  - \"--- END FEEDBACK ---\"\n- **AND** displays pre-filled GitHub issue URL for manual submission\n- **AND** exits with zero code (successful fallback)\n\n#### Scenario: Cross-platform gh CLI detection on Unix\n\n- **WHEN** system is running on macOS or Linux (platform is 'darwin' or 'linux')\n- **AND** checking if `gh` CLI is installed\n- **THEN** the system executes `which gh` command\n\n#### Scenario: Cross-platform gh CLI detection on Windows\n\n- **WHEN** system is running on Windows (platform is 'win32')\n- **AND** checking if `gh` CLI is installed\n- **THEN** the system executes `where gh` command\n\n#### Scenario: Unauthenticated gh CLI with fallback\n\n- **WHEN** user runs `openspec feedback \"message\"`\n- **AND** `gh` CLI is installed but not authenticated\n- **THEN** the system displays warning: \"GitHub authentication required. Manual submission required.\"\n- **AND** outputs structured feedback content (same format as missing gh CLI scenario)\n- **AND** displays pre-filled GitHub issue URL for manual submission\n- **AND** displays authentication instructions: \"To auto-submit in the future: gh auth login\"\n- **AND** exits with zero code (successful fallback)\n\n#### Scenario: Authenticated gh CLI\n\n- **WHEN** user runs `openspec feedback \"message\"`\n- **AND** `gh auth status` returns success (authenticated)\n- **THEN** the system proceeds with feedback submission\n\n### Requirement: Issue metadata\n\nThe system SHALL include relevant metadata in the GitHub Issue body.\n\n#### Scenario: Standard metadata\n\n- **WHEN** creating a GitHub Issue for feedback\n- **THEN** the issue body includes:\n  - OpenSpec CLI version\n  - Platform (darwin, linux, win32)\n  - Submission timestamp\n  - Separator line: \"---\\nSubmitted via OpenSpec CLI\"\n\n#### Scenario: Windows platform metadata\n\n- **WHEN** creating a GitHub Issue for feedback on Windows\n- **THEN** the issue body includes \"Platform: win32\"\n- **AND** all platform detection uses Node.js `os.platform()` API\n\n#### Scenario: No sensitive metadata\n\n- **WHEN** creating a GitHub Issue for feedback\n- **THEN** the issue body does NOT include:\n  - File paths from user's system\n  - Project names or directory names\n  - Environment variables\n  - IP addresses\n\n### Requirement: Feedback always works\n\nThe system SHALL allow feedback submission regardless of telemetry settings.\n\n#### Scenario: Feedback with telemetry disabled\n\n- **WHEN** user has disabled telemetry via `OPENSPEC_TELEMETRY=0`\n- **AND** user runs `openspec feedback \"message\"`\n- **THEN** the feedback is still submitted via `gh` CLI\n- **AND** telemetry events are not sent\n\n#### Scenario: Feedback in CI environment\n\n- **WHEN** `CI=true` is set in the environment\n- **AND** user runs `openspec feedback \"message\"`\n- **THEN** the feedback submission proceeds normally (if `gh` is available and authenticated)\n\n### Requirement: Error handling\n\nThe system SHALL handle feedback submission errors gracefully.\n\n#### Scenario: gh CLI execution failure\n\n- **WHEN** `gh issue create` command fails\n- **THEN** the system displays the error output from `gh` CLI\n- **AND** exits with the same exit code as `gh`\n\n#### Scenario: Network failure\n\n- **WHEN** `gh` CLI reports network connectivity issues\n- **THEN** the system displays the error message from `gh`\n- **AND** suggests checking network connectivity\n- **AND** exits with non-zero code\n\n### Requirement: Feedback skill for agents\n\nThe system SHALL provide a `/feedback` skill that guides agents through collecting and submitting user feedback.\n\n#### Scenario: Agent-initiated feedback\n\n- **WHEN** user invokes `/feedback` in an agent conversation\n- **THEN** the agent gathers context from the conversation\n- **AND** drafts a feedback issue with enriched content\n- **AND** anonymizes sensitive information\n- **AND** presents the draft to the user for approval\n- **AND** submits via `openspec feedback` command on user confirmation\n\n#### Scenario: Context enrichment\n\n- **WHEN** agent drafts feedback\n- **THEN** the agent includes relevant context such as:\n  - What task was being performed\n  - What worked well or poorly\n  - Specific friction points or praise\n\n#### Scenario: Anonymization\n\n- **WHEN** agent drafts feedback\n- **THEN** the agent removes or replaces:\n  - File paths with `<path>` or generic descriptions\n  - API keys, tokens, secrets with `<redacted>`\n  - Company/organization names with `<company>`\n  - Personal names with `<user>`\n  - Specific URLs with `<url>` unless public/relevant\n\n#### Scenario: User confirmation required\n\n- **WHEN** agent has drafted feedback\n- **THEN** the agent MUST show the complete draft to the user\n- **AND** ask for explicit approval before submitting\n- **AND** allow the user to request modifications\n- **AND** only submit after user confirms\n\n### Requirement: Shell completions\n\nThe system SHALL provide shell completions for the feedback command.\n\n#### Scenario: Command completion\n\n- **WHEN** user types `openspec fee<TAB>`\n- **THEN** the shell completes to `openspec feedback`\n\n#### Scenario: Flag completion\n\n- **WHEN** user types `openspec feedback \"msg\" --<TAB>`\n- **THEN** the shell suggests available flags (`--body`)\n\n"
  },
  {
    "path": "openspec/specs/cli-init/spec.md",
    "content": "# CLI Init Specification\n\n## Purpose\n\nThe `openspec init` command SHALL create a complete OpenSpec directory structure in any project, enabling immediate adoption of OpenSpec conventions with support for multiple AI coding assistants.\n## Requirements\n### Requirement: Progress Indicators\n\nThe command SHALL display progress indicators during initialization to provide clear feedback about each step.\n\n#### Scenario: Displaying initialization progress\n\n- **WHEN** executing initialization steps\n- **THEN** validate environment silently in background (no output unless error)\n- **AND** display progress with ora spinners:\n  - Show spinner: \"⠋ Creating OpenSpec structure...\"\n  - Then success: \"✔ OpenSpec structure created\"\n  - Show spinner: \"⠋ Configuring AI tools...\"\n  - Then success: \"✔ AI tools configured\"\n\n### Requirement: Directory Creation\n\nThe command SHALL create the OpenSpec directory structure with config file.\n\n#### Scenario: Creating OpenSpec structure\n\n- **WHEN** `openspec init` is executed\n- **THEN** create the following directory structure:\n```\nopenspec/\n├── config.yaml\n├── specs/\n└── changes/\n    └── archive/\n```\n\n### Requirement: AI Tool Configuration\n\nThe command SHALL configure AI coding assistants with skills and slash commands using a searchable multi-select experience.\n\n#### Scenario: Prompting for AI tool selection\n\n- **WHEN** run interactively\n- **THEN** display animated welcome screen with OpenSpec logo\n- **AND** present a searchable multi-select that shows all available tools\n- **AND** mark already configured tools with \"(configured ✓)\" indicator\n- **AND** pre-select configured tools for easy refresh\n- **AND** sort configured tools to appear first in the list\n- **AND** allow filtering by typing to search\n\n#### Scenario: Selecting tools to configure\n\n- **WHEN** user selects tools and confirms\n- **THEN** generate skills in `.<tool>/skills/` directory for each selected tool\n- **AND** generate slash commands in `.<tool>/commands/opsx/` directory for each selected tool\n- **AND** create `openspec/config.yaml` with default schema setting\n\n### Requirement: Interactive Mode\nThe command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.\n#### Scenario: Displaying interactive menu\n- **WHEN** run in fresh or extend mode\n- **THEN** present a looping select menu that lets users toggle tools with Space and review selections with Enter\n- **AND** when Enter is pressed on a highlighted selectable tool that is not already selected, automatically add it to the selection before moving to review so the highlighted tool is configured\n- **AND** label already configured tools with \"(already configured)\" while keeping disabled options marked \"coming soon\"\n- **AND** change the prompt copy in extend mode to \"Which AI tools would you like to add or refresh?\"\n- **AND** display inline instructions clarifying that Space toggles tools and Enter selects the highlighted tool before reviewing selections\n\n### Requirement: Safety Checks\nThe command SHALL perform safety checks to prevent overwriting existing structures and ensure proper permissions.\n\n#### Scenario: Detecting existing initialization\n- **WHEN** the `openspec/` directory already exists\n- **THEN** inform the user that OpenSpec is already initialized, skip recreating the base structure, and enter an extend mode\n- **AND** continue to the AI tool selection step so additional tools can be configured\n- **AND** display the existing-initialization error message only when the user declines to add any AI tools\n\n### Requirement: Success Output\n\nThe command SHALL provide clear, actionable next steps upon successful initialization.\n\n#### Scenario: Displaying success message\n\n- **WHEN** initialization completes successfully\n- **THEN** display categorized summary:\n  - \"Created: <tools>\" for newly configured tools\n  - \"Refreshed: <tools>\" for already-configured tools that were updated\n  - Count of skills and commands generated\n- **AND** display getting started section with:\n  - `/opsx:new` - Start a new change\n  - `/opsx:continue` - Create the next artifact\n  - `/opsx:apply` - Implement tasks\n- **AND** display links to documentation and feedback\n\n#### Scenario: Displaying restart instruction\n\n- **WHEN** initialization completes successfully and tools were created or refreshed\n- **THEN** display instruction to restart IDE for slash commands to take effect\n\n### Requirement: Exit Codes\n\nThe command SHALL use consistent exit codes to indicate different failure modes.\n\n#### Scenario: Returning exit codes\n\n- **WHEN** the command completes\n- **THEN** return appropriate exit code:\n  - 0: Success\n  - 1: General error (including when OpenSpec directory already exists)\n  - 2: Insufficient permissions (reserved for future use)\n  - 3: User cancelled operation (reserved for future use)\n\n### Requirement: Additional AI Tool Initialization\n`openspec init` SHALL allow users to add configuration files for new AI coding assistants after the initial setup.\n\n#### Scenario: Configuring an extra tool after initial setup\n- **GIVEN** an `openspec/` directory already exists and at least one AI tool file is present\n- **WHEN** the user runs `openspec init` and selects a different supported AI tool\n- **THEN** generate that tool's configuration files with OpenSpec markers the same way as during first-time initialization\n- **AND** leave existing tool configuration files unchanged except for managed sections that need refreshing\n- **AND** exit with code 0 and display a success summary highlighting the newly added tool files\n\n### Requirement: Success Output Enhancements\n`openspec init` SHALL summarize tool actions when initialization or extend mode completes.\n\n#### Scenario: Showing tool summary\n- **WHEN** the command completes successfully\n- **THEN** display a categorized summary of tools that were created, refreshed, or skipped (including already-configured skips)\n- **AND** personalize the \"Next steps\" header using the names of the selected tools, defaulting to a generic label when none remain\n\n### Requirement: Exit Code Adjustments\n`openspec init` SHALL treat extend mode without new native tool selections as a successful refresh.\n\n#### Scenario: Allowing empty extend runs\n- **WHEN** OpenSpec is already initialized and the user selects no additional natively supported tools\n- **THEN** complete successfully without requiring additional tool setup\n- **AND** preserve the existing OpenSpec structure and config files\n- **AND** exit with code 0\n\n### Requirement: Non-Interactive Mode\n\nThe command SHALL support non-interactive operation through command-line options.\n\n#### Scenario: Select all tools non-interactively\n\n- **WHEN** run with `--tools all`\n- **THEN** automatically select every available AI tool without prompting\n- **AND** proceed with skill and command generation\n\n#### Scenario: Select specific tools non-interactively\n\n- **WHEN** run with `--tools claude,cursor`\n- **THEN** parse the comma-separated tool IDs\n- **AND** generate skills and commands for specified tools only\n\n#### Scenario: Skip tool configuration non-interactively\n\n- **WHEN** run with `--tools none`\n- **THEN** create only the openspec directory structure\n- **AND** skip skill and command generation\n- **AND** create config only when config creation conditions are met\n\n#### Scenario: Invalid tool specification\n\n- **WHEN** run with `--tools invalid-tool`\n- **THEN** fail with exit code 1\n- **AND** display an error listing available values (`all`, `none`, and supported tool IDs)\n\n#### Scenario: Reserved value combined with tool IDs\n\n- **WHEN** run with `--tools all,claude` or `--tools none,cursor`\n- **THEN** fail with exit code 1\n- **AND** display an error explaining reserved values cannot be combined with specific tool IDs\n\n#### Scenario: Missing --tools in non-interactive mode\n\n- **GIVEN** prompts are unavailable in non-interactive execution\n- **WHEN** user runs `openspec init` without `--tools`\n- **THEN** fail with exit code 1\n- **AND** instruct to use `--tools all`, `--tools none`, or explicit tool IDs\n\n### Requirement: Skill Generation\n\nThe command SHALL generate Agent Skills for selected AI tools.\n\n#### Scenario: Generating skills for a tool\n\n- **WHEN** a tool is selected during initialization\n- **THEN** create 9 skill directories under `.<tool>/skills/`:\n  - `openspec-explore/SKILL.md`\n  - `openspec-new-change/SKILL.md`\n  - `openspec-continue-change/SKILL.md`\n  - `openspec-apply-change/SKILL.md`\n  - `openspec-ff-change/SKILL.md`\n  - `openspec-verify-change/SKILL.md`\n  - `openspec-sync-specs/SKILL.md`\n  - `openspec-archive-change/SKILL.md`\n  - `openspec-bulk-archive-change/SKILL.md`\n- **AND** each SKILL.md SHALL contain YAML frontmatter with name and description\n- **AND** each SKILL.md SHALL contain the skill instructions\n\n### Requirement: Slash Command Generation\n\nThe command SHALL generate opsx slash commands for selected AI tools.\n\n#### Scenario: Generating slash commands for a tool\n\n- **WHEN** a tool is selected during initialization\n- **THEN** create 9 slash command files using the tool's command adapter:\n  - `/opsx:explore`\n  - `/opsx:new`\n  - `/opsx:continue`\n  - `/opsx:apply`\n  - `/opsx:ff`\n  - `/opsx:verify`\n  - `/opsx:sync`\n  - `/opsx:archive`\n  - `/opsx:bulk-archive`\n- **AND** use tool-specific path conventions (e.g., `.claude/commands/opsx/` for Claude)\n- **AND** include tool-specific frontmatter format\n\n### Requirement: Config File Generation\n\nThe command SHALL create an OpenSpec config file with schema settings.\n\n#### Scenario: Creating config.yaml\n\n- **WHEN** initialization completes\n- **AND** config.yaml does not exist\n- **THEN** create `openspec/config.yaml` with default schema setting\n- **AND** display config location in output\n\n#### Scenario: Preserving existing config.yaml\n\n- **WHEN** initialization runs in extend mode\n- **AND** `openspec/config.yaml` already exists\n- **THEN** preserve the existing config file\n- **AND** display \"(exists)\" indicator in output\n\n### Requirement: Experimental Command Alias\n\nThe command SHALL maintain backward compatibility with the experimental command.\n\n#### Scenario: Running openspec experimental\n\n- **WHEN** user runs `openspec experimental`\n- **THEN** delegate to `openspec init`\n- **AND** the command SHALL be hidden from help output\n\n## Why\n\nManual creation of OpenSpec structure is error-prone and creates adoption friction. A standardized init command ensures:\n- Consistent structure across all projects\n- Proper AI instruction files are always included\n- Quick onboarding for new projects\n- Clear conventions from the start\n"
  },
  {
    "path": "openspec/specs/cli-list/spec.md",
    "content": "# List Command Specification\n\n## Purpose\n\nThe `openspec list` command SHALL provide developers with a quick overview of all active changes in the project, showing their names and task completion status.\n## Requirements\n### Requirement: Command Execution\nThe command SHALL scan and analyze either active changes or specs based on the selected mode.\n\n#### Scenario: Scanning for changes (default)\n- **WHEN** `openspec list` is executed without flags\n- **THEN** scan the `openspec/changes/` directory for change directories\n- **AND** exclude the `archive/` subdirectory from results\n- **AND** parse each change's `tasks.md` file to count task completion\n\n#### Scenario: Scanning for specs\n- **WHEN** `openspec list --specs` is executed\n- **THEN** scan the `openspec/specs/` directory for capabilities\n- **AND** read each capability's `spec.md`\n- **AND** parse requirements to compute requirement counts\n\n### Requirement: Task Counting\n\nThe command SHALL accurately count task completion status using standard markdown checkbox patterns.\n\n#### Scenario: Counting tasks in tasks.md\n\n- **WHEN** parsing a `tasks.md` file\n- **THEN** count tasks matching these patterns:\n  - Completed: Lines containing `- [x]`\n  - Incomplete: Lines containing `- [ ]`\n- **AND** calculate total tasks as the sum of completed and incomplete\n\n### Requirement: Output Format\nThe command SHALL display items in a clear, readable table format with mode-appropriate progress or counts.\n\n#### Scenario: Displaying change list (default)\n- **WHEN** displaying the list of changes\n- **THEN** show a table with columns:\n  - Change name (directory name)\n  - Task progress (e.g., \"3/5 tasks\" or \"✓ Complete\")\n\n#### Scenario: Displaying spec list\n- **WHEN** displaying the list of specs\n- **THEN** show a table with columns:\n  - Spec id (directory name)\n  - Requirement count (e.g., \"requirements 12\")\n\n### Requirement: Flags\nThe command SHALL accept flags to select the noun being listed.\n\n#### Scenario: Selecting specs\n- **WHEN** `--specs` is provided\n- **THEN** list specs instead of changes\n\n#### Scenario: Selecting changes\n- **WHEN** `--changes` is provided\n- **THEN** list changes explicitly (same as default behavior)\n\n### Requirement: Empty State\nThe command SHALL provide clear feedback when no items are present for the selected mode.\n\n#### Scenario: Handling empty state (changes)\n- **WHEN** no active changes exist (only archive/ or empty changes/)\n- **THEN** display: \"No active changes found.\"\n\n#### Scenario: Handling empty state (specs)\n- **WHEN** no specs directory exists or contains no capabilities\n- **THEN** display: \"No specs found.\"\n\n### Requirement: Error Handling\n\nThe command SHALL gracefully handle missing files and directories with appropriate messages.\n\n#### Scenario: Missing tasks.md file\n\n- **WHEN** a change directory has no `tasks.md` file\n- **THEN** display the change with \"No tasks\" status\n\n#### Scenario: Missing changes directory\n\n- **WHEN** `openspec/changes/` directory doesn't exist\n- **THEN** display error: \"No OpenSpec changes directory found. Run 'openspec init' first.\"\n- **AND** exit with code 1\n\n### Requirement: Sorting\n\nThe command SHALL maintain consistent ordering of changes for predictable output.\n\n#### Scenario: Ordering changes\n\n- **WHEN** displaying multiple changes\n- **THEN** sort them in alphabetical order by change name\n\n## Why\n\nDevelopers need a quick way to:\n- See what changes are in progress\n- Identify which changes are ready to archive\n- Understand the overall project evolution status\n- Get a bird's-eye view without opening multiple files\n\nThis command provides that visibility with minimal effort, following OpenSpec's philosophy of simplicity and clarity."
  },
  {
    "path": "openspec/specs/cli-show/spec.md",
    "content": "# cli-show Specification\n\n## Purpose\nDefine top-level `openspec show` behavior for interactive and direct display of change and spec content.\n\n## Requirements\n### Requirement: Top-level show command\n\nThe CLI SHALL provide a top-level `show` command for displaying changes and specs with intelligent selection.\n\n#### Scenario: Interactive show selection\n\n- **WHEN** executing `openspec show` without arguments\n- **THEN** prompt user to select type (change or spec)\n- **AND** display list of available items for selected type\n- **AND** show the selected item's content\n\n#### Scenario: Non-interactive environments do not prompt\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec show` without arguments\n- **THEN** do not prompt\n- **AND** print a helpful hint with examples for `openspec show <item>` or `openspec change/spec show`\n- **AND** exit with code 1\n\n#### Scenario: Direct item display\n\n- **WHEN** executing `openspec show <item-name>`\n- **THEN** automatically detect if item is a change or spec\n- **AND** display the item's content\n- **AND** use appropriate formatting based on item type\n\n#### Scenario: Type detection and ambiguity handling\n\n- **WHEN** executing `openspec show <item-name>`\n- **THEN** if `<item-name>` uniquely matches a change or a spec, show that item\n- **AND** if it matches both, print an ambiguity error and suggest `--type change|spec` or using `openspec change show`/`openspec spec show`\n- **AND** if it matches neither, print not-found with nearest-match suggestions\n\n#### Scenario: Explicit type override\n\n- **WHEN** executing `openspec show --type change <item>`\n- **THEN** treat `<item>` as a change ID and show it (skipping auto-detection)\n\n- **WHEN** executing `openspec show --type spec <item>`\n- **THEN** treat `<item>` as a spec ID and show it (skipping auto-detection)\n\n### Requirement: Output format options\n\nThe show command SHALL support various output formats consistent with existing commands.\n\n#### Scenario: JSON output\n\n- **WHEN** executing `openspec show <item> --json`\n- **THEN** output the item in JSON format\n- **AND** include parsed metadata and structure\n- **AND** maintain format consistency with existing change/spec show commands\n\n#### Scenario: Flag scoping and delegation\n\n- **WHEN** showing a change or a spec via the top-level command\n- **THEN** accept common flags such as `--json`\n- **AND** pass through type-specific flags to the corresponding implementation\n  - Change-only flags: `--deltas-only` (alias `--requirements-only` deprecated)\n  - Spec-only flags: `--requirements`, `--no-scenarios`, `-r/--requirement`\n- **AND** ignore irrelevant flags for the detected type with a warning\n\n### Requirement: Interactivity controls\n\n- The CLI SHALL respect `--no-interactive` to disable prompts.\n- The CLI SHALL respect `OPEN_SPEC_INTERACTIVE=0` to disable prompts globally.\n- Interactive prompts SHALL only be shown when stdin is a TTY and interactivity is not disabled.\n\n#### Scenario: Change-specific options\n\n- **WHEN** showing a change with `openspec show <change-name> --deltas-only`\n- **THEN** display only the deltas in JSON format\n- **AND** maintain compatibility with existing change show options\n\n#### Scenario: Spec-specific options  \n\n- **WHEN** showing a spec with `openspec show <spec-id> --requirements`\n- **THEN** display only requirements in JSON format\n- **AND** support other spec options (--no-scenarios, -r)\n- **AND** maintain compatibility with existing spec show options\n\n"
  },
  {
    "path": "openspec/specs/cli-spec/spec.md",
    "content": "# cli-spec Specification\n\n## Purpose\nDefine `openspec spec` command behavior for listing, showing, and validating source-of-truth specifications.\n\n## Requirements\n### Requirement: Interactive spec show\n\nThe spec show command SHALL support interactive selection when no spec-id is provided.\n\n#### Scenario: Interactive spec selection for show\n\n- **WHEN** executing `openspec spec show` without arguments\n- **THEN** display an interactive list of available specs\n- **AND** allow the user to select a spec to show\n- **AND** display the selected spec content\n- **AND** maintain all existing show options (--json, --requirements, --no-scenarios, -r)\n\n#### Scenario: Non-interactive fallback keeps current behavior\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec spec show` without a spec-id\n- **THEN** do not prompt interactively\n- **AND** print the existing error message for missing spec-id\n- **AND** set non-zero exit code\n\n### Requirement: Spec Command\n\nThe system SHALL provide a `spec` command with subcommands for displaying, listing, and validating specifications.\n\n#### Scenario: Show spec as JSON\n\n- **WHEN** executing `openspec spec show init --json`\n- **THEN** parse the markdown spec file\n- **AND** extract headings and content hierarchically\n- **AND** output valid JSON to stdout\n\n#### Scenario: List all specs\n\n- **WHEN** executing `openspec spec list`\n- **THEN** scan the openspec/specs directory\n- **AND** return list of all available capabilities\n- **AND** support JSON output with `--json` flag\n\n#### Scenario: Filter spec content\n\n- **WHEN** executing `openspec spec show init --requirements`\n- **THEN** display only requirement names and SHALL statements\n- **AND** exclude scenario content\n\n#### Scenario: Validate spec structure\n\n- **WHEN** executing `openspec spec validate init`\n- **THEN** parse the spec file\n- **AND** validate against Zod schema\n- **AND** report any structural issues\n\n### Requirement: JSON Schema Definition\n\nThe system SHALL define Zod schemas that accurately represent the spec structure for runtime validation.\n\n#### Scenario: Schema validation\n\n- **WHEN** parsing a spec into JSON\n- **THEN** validate the structure using Zod schemas\n- **AND** ensure all required fields are present\n- **AND** provide clear error messages for validation failures\n\n### Requirement: Interactive spec validation\n\nThe spec validate command SHALL support interactive selection when no spec-id is provided.\n\n#### Scenario: Interactive spec selection for validation\n\n- **WHEN** executing `openspec spec validate` without arguments\n- **THEN** display an interactive list of available specs\n- **AND** allow the user to select a spec to validate\n- **AND** validate the selected spec\n- **AND** maintain all existing validation options (--strict, --json)\n\n#### Scenario: Non-interactive fallback keeps current behavior\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec spec validate` without a spec-id\n- **THEN** do not prompt interactively\n- **AND** print the existing error message for missing spec-id\n- **AND** set non-zero exit code\n\n"
  },
  {
    "path": "openspec/specs/cli-update/spec.md",
    "content": "# Update Command Specification\n\n## Purpose\n\nAs a developer using OpenSpec, I want to update the OpenSpec instructions in my project when new versions are released, so that I can benefit from improvements to AI agent instructions.\n## Requirements\n### Requirement: Update Behavior\nThe update command SHALL update OpenSpec instruction files to the latest templates in a team-friendly manner.\n\n#### Scenario: Running update command\n- **WHEN** a user runs `openspec update`\n- **THEN** replace `openspec/AGENTS.md` with the latest template\n- **AND** if a root-level stub (`AGENTS.md`/`CLAUDE.md`) exists, refresh it so it points to `@/openspec/AGENTS.md`\n\n### Requirement: Prerequisites\n\nThe command SHALL require an existing OpenSpec structure before allowing updates.\n\n#### Scenario: Checking prerequisites\n\n- **GIVEN** the command requires an existing `openspec` directory (created by `openspec init`)\n- **WHEN** the `openspec` directory does not exist\n- **THEN** display error: \"No OpenSpec directory found. Run 'openspec init' first.\"\n- **AND** exit with code 1\n\n### Requirement: File Handling\nThe update command SHALL handle file updates in a predictable and safe manner.\n\n#### Scenario: Updating files\n- **WHEN** updating files\n- **THEN** completely replace `openspec/AGENTS.md` with the latest template\n- **AND** if a root-level stub exists, update the managed block content so it keeps directing teammates to `@/openspec/AGENTS.md`\n\n### Requirement: Tool-Agnostic Updates\nThe update command SHALL refresh OpenSpec-managed files in a predictable manner while respecting each team's chosen tooling.\n\n#### Scenario: Updating files\n- **WHEN** updating files\n- **THEN** completely replace `openspec/AGENTS.md` with the latest template\n- **AND** create or refresh the root-level `AGENTS.md` stub using the managed marker block, even if the file was previously absent\n- **AND** update only the OpenSpec-managed sections inside existing AI tool files, leaving user-authored content untouched\n- **AND** avoid creating new native-tool configuration files (slash commands, CLAUDE.md, etc.) unless they already exist\n\n### Requirement: Core Files Always Updated\nThe update command SHALL always update the core OpenSpec files and display an ASCII-safe success message.\n\n#### Scenario: Successful update\n- **WHEN** the update completes successfully\n- **THEN** replace `openspec/AGENTS.md` with the latest template\n- **AND** if a root-level stub exists, refresh it so it still directs contributors to `@/openspec/AGENTS.md`\n\n### Requirement: Slash Command Updates\n\nThe 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.\n\n#### Scenario: Updating slash commands for Antigravity\n- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter\n- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs\n\n#### Scenario: Updating slash commands for Claude Code\n- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for CodeBuddy Code\n- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`\n- **THEN** refresh each file using the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`)\n- **AND** preserve any user customizations outside the OpenSpec managed markers\n\n#### Scenario: Updating slash commands for Cline\n- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** include Cline-specific Markdown heading frontmatter\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Continue\n- **WHEN** `.continue/prompts/` contains `openspec-proposal.prompt`, `openspec-apply.prompt`, and `openspec-archive.prompt`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Crush\n- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** include Crush-specific frontmatter with OpenSpec category and tags\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Cursor\n- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Factory Droid\n- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields\n- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid\n- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched\n- **AND** skip creating missing files during update\n\n#### Scenario: Updating slash commands for OpenCode\n- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments\n\n#### Scenario: Updating slash commands for Windsurf\n- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Kilo Code\n- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates wrapped in OpenSpec markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n- **AND** skip creating missing files (the update command only refreshes what already exists)\n\n#### Scenario: Updating slash commands for Codex\n- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **WHEN** a user runs `openspec update`\n- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance)\n- **AND** preserve any unmanaged content outside the OpenSpec marker block\n- **AND** skip creation when a Codex prompt file is missing\n\n#### Scenario: Updating slash commands for GitHub Copilot\n- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md`\n- **THEN** refresh each file using shared templates while preserving the YAML frontmatter\n- **AND** update only the OpenSpec-managed block between markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Updating slash commands for Gemini CLI\n- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml`\n- **THEN** refresh the body of each file using the shared proposal/apply/archive templates\n- **AND** replace only the content between `<!-- OPENSPEC:START -->` and `<!-- OPENSPEC:END -->` markers inside the `prompt = \"\"\"` block so the TOML framing (`description`, `prompt`) stays intact\n- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed\n\n#### Scenario: Updating slash commands for iFlow CLI\n- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`\n- **THEN** refresh each file using shared templates\n- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields\n- **AND** update only the OpenSpec-managed block between markers\n- **AND** ensure templates include instructions for the relevant workflow stage\n\n#### Scenario: Missing slash command file\n- **WHEN** a tool lacks a slash command file\n- **THEN** do not create a new file during update\n\n### Requirement: Archive Command Argument Support\nThe archive slash command template SHALL support optional change ID arguments for tools that support `$ARGUMENTS` placeholder.\n\n#### Scenario: Archive command with change ID argument\n- **WHEN** a user invokes `/openspec:archive <change-id>` with a change ID\n- **THEN** the template SHALL instruct the AI to validate the provided change ID against `openspec list`\n- **AND** use the provided change ID for archiving if valid\n- **AND** fail fast if the provided change ID doesn't match an archivable change\n\n#### Scenario: Archive command without argument (backward compatibility)\n- **WHEN** a user invokes `/openspec:archive` without providing a change ID\n- **THEN** the template SHALL instruct the AI to identify the change ID from context or by running `openspec list`\n- **AND** proceed with the existing behavior (maintaining backward compatibility)\n\n#### Scenario: OpenCode archive template generation\n- **WHEN** generating the OpenCode archive slash command file\n- **THEN** include the `$ARGUMENTS` placeholder in the frontmatter\n- **AND** wrap it in a clear structure like `<ChangeId>\\n  $ARGUMENTS\\n</ChangeId>` to indicate the expected argument\n- **AND** include validation steps in the template body to check if the change ID is valid\n\n## Edge Cases\n\n### Requirement: Error Handling\n\nThe command SHALL handle edge cases gracefully.\n\n#### Scenario: File permission errors\n\n- **WHEN** file write fails\n- **THEN** let the error bubble up naturally with file path\n\n#### Scenario: Missing AI tool files\n\n- **WHEN** an AI tool configuration file doesn't exist\n- **THEN** skip updating that file\n- **AND** do not create it\n\n#### Scenario: Custom directory names\n\n- **WHEN** considering custom directory names\n- **THEN** not supported in this change\n- **AND** the default directory name `openspec` SHALL be used\n\n## Success Criteria\n\nUsers SHALL be able to:\n- Update OpenSpec instructions with a single command\n- Get the latest AI agent instructions\n- See clear confirmation of the update\n\nThe update process SHALL be:\n- Simple and fast (no version checking)\n- Predictable (same result every time)\n- Self-contained (no network required)\n"
  },
  {
    "path": "openspec/specs/cli-validate/spec.md",
    "content": "# cli-validate Specification\n\n## Purpose\nDefine `openspec validate` behavior for validating changes and specs with actionable remediation guidance and structured output.\n\n## Requirements\n### Requirement: Validation SHALL provide actionable remediation steps\nValidation output SHALL include specific guidance to fix each error, including expected structure, example headers, and suggested commands to verify fixes.\n\n#### Scenario: No deltas found in change\n- **WHEN** validating a change with zero parsed deltas\n- **THEN** show error \"No deltas found\" with guidance:\n  - Explain that change specs must include `## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, or `## RENAMED Requirements`\n  - Remind authors that files must live under `openspec/changes/{id}/specs/<capability>/spec.md`\n  - Include an explicit note: \"Spec delta files cannot start with titles before the operation headers\"\n  - Suggest running `openspec change show {id} --json --deltas-only` for debugging\n\n#### Scenario: Missing required sections\n- **WHEN** a required section is missing\n- **THEN** include expected header names and a minimal skeleton:\n  - For Spec: `## Purpose`, `## Requirements`\n  - For Change: `## Why`, `## What Changes`\n  - Provide an example snippet of the missing section with placeholder prose ready to copy\n  - Mention the quick-reference section in `openspec/AGENTS.md` as the authoritative template\n\n#### Scenario: Missing requirement descriptive text\n- **WHEN** a requirement header lacks descriptive text before scenarios\n- **THEN** emit an error explaining that `### Requirement:` lines must be followed by narrative text before any `#### Scenario:` headers\n  - Show compliant example: \"### Requirement: Foo\" followed by \"The system SHALL ...\"\n  - Suggest adding 1-2 sentences describing the normative behavior prior to listing scenarios\n  - Reference the pre-validation checklist in `openspec/AGENTS.md`\n\n### Requirement: Validator SHALL detect likely misformatted scenarios and warn with a fix\nThe validator SHALL recognize bulleted lines that look like scenarios (e.g., lines beginning with WHEN/THEN/AND) and emit a targeted warning with a conversion example to `#### Scenario:`.\n\n#### Scenario: Bulleted WHEN/THEN under a Requirement\n- **WHEN** bullets that start with WHEN/THEN/AND are found under a requirement without any `#### Scenario:` headers\n- **THEN** emit warning: \"Scenarios must use '#### Scenario:' headers\", and show a conversion template:\n```\n#### Scenario: Short name\n- **WHEN** ...\n- **THEN** ...\n- **AND** ...\n```\n\n### Requirement: All issues SHALL include file paths and structured locations\nError, warning, and info messages SHALL include:\n- Source file path (`openspec/changes/{id}/proposal.md`, `.../specs/{cap}/spec.md`)\n- Structured path (e.g., `deltas[0].requirements[0].scenarios`)\n\n#### Scenario: Zod validation error\n- **WHEN** a schema validation fails\n- **THEN** the message SHALL include `file`, `path`, and a remediation hint if applicable\n\n### Requirement: Invalid results SHALL include a Next steps footer in human-readable output\nThe CLI SHALL append a Next steps footer when the item is invalid and not using `--json`, including:\n- Summary line with counts\n- Top-3 guidance bullets (contextual to the most frequent or blocking errors)\n- A suggestion to re-run with `--json` and/or the debug command\n\n#### Scenario: Change invalid summary\n- **WHEN** a change validation fails\n- **THEN** print \"Next steps\" with 2-3 targeted bullets and suggest `openspec change show <id> --json --deltas-only`\n\n### Requirement: Top-level validate command\n\nThe CLI SHALL provide a top-level `validate` command for validating changes and specs with flexible selection options.\n\n#### Scenario: Interactive validation selection\n\n- **WHEN** executing `openspec validate` without arguments\n- **THEN** prompt user to select what to validate (all, changes, specs, or specific item)\n- **AND** perform validation based on selection\n- **AND** display results with appropriate formatting\n\n#### Scenario: Non-interactive environments do not prompt\n\n- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`\n- **WHEN** executing `openspec validate` without arguments\n- **THEN** do not prompt interactively\n- **AND** print a helpful hint listing available commands/flags and exit with code 1\n\n#### Scenario: Direct item validation\n\n- **WHEN** executing `openspec validate <item-name>`\n- **THEN** automatically detect if item is a change or spec\n- **AND** validate the specified item\n- **AND** display validation results\n\n### Requirement: Bulk and filtered validation\n\nThe validate command SHALL support flags for bulk validation (--all) and filtered validation by type (--changes, --specs).\n\n#### Scenario: Validate everything\n\n- **WHEN** executing `openspec validate --all`\n- **THEN** validate all changes in openspec/changes/ (excluding archive)\n- **AND** validate all specs in openspec/specs/\n- **AND** display a summary showing passed/failed items\n- **AND** exit with code 1 if any validation fails\n\n#### Scenario: Scope of bulk validation\n\n- **WHEN** validating with `--all` or `--changes`\n- **THEN** include all change proposals under `openspec/changes/`\n- **AND** exclude the `openspec/changes/archive/` directory\n\n- **WHEN** validating with `--specs`\n- **THEN** include all specs that have a `spec.md` under `openspec/specs/<id>/spec.md`\n\n#### Scenario: Validate all changes\n\n- **WHEN** executing `openspec validate --changes`\n- **THEN** validate all changes in openspec/changes/ (excluding archive)\n- **AND** display results for each change\n- **AND** show summary statistics\n\n#### Scenario: Validate all specs\n\n- **WHEN** executing `openspec validate --specs`\n- **THEN** validate all specs in openspec/specs/\n- **AND** display results for each spec\n- **AND** show summary statistics\n\n### Requirement: Validation options and progress indication\n\nThe validate command SHALL support standard validation options (--strict, --json) and display progress during bulk operations.\n\n#### Scenario: Strict validation\n\n- **WHEN** executing `openspec validate --all --strict`\n- **THEN** apply strict validation to all items\n- **AND** treat warnings as errors\n- **AND** fail if any item has warnings or errors\n\n#### Scenario: JSON output\n\n- **WHEN** executing `openspec validate --all --json`\n- **THEN** output validation results as JSON\n- **AND** include detailed issues for each item\n- **AND** include summary statistics\n\n#### Scenario: JSON output schema for bulk validation\n\n- **WHEN** executing `openspec validate --all --json` (or `--changes` / `--specs`)\n- **THEN** output a JSON object with the following shape:\n  - `items`: Array of objects with fields `{ id: string, type: \"change\"|\"spec\", valid: boolean, issues: Issue[], durationMs: number }`\n  - `summary`: Object `{ totals: { items: number, passed: number, failed: number }, byType: { change?: { items: number, passed: number, failed: number }, spec?: { items: number, passed: number, failed: number } } }`\n  - `version`: String identifier for the schema (e.g., `\"1.0\"`)\n- **AND** exit with code 1 if any `items[].valid === false`\n\nWhere `Issue` follows the existing per-item validation report shape `{ level: \"ERROR\"|\"WARNING\"|\"INFO\", path: string, message: string }`.\n\n#### Scenario: Show validation progress\n\n- **WHEN** validating multiple items (--all, --changes, or --specs)\n- **THEN** show progress indicator or status updates\n- **AND** indicate which item is currently being validated\n- **AND** display running count of passed/failed items\n\n#### Scenario: Concurrency limits for performance\n\n- **WHEN** validating multiple items\n- **THEN** run validations with a bounded concurrency (e.g., 4–8 in parallel)\n- **AND** ensure progress indicators remain responsive\n\n### Requirement: Item type detection and ambiguity handling\n\nThe validate command SHALL handle ambiguous names and explicit type overrides to ensure clear, deterministic behavior.\n\n#### Scenario: Direct item validation with automatic type detection\n\n- **WHEN** executing `openspec validate <item-name>`\n- **THEN** if `<item-name>` uniquely matches a change or a spec, validate that item\n\n#### Scenario: Ambiguity between change and spec names\n\n- **GIVEN** `<item-name>` exists both as a change and as a spec\n- **WHEN** executing `openspec validate <item-name>`\n- **THEN** print an ambiguity error explaining both matches\n- **AND** suggest passing `--type change` or `--type spec`, or using `openspec change validate` / `openspec spec validate`\n- **AND** exit with code 1 without performing validation\n\n#### Scenario: Unknown item name\n\n- **WHEN** the `<item-name>` matches neither a change nor a spec\n- **THEN** print a not-found error\n- **AND** show nearest-match suggestions when available\n- **AND** exit with code 1\n\n#### Scenario: Explicit type override\n\n- **WHEN** executing `openspec validate --type change <item>`\n- **THEN** treat `<item>` as a change ID and validate it (skipping auto-detection)\n\n- **WHEN** executing `openspec validate --type spec <item>`\n- **THEN** treat `<item>` as a spec ID and validate it (skipping auto-detection)\n\n### Requirement: Interactivity controls\n\n- The CLI SHALL respect `--no-interactive` to disable prompts.\n- The CLI SHALL respect `OPEN_SPEC_INTERACTIVE=0` to disable prompts globally.\n- Interactive prompts SHALL only be shown when stdin is a TTY and interactivity is not disabled.\n\n#### Scenario: Disabling prompts via flags or environment\n\n- **WHEN** `openspec validate` is executed with `--no-interactive` or with environment `OPEN_SPEC_INTERACTIVE=0`\n- **THEN** the CLI SHALL not display interactive prompts\n- **AND** SHALL print non-interactive hints or chosen outputs as appropriate\n\n### Requirement: Parser SHALL handle cross-platform line endings\nThe markdown parser SHALL correctly identify sections regardless of line ending format (LF, CRLF, CR).\n\n#### Scenario: Required sections parsed with CRLF line endings\n- **GIVEN** a change proposal markdown saved with CRLF line endings\n- **AND** the document contains `## Why` and `## What Changes`\n- **WHEN** running `openspec validate <change-id>`\n- **THEN** validation SHALL recognize the sections and NOT raise parsing errors\n\n"
  },
  {
    "path": "openspec/specs/cli-view/spec.md",
    "content": "# cli-view Specification\n\n## Purpose\n\nThe `openspec view` command provides a comprehensive dashboard view of the OpenSpec project state, displaying specifications, changes, and progress metrics in a unified, visually appealing format to help developers quickly understand project status.\n## Requirements\n### Requirement: Dashboard Display\n\nThe system SHALL provide a `view` command that displays a dashboard overview of specs and changes.\n\n#### Scenario: Basic dashboard display\n\n- **WHEN** user runs `openspec view`\n- **THEN** system displays a formatted dashboard with sections for summary, active changes, completed changes, and specifications\n\n#### Scenario: No OpenSpec directory\n\n- **WHEN** user runs `openspec view` in a directory without OpenSpec\n- **THEN** system displays error message \"✗ No openspec directory found\"\n\n### Requirement: Summary Section\n\nThe dashboard SHALL display a summary section with key project metrics, including draft change count.\n\n#### Scenario: Complete summary display\n\n- **WHEN** dashboard is rendered with specs and changes\n- **THEN** system shows total number of specifications and requirements\n- **AND** shows number of draft changes\n- **AND** shows number of active changes in progress\n- **AND** shows number of completed changes\n- **AND** shows overall task progress percentage\n\n#### Scenario: Empty project summary\n\n- **WHEN** no specs or changes exist\n- **THEN** summary shows zero counts for all metrics\n\n### Requirement: Active Changes Display\nThe dashboard SHALL show active changes with visual progress indicators.\n\n#### Scenario: Active changes ordered by completion percentage\n- **WHEN** multiple active changes are displayed with progress information\n- **THEN** list them sorted by completion percentage ascending so 0% items appear first\n- **AND** treat missing progress values as 0% for ordering\n- **AND** break ties by change identifier in ascending alphabetical order to keep output deterministic\n\n### Requirement: Completed Changes Display\n\nThe dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.\n\n> **Fixes bug**: Previously, changes with `total === 0` were incorrectly shown as completed.\n\n#### Scenario: Completed changes listing\n\n- **WHEN** there are changes with `tasks.total > 0` AND `tasks.completed === tasks.total`\n- **THEN** system shows them with checkmark indicators in a dedicated section\n\n#### Scenario: Mixed completion states\n\n- **WHEN** some changes are complete and others active\n- **THEN** system separates them into appropriate sections\n\n#### Scenario: Empty changes not completed\n\n- **WHEN** a change has no tasks.md or zero tasks defined\n- **THEN** system does NOT show it in \"Completed Changes\" section\n- **AND** shows it in \"Draft Changes\" section instead\n\n### Requirement: Specifications Display\n\nThe dashboard SHALL display specifications sorted by requirement count.\n\n#### Scenario: Specs listing with counts\n\n- **WHEN** specifications exist in the project\n- **THEN** system shows specs sorted by requirement count (descending) with count labels\n\n#### Scenario: Specs with parsing errors\n\n- **WHEN** a spec file cannot be parsed\n- **THEN** system includes it with 0 requirement count\n\n### Requirement: Visual Formatting\n\nThe dashboard SHALL use consistent visual formatting with colors and symbols.\n\n#### Scenario: Color coding\n\n- **WHEN** dashboard elements are displayed\n- **THEN** system uses cyan for specification items\n- **AND** yellow for active changes\n- **AND** green for completed items\n- **AND** dim gray for supplementary text\n\n#### Scenario: Progress bar rendering\n\n- **WHEN** displaying progress bars\n- **THEN** system uses filled blocks (█) for completed portions and light blocks (░) for remaining\n\n### Requirement: Error Handling\n\nThe view command SHALL handle errors gracefully.\n\n#### Scenario: File system errors\n\n- **WHEN** file system operations fail\n- **THEN** system continues with available data and omits inaccessible items\n\n#### Scenario: Invalid data structures\n\n- **WHEN** specs or changes have invalid format\n- **THEN** system skips invalid items and continues rendering\n\n### Requirement: Draft Changes Display\n\nThe dashboard SHALL display changes without tasks in a separate \"Draft\" section.\n\n#### Scenario: Draft changes listing\n\n- **WHEN** there are changes with no tasks.md or zero tasks defined\n- **THEN** system shows them in a \"Draft Changes\" section\n- **AND** uses a distinct indicator (e.g., `○`) to show draft status\n\n#### Scenario: Draft section ordering\n\n- **WHEN** multiple draft changes exist\n- **THEN** system sorts them alphabetically by name\n\n"
  },
  {
    "path": "openspec/specs/command-generation/spec.md",
    "content": "# command-generation Specification\n\n## Purpose\nDefine tool-agnostic command content and adapter contracts for generating tool-specific OpenSpec command files.\n\n## Requirements\n### Requirement: CommandContent interface\n\nThe system SHALL define a tool-agnostic `CommandContent` interface for command data.\n\n#### Scenario: CommandContent structure\n\n- **WHEN** defining a command to generate\n- **THEN** `CommandContent` SHALL include:\n  - `id`: string identifier (e.g., 'explore', 'apply')\n  - `name`: human-readable name (e.g., 'OpenSpec Explore')\n  - `description`: brief description of command purpose\n  - `category`: grouping category (e.g., 'OpenSpec')\n  - `tags`: array of tag strings\n  - `body`: the command instruction content\n\n### Requirement: ToolCommandAdapter interface\n\nThe system SHALL define a `ToolCommandAdapter` interface for per-tool formatting.\n\n#### Scenario: Adapter interface structure\n\n- **WHEN** implementing a tool adapter\n- **THEN** `ToolCommandAdapter` SHALL require:\n  - `toolId`: string identifier matching `AIToolOption.value`\n  - `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex)\n  - `formatFile(content: CommandContent)`: returns complete file content with frontmatter\n\n#### Scenario: Claude adapter formatting\n\n- **WHEN** formatting a command for Claude Code\n- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields\n- **AND** file path SHALL follow pattern `.claude/commands/opsx/<id>.md`\n\n#### Scenario: Cursor adapter formatting\n\n- **WHEN** formatting a command for Cursor\n- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-<id>`, `id`, `category`, `description` fields\n- **AND** file path SHALL follow pattern `.cursor/commands/opsx-<id>.md`\n\n#### Scenario: Windsurf adapter formatting\n\n- **WHEN** formatting a command for Windsurf\n- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields\n- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-<id>.md`\n\n### Requirement: Command generator function\n\nThe system SHALL provide a `generateCommand` function that combines content with adapter.\n\n#### Scenario: Generate command file\n\n- **WHEN** calling `generateCommand(content, adapter)`\n- **THEN** it SHALL return an object with:\n  - `path`: the file path from `adapter.getFilePath(content.id)`\n  - `fileContent`: the formatted content from `adapter.formatFile(content)`\n\n#### Scenario: Generate multiple commands\n\n- **WHEN** generating all opsx commands for a tool\n- **THEN** the system SHALL iterate over command contents and generate each using the tool's adapter\n\n### Requirement: CommandAdapterRegistry\n\nThe system SHALL provide a registry for looking up tool adapters.\n\n#### Scenario: Get adapter by tool ID\n\n- **WHEN** calling `CommandAdapterRegistry.get('cursor')`\n- **THEN** it SHALL return the Cursor adapter or undefined if not registered\n\n#### Scenario: Get all adapters\n\n- **WHEN** calling `CommandAdapterRegistry.getAll()`\n- **THEN** it SHALL return array of all registered adapters\n\n#### Scenario: Adapter not found\n\n- **WHEN** looking up an adapter for unregistered tool\n- **THEN** `CommandAdapterRegistry.get()` SHALL return undefined\n- **AND** caller SHALL handle missing adapter appropriately\n\n### Requirement: Shared command body content\n\nThe body content of commands SHALL be shared across all tools.\n\n#### Scenario: Same instructions across tools\n\n- **WHEN** generating the 'explore' command for Claude and Cursor\n- **THEN** both SHALL use the same `body` content\n- **AND** only the frontmatter and file path SHALL differ\n\n"
  },
  {
    "path": "openspec/specs/config-loading/spec.md",
    "content": "# config-loading Specification\n\n## Purpose\nDefine how `openspec/config.yaml` is discovered, parsed, validated, and exposed to callers with safe fallbacks.\n\n## Requirements\n### Requirement: Load project config from openspec/config.yaml\n\nThe system SHALL read and parse the project configuration file located at `openspec/config.yaml` relative to the project root.\n\n#### Scenario: Valid config file exists\n- **WHEN** `openspec/config.yaml` exists with valid YAML content\n- **THEN** system parses the file and returns a ProjectConfig object\n\n#### Scenario: Config file does not exist\n- **WHEN** `openspec/config.yaml` does not exist\n- **THEN** system returns null without error\n\n#### Scenario: Config file has invalid YAML syntax\n- **WHEN** `openspec/config.yaml` contains malformed YAML\n- **THEN** system logs a warning message and returns null\n\n#### Scenario: Config file has valid YAML but invalid schema\n- **WHEN** `openspec/config.yaml` contains valid YAML that fails Zod schema validation\n- **THEN** system logs a warning message with validation details and returns null\n\n### Requirement: Support .yml file extension alias\n\nThe system SHALL accept both `.yaml` and `.yml` file extensions for the config file.\n\n#### Scenario: Config file uses .yml extension\n- **WHEN** `openspec/config.yml` exists and `openspec/config.yaml` does not exist\n- **THEN** system reads from `openspec/config.yml`\n\n#### Scenario: Both .yaml and .yml exist\n- **WHEN** both `openspec/config.yaml` and `openspec/config.yml` exist\n- **THEN** system prefers `openspec/config.yaml`\n\n### Requirement: Use resilient field-by-field parsing\n\nThe system SHALL parse each config field independently, collecting valid fields and warning about invalid ones without rejecting the entire config.\n\n#### Scenario: Schema field is valid\n- **WHEN** config contains `schema: \"spec-driven\"`\n- **THEN** schema field is included in returned config\n\n#### Scenario: Schema field is missing\n- **WHEN** config lacks the `schema` field\n- **THEN** no warning is logged (field is optional at parse level)\n\n#### Scenario: Schema field is empty string\n- **WHEN** config contains `schema: \"\"`\n- **THEN** warning is logged and schema field is not included in returned config\n\n#### Scenario: Schema field is invalid type\n- **WHEN** config contains `schema: 123` (number instead of string)\n- **THEN** warning is logged and schema field is not included in returned config\n\n#### Scenario: Context field is valid\n- **WHEN** config contains `context: \"Tech stack: TypeScript\"`\n- **THEN** context field is included in returned config\n\n#### Scenario: Context field is invalid type\n- **WHEN** config contains `context: 123` (number instead of string)\n- **THEN** warning is logged and context field is not included in returned config\n\n#### Scenario: Rules field has valid structure\n- **WHEN** config contains `rules: { proposal: [\"Rule 1\"], specs: [\"Rule 2\"] }`\n- **THEN** rules field is included in returned config with valid rules\n\n#### Scenario: Rules field has non-array value for artifact\n- **WHEN** config contains `rules: { proposal: \"not an array\", specs: [\"Valid\"] }`\n- **THEN** warning is logged for proposal, but specs rules are still included in returned config\n\n#### Scenario: Rules array contains non-string elements\n- **WHEN** config contains `rules: { proposal: [\"Valid rule\", 123, \"\"] }`\n- **THEN** only \"Valid rule\" is included, warning logged about invalid elements\n\n#### Scenario: Mix of valid and invalid fields\n- **WHEN** config contains valid schema, invalid context type, valid rules\n- **THEN** config is returned with schema and rules fields, warning logged about context\n\n### Requirement: Enforce context size limit\n\nThe system SHALL reject context fields exceeding 50KB and log a warning.\n\n#### Scenario: Context within size limit\n- **WHEN** config contains context of 1KB\n- **THEN** context is included in returned config\n\n#### Scenario: Context at size limit\n- **WHEN** config contains context of exactly 50KB\n- **THEN** context is included in returned config\n\n#### Scenario: Context exceeds size limit\n- **WHEN** config contains context of 51KB\n- **THEN** warning is logged with size and limit, context field is not included in returned config\n\n### Requirement: Defer artifact ID validation to instruction loading\n\nThe system SHALL NOT validate artifact IDs in rules during config load time. Validation happens during instruction loading when schema is known.\n\n#### Scenario: Config with rules is loaded\n- **WHEN** config contains `rules: { unknownartifact: [...] }`\n- **THEN** config is loaded successfully without validation errors\n\n#### Scenario: Validation happens at instruction load time\n- **WHEN** instructions are loaded for any artifact and config has unknown artifact IDs in rules\n- **THEN** warnings are emitted about unknown artifact IDs (see rules-injection spec for details)\n\n### Requirement: Gracefully handle config errors without halting\n\nThe system SHALL continue operation with default values when config loading or parsing fails.\n\n#### Scenario: Config parse failure during command execution\n- **WHEN** config file has syntax errors and user runs `openspec new change`\n- **THEN** command executes using default schema \"spec-driven\"\n\n#### Scenario: Warning is visible to user\n- **WHEN** config loading fails\n- **THEN** system outputs warning message to stderr with details about the failure\n\n"
  },
  {
    "path": "openspec/specs/context-injection/spec.md",
    "content": "# context-injection Specification\n\n## Purpose\nDefine how project context from `openspec/config.yaml` is injected into workflow instructions while preserving source text and formatting.\n\n## Requirements\n### Requirement: Inject context into all artifact instructions\n\nThe system SHALL inject the context field from project config into instructions for all artifacts, wrapped in XML-style `<context>` tags.\n\n#### Scenario: Config has context field\n- **WHEN** config contains `context: \"Tech stack: TypeScript, React\"`\n- **THEN** instruction output includes `<context>\\nTech stack: TypeScript, React\\n</context>`\n\n#### Scenario: Config has no context field\n- **WHEN** config omits the context field or context is undefined\n- **THEN** instruction output does not include `<context>` tags\n\n#### Scenario: Context is multi-line string\n- **WHEN** config contains context with multiple lines\n- **THEN** instruction output preserves line breaks within `<context>` tags\n\n#### Scenario: Context applied to all artifacts\n- **WHEN** instructions are loaded for any artifact (proposal, specs, design, tasks)\n- **THEN** context section appears in all instruction outputs\n\n### Requirement: Format context with XML-style tags\n\nThe system SHALL wrap context content in `<context>` opening and `</context>` closing tags with content on separate lines.\n\n#### Scenario: Context tag structure\n- **WHEN** context is injected into instructions\n- **THEN** format is exactly `<context>\\n{content}\\n</context>\\n\\n`\n\n#### Scenario: Context appears before template\n- **WHEN** instructions are generated with context\n- **THEN** `<context>` section appears before the `<template>` section\n\n### Requirement: Preserve context content exactly as provided\n\nThe system SHALL inject context content without modification, escaping, or interpretation.\n\n#### Scenario: Context contains special characters\n- **WHEN** context includes characters like `<`, `>`, `&`, quotes\n- **THEN** characters are preserved exactly as written in the config\n\n#### Scenario: Context contains URLs\n- **WHEN** context includes URLs like \"docs at https://example.com\"\n- **THEN** URLs are preserved exactly in the injected content\n\n#### Scenario: Context contains Markdown\n- **WHEN** context includes Markdown formatting like `**bold**` or `[links](url)`\n- **THEN** Markdown is preserved without rendering or escaping\n"
  },
  {
    "path": "openspec/specs/docs-agent-instructions/spec.md",
    "content": "# docs-agent-instructions Specification\n\n## Purpose\nDefine authoring standards for generated agent instruction docs so templates, examples, and validation checklists are clear and copy-ready.\n\n## Requirements\n### Requirement: Quick Reference Placement\nThe AI instructions SHALL begin with a quick-reference section that surfaces required file structures, templates, and formatting rules before any narrative guidance.\n\n#### Scenario: Loading templates at the top\n- **WHEN** `openspec/AGENTS.md` is regenerated or updated\n- **THEN** the first substantive section after the title SHALL provide copy-ready headings for `proposal.md`, `tasks.md`, spec deltas, and scenario formatting\n- **AND** link each template to the corresponding workflow step for deeper reading\n\n### Requirement: Embedded Templates and Examples\n`openspec/AGENTS.md` SHALL include complete copy/paste templates and inline examples exactly where agents make corresponding edits.\n\n#### Scenario: Providing file templates\n- **WHEN** authors reach the workflow guidance for drafting proposals and deltas\n- **THEN** provide fenced Markdown templates that match the required structure (`## Why`, `## ADDED Requirements`, `#### Scenario:` etc.)\n- **AND** accompany each template with a brief example showing correct header usage and scenario bullets\n\n### Requirement: Pre-validation Checklist\n`openspec/AGENTS.md` SHALL offer a concise pre-validation checklist that highlights common formatting mistakes before running `openspec validate`.\n\n#### Scenario: Highlighting common validation failures\n- **WHEN** a reader reaches the validation guidance\n- **THEN** present a checklist reminding them to verify requirement headers, scenario formatting, and delta sections\n- **AND** include reminders about at least `#### Scenario:` usage and descriptive requirement text before scenarios\n\n### Requirement: Progressive Disclosure of Workflow Guidance\nThe documentation SHALL separate beginner essentials from advanced topics so newcomers can focus on core steps without losing access to advanced workflows.\n\n#### Scenario: Organizing beginner and advanced sections\n- **WHEN** reorganizing `openspec/AGENTS.md`\n- **THEN** keep an introductory section limited to the minimum steps (scaffold, draft, validate, request review)\n- **AND** move advanced topics (multi-capability changes, archiving details, tooling deep dives) into clearly labeled later sections\n- **AND** provide anchor links from the quick-reference to those advanced sections\n\n### Requirement: Behavior-First Spec Authoring Guidance\nAgent instruction docs SHALL explicitly teach that specs capture observable behavior contracts, while implementation details belong in design/tasks.\n\n#### Scenario: Distinguishing spec vs implementation content\n- **WHEN** `openspec/AGENTS.md` explains how to write `spec.md`\n- **THEN** it SHALL instruct agents to include externally verifiable behavior, inputs/outputs, errors, and constraints\n- **AND** it SHALL instruct agents to avoid internal library/framework choices and class/function-level implementation details in specs\n\n#### Scenario: Routing detail to the right artifact\n- **WHEN** implementation detail is necessary\n- **THEN** instructions SHALL direct the agent to place it in `design.md` or `tasks.md`, not in the behavioral requirements section of `spec.md`\n\n### Requirement: Lightweight-by-Default Guidance\nAgent instruction docs SHALL promote minimal ceremony and proportional rigor for spec authoring.\n\n#### Scenario: Applying progressive rigor\n- **WHEN** an agent drafts specs for routine changes\n- **THEN** instructions SHALL favor concise, lightweight requirements and scenarios\n- **AND** reserve deeper, fuller specification style for higher-risk changes (such as API breaks, migrations, cross-team, or security/privacy sensitive work)\n\n#### Scenario: Time-to-clarity optimization\n- **WHEN** guidance discusses drafting workflow\n- **THEN** it SHALL emphasize producing the smallest spec that is still testable and reviewable\n"
  },
  {
    "path": "openspec/specs/global-config/spec.md",
    "content": "# global-config Specification\n\n## Purpose\n\nThis spec defines how OpenSpec resolves, reads, and writes user-level global configuration. It governs the `src/core/global-config.ts` module, which provides the foundation for storing user preferences, feature flags, and settings that persist across projects. The spec ensures cross-platform compatibility by following XDG Base Directory Specification with platform-specific fallbacks, and guarantees forward/backward compatibility through schema evolution rules.\n## Requirements\n### Requirement: Global configuration storage\nThe system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.\n\n#### Scenario: Initial config creation\n- **WHEN** no global config file exists\n- **AND** the first telemetry event is about to be sent\n- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration\n\n#### Scenario: Telemetry config structure\n- **WHEN** reading or writing telemetry configuration\n- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields\n\n#### Scenario: Config file format\n- **WHEN** storing configuration\n- **THEN** the system writes valid JSON that can be read and modified by users\n\n#### Scenario: Existing config preservation\n- **WHEN** adding telemetry fields to an existing config file\n- **THEN** the system preserves all existing configuration fields\n\n### Requirement: Global Config Directory Path\n\nThe system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.\n\n#### Scenario: Unix/macOS with XDG_CONFIG_HOME set\n- **WHEN** `$XDG_CONFIG_HOME` environment variable is set to `/custom/config`\n- **THEN** `getGlobalConfigDir()` returns `/custom/config/openspec`\n\n#### Scenario: Unix/macOS without XDG_CONFIG_HOME\n- **WHEN** `$XDG_CONFIG_HOME` environment variable is not set\n- **AND** the platform is Unix or macOS\n- **THEN** `getGlobalConfigDir()` returns `~/.config/openspec` (expanded to absolute path)\n\n#### Scenario: Windows platform\n- **WHEN** the platform is Windows\n- **AND** `%APPDATA%` is set to `C:\\Users\\User\\AppData\\Roaming`\n- **THEN** `getGlobalConfigDir()` returns `C:\\Users\\User\\AppData\\Roaming\\openspec`\n\n### Requirement: Global Config Loading\n\nThe system SHALL load global configuration from the config directory with sensible defaults when the config file does not exist or cannot be parsed.\n\n#### Scenario: Config file exists and is valid\n- **WHEN** `config.json` exists in the global config directory\n- **AND** the file contains valid JSON matching the config schema\n- **THEN** `getGlobalConfig()` returns the parsed configuration\n\n#### Scenario: Config file does not exist\n- **WHEN** `config.json` does not exist in the global config directory\n- **THEN** `getGlobalConfig()` returns the default configuration\n- **AND** no directory or file is created\n\n#### Scenario: Config file is invalid JSON\n- **WHEN** `config.json` exists but contains invalid JSON\n- **THEN** `getGlobalConfig()` returns the default configuration\n- **AND** a warning is logged to stderr\n\n### Requirement: Global Config Saving\n\nThe system SHALL save global configuration to the config directory, creating the directory if it does not exist.\n\n#### Scenario: Save config to new directory\n- **WHEN** `saveGlobalConfig(config)` is called\n- **AND** the global config directory does not exist\n- **THEN** the directory is created\n- **AND** `config.json` is written with the provided configuration\n\n#### Scenario: Save config to existing directory\n- **WHEN** `saveGlobalConfig(config)` is called\n- **AND** the global config directory already exists\n- **THEN** `config.json` is written (overwriting if exists)\n\n### Requirement: Default Configuration\n\nThe system SHALL provide a default configuration that is used when no config file exists.\n\n#### Scenario: Default config structure\n- **WHEN** no config file exists\n- **THEN** the default configuration includes an empty `featureFlags` object\n\n### Requirement: Config Schema Evolution\n\nThe system SHALL merge loaded configuration with default values to ensure new config fields are available even when loading older config files.\n\n#### Scenario: Config file missing new fields\n- **WHEN** `config.json` exists with `{ \"featureFlags\": {} }`\n- **AND** the current schema includes a new field `defaultAiTool`\n- **THEN** `getGlobalConfig()` returns `{ featureFlags: {}, defaultAiTool: <default> }`\n- **AND** the loaded values take precedence over defaults for fields that exist in both\n\n#### Scenario: Config file has extra unknown fields\n- **WHEN** `config.json` contains fields not in the current schema\n- **THEN** the unknown fields are preserved in the returned configuration\n- **AND** no error or warning is raised\n\n"
  },
  {
    "path": "openspec/specs/instruction-loader/spec.md",
    "content": "# instruction-loader Specification\n\n## Purpose\nThe instruction-loader loads instruction templates from schema directories, validates and enriches them with metadata and parameters (such as change context and dependency status), and exposes them for use by downstream services including template retrieval, parameter substitution, and enrichment.\n\n## Requirements\n### Requirement: Template Loading\nThe system SHALL load templates from schema directories.\n\n#### Scenario: Load template from schema directory\n- **WHEN** `loadTemplate(schemaName, templatePath)` is called\n- **THEN** the system loads the template from `schemas/<schemaName>/templates/<templatePath>`\n\n#### Scenario: Template file not found\n- **WHEN** a template file does not exist in the schema's templates directory\n- **THEN** the system throws an error with the template path\n\n### Requirement: Change Context Loading\nThe system SHALL load change context combining graph and completion state.\n\n#### Scenario: Load context for existing change\n- **WHEN** `loadChangeContext(projectRoot, changeName)` is called for an existing change\n- **THEN** the system returns a context with graph, completed set, schema name, and change info\n\n#### Scenario: Load context with custom schema\n- **WHEN** `loadChangeContext(projectRoot, changeName, schemaName)` is called\n- **THEN** the system uses the specified schema instead of default\n\n#### Scenario: Load context for non-existent change directory\n- **WHEN** `loadChangeContext` is called for a non-existent change directory\n- **THEN** the system returns context with empty completed set\n\n### Requirement: Template Enrichment\nThe system SHALL enrich templates with change-specific context.\n\n#### Scenario: Include artifact metadata\n- **WHEN** instructions are generated for an artifact\n- **THEN** the output includes change name, artifact ID, schema name, and output path\n\n#### Scenario: Include dependency status\n- **WHEN** an artifact has dependencies\n- **THEN** the output shows each dependency with completion status (done/missing)\n\n#### Scenario: Include unlocked artifacts\n- **WHEN** instructions are generated\n- **THEN** the output includes which artifacts become available after this one\n\n#### Scenario: Root artifact indicator\n- **WHEN** an artifact has no dependencies\n- **THEN** the dependency section indicates this is a root artifact\n\n### Requirement: Status Formatting\nThe system SHALL format change status as readable output.\n\n#### Scenario: All artifacts completed\n- **WHEN** all artifacts are completed\n- **THEN** status shows all artifacts as \"done\"\n\n#### Scenario: Mixed completion status\n- **WHEN** some artifacts are completed\n- **THEN** status shows completed as \"done\", ready as \"ready\", blocked as \"blocked\"\n\n#### Scenario: Blocked artifact details\n- **WHEN** an artifact is blocked\n- **THEN** status shows which dependencies are missing\n\n#### Scenario: Include output paths\n- **WHEN** status is formatted\n- **THEN** each artifact shows its output path pattern\n\n"
  },
  {
    "path": "openspec/specs/legacy-cleanup/spec.md",
    "content": "# legacy-cleanup Specification\n\n## Purpose\nDefine detection and cleanup behavior for legacy OpenSpec artifacts during initialization and update workflows.\n\n## Requirements\n### Requirement: Legacy artifact detection\n\nThe system SHALL detect legacy OpenSpec artifacts from previous init versions.\n\n#### Scenario: Detecting legacy config files\n\n- **WHEN** running `openspec init` on an existing project\n- **THEN** the system SHALL check for config files with OpenSpec markers:\n  - `CLAUDE.md`\n  - `.cursorrules`\n  - `.windsurfrules`\n  - `.clinerules`\n  - `.kilocode_rules`\n  - `.github/copilot-instructions.md`\n  - `.amazonq/instructions.md`\n  - `CODEBUDDY.md`\n  - `IFLOW.md`\n  - And all other tool config files from the legacy ToolRegistry\n\n#### Scenario: Detecting legacy slash command directories\n\n- **WHEN** running `openspec init` on an existing project\n- **THEN** the system SHALL check for old slash command directories:\n  - `.claude/commands/openspec/`\n  - `.cursor/commands/openspec/` (note: old format used `openspec-*.md` in commands root)\n  - `.windsurf/workflows/openspec-*.md`\n  - And equivalent directories for all tools in the legacy SlashCommandRegistry\n\n#### Scenario: Detecting legacy OpenSpec structure files\n\n- **WHEN** running `openspec init` on an existing project\n- **THEN** the system SHALL check for:\n  - `openspec/AGENTS.md`\n  - `openspec/project.md` (for migration messaging only, not deleted)\n  - Root `AGENTS.md` with OpenSpec markers\n\n### Requirement: Legacy cleanup confirmation\n\nThe system SHALL prompt for confirmation before removing legacy artifacts.\n\n#### Scenario: Prompting for cleanup when legacy detected\n\n- **WHEN** legacy artifacts are detected\n- **THEN** the system SHALL display what was found\n- **AND** prompt: \"Legacy files detected. Upgrade and clean up? [Y/n]\"\n- **AND** default to Yes if user presses Enter\n\n#### Scenario: User confirms cleanup\n\n- **WHEN** user responds Y or presses Enter\n- **THEN** the system SHALL remove legacy artifacts\n- **AND** proceed with skill-based setup\n\n#### Scenario: User declines cleanup\n\n- **WHEN** user responds N\n- **THEN** the system SHALL abort initialization\n- **AND** display message suggesting manual cleanup or using `--force` flag\n\n#### Scenario: Non-interactive mode\n\n- **WHEN** running with `--no-interactive` or in CI environment\n- **AND** legacy artifacts are detected\n- **THEN** the system SHALL abort with exit code 1\n- **AND** display detected legacy artifacts\n- **AND** suggest running interactively or using `--force` flag\n\n### Requirement: Surgical removal of config file content\n\nThe system SHALL preserve user content when removing OpenSpec markers from config files.\n\n#### Scenario: Config file with only OpenSpec content\n\n- **WHEN** a config file contains only OpenSpec marker block (whitespace outside is acceptable)\n- **THEN** the system SHALL remove the OpenSpec marker block\n- **AND** preserve the file (even if empty or whitespace-only)\n- **AND** NOT delete the file (config files belong to the user's project root)\n\n#### Scenario: Config file with mixed content\n\n- **WHEN** a config file contains content outside OpenSpec markers\n- **THEN** the system SHALL remove only the `<!-- OPENSPEC:START -->` to `<!-- OPENSPEC:END -->` block\n- **AND** preserve all content before and after the markers\n- **AND** clean up any resulting double blank lines\n\n#### Scenario: Root AGENTS.md with mixed content\n\n- **WHEN** root `AGENTS.md` contains OpenSpec markers AND other content\n- **THEN** the system SHALL remove only the OpenSpec marker block\n- **AND** preserve the rest of the file\n\n### Requirement: Legacy directory removal\n\nThe system SHALL remove legacy slash command directories entirely.\n\n#### Scenario: Removing old slash command directory\n\n- **WHEN** a legacy slash command directory exists (e.g., `.claude/commands/openspec/`)\n- **THEN** the system SHALL delete the entire directory and its contents\n- **AND** NOT delete the parent directory (e.g., `.claude/commands/` remains)\n\n#### Scenario: Removing legacy AGENTS.md\n\n- **WHEN** `openspec/AGENTS.md` exists\n- **THEN** the system SHALL delete the file\n- **AND** NOT delete the `openspec/` directory itself\n\n### Requirement: project.md migration hint\n\nThe system SHALL preserve project.md and display a migration hint instead of deleting it.\n\n#### Scenario: project.md exists during upgrade\n\n- **WHEN** `openspec/project.md` exists during legacy cleanup\n- **THEN** the system SHALL NOT delete the file\n- **AND** the system SHALL display a migration hint in the output:\n  ```\n  Manual migration needed:\n    → openspec/project.md still exists\n      Move useful content to config.yaml's \"context:\" field, then delete\n  ```\n\n#### Scenario: project.md migration rationale\n\n- **GIVEN** project.md may contain user-written project documentation\n- **AND** config.yaml's context field serves the same purpose (auto-injected into artifacts)\n- **WHEN** displaying the migration hint\n- **THEN** users can migrate manually or use `/opsx:explore` to get AI assistance\n\n### Requirement: Cleanup reporting\n\nThe system SHALL report what was cleaned up.\n\n#### Scenario: Displaying cleanup summary\n\n- **WHEN** legacy cleanup completes\n- **THEN** the system SHALL display a summary section:\n  ```\n  Cleaned up legacy files:\n    ✓ Removed OpenSpec markers from CLAUDE.md\n    ✓ Removed .claude/commands/openspec/ (replaced by /opsx:*)\n    ✓ Removed openspec/AGENTS.md (no longer needed)\n  ```\n- **AND IF** `openspec/project.md` exists\n- **THEN** the system SHALL display a separate migration section:\n  ```\n  Manual migration needed:\n    → openspec/project.md still exists\n      Move useful content to config.yaml's \"context:\" field, then delete\n  ```\n\n#### Scenario: No legacy detected\n\n- **WHEN** no legacy artifacts are found\n- **THEN** the system SHALL NOT display the cleanup section\n- **AND** proceed directly with skill setup\n\n"
  },
  {
    "path": "openspec/specs/openspec-conventions/spec.md",
    "content": "# OpenSpec Conventions Specification\n\n## Purpose\n\nOpenSpec conventions SHALL define how system capabilities are documented, how changes are proposed and tracked, and how specifications evolve over time. This meta-specification serves as the source of truth for OpenSpec's own conventions.\n## Requirements\n### Requirement: Structured conventions for specs and changes\n\nOpenSpec conventions SHALL mandate a structured spec format with clear requirement and scenario sections so tooling can parse consistently.\n\n#### Scenario: Following the structured spec format\n\n- **WHEN** writing or updating OpenSpec specifications\n- **THEN** authors SHALL use `### Requirement: ...` followed by at least one `#### Scenario: ...` section\n\n### Requirement: Behavior-First Specification Boundary\nOpenSpec specifications SHALL capture verifiable behavior contracts and avoid internal implementation detail.\n\n#### Scenario: Writing behavior requirements\n- **WHEN** documenting a capability in `spec.md`\n- **THEN** requirements focus on externally observable behavior, interfaces, error handling, and constraints\n- **AND** scenarios remain testable or explicitly verifiable\n\n#### Scenario: Avoiding implementation leakage\n- **WHEN** details involve concrete library choices, class/function structure, or execution mechanics\n- **THEN** those details SHALL be documented in `design.md` or `tasks.md` instead of behavioral requirements\n\n### Requirement: Progressive Rigor\nOpenSpec conventions SHALL keep specs lightweight by default and scale rigor only when risk or coordination complexity demands it.\n\n#### Scenario: Routine change specification\n- **WHEN** a change is local and low-risk\n- **THEN** authors use concise, behavior-first requirements with minimal ceremony\n\n#### Scenario: High-risk or cross-boundary change specification\n- **WHEN** a change is cross-team, cross-repo, API-contract breaking, migration-heavy, or security/privacy sensitive\n- **THEN** authors increase detail and explicit validation expectations proportionally\n\n### Requirement: Project Structure\nAn OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.\n\n#### Scenario: Initializing project structure\n- **WHEN** an OpenSpec project is initialized\n- **THEN** it SHALL have this structure:\n```\nopenspec/\n├── project.md              # Project-specific context\n├── AGENTS.md               # AI assistant instructions\n├── specs/                  # Current deployed capabilities\n│   └── [capability]/       # Single, focused capability\n│       ├── spec.md         # WHAT and WHY\n│       └── design.md       # HOW (optional, for established patterns)\n└── changes/                # Proposed changes\n    ├── [change-name]/      # Descriptive change identifier\n    │   ├── proposal.md     # Why, what, and impact\n    │   ├── tasks.md        # Implementation checklist\n    │   ├── design.md       # Technical decisions (optional)\n    │   └── specs/          # Complete future state\n    │       └── [capability]/\n    │           └── spec.md # Clean markdown (no diff syntax)\n    └── archive/            # Completed changes\n        └── YYYY-MM-DD-[name]/\n```\n\n### Requirement: Structured Format for Behavioral Specs\n\nBehavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability.\n\n#### Scenario: Writing requirement sections\n\n- **WHEN** documenting a requirement in a behavioral specification\n- **THEN** use a level-3 heading with format `### Requirement: [Name]`\n- **AND** immediately follow with a SHALL statement describing core behavior\n- **AND** keep requirement names descriptive and under 50 characters\n\n#### Scenario: Documenting scenarios\n\n- **WHEN** documenting specific behaviors or use cases\n- **THEN** use level-4 headings with format `#### Scenario: [Description]`\n- **AND** use bullet points with bold keywords for steps:\n  - **GIVEN** for initial state (optional)\n  - **WHEN** for conditions or triggers\n  - **THEN** for expected outcomes\n  - **AND** for additional outcomes or conditions\n\n#### Scenario: Adding implementation details\n\n- **WHEN** a step requires additional detail\n- **THEN** use sub-bullets under the main step\n- **AND** maintain consistent indentation\n  - Sub-bullets provide examples or specifics\n  - Keep sub-bullets concise\n\n### Requirement: Header-Based Requirement Identification\n\nRequirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes.\n\n#### Scenario: Matching requirements programmatically\n\n- **WHEN** processing delta changes\n- **THEN** use the `### Requirement: [Name]` header as the unique identifier\n- **AND** match using normalized headers: `normalize(header) = trim(header)`\n- **AND** compare headers with case-sensitive equality after normalization\n\n#### Scenario: Handling requirement renames\n\n- **WHEN** renaming a requirement\n- **THEN** use a special `## RENAMED Requirements` section\n- **AND** specify both old and new names explicitly:\n  ```markdown\n  ## RENAMED Requirements\n  - FROM: `### Requirement: Old Name`\n  - TO: `### Requirement: New Name`\n  ```\n- **AND** if content also changes, include under MODIFIED using the NEW header\n\n#### Scenario: Validating header uniqueness\n\n- **WHEN** creating or modifying requirements\n- **THEN** ensure no duplicate headers exist within a spec\n- **AND** validation tools SHALL flag duplicate headers as errors\n\n### Requirement: Change Storage Convention\n\nChange proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states.\n\n#### Scenario: Creating change proposals with additions\n\n- **WHEN** creating a change proposal that adds new requirements\n- **THEN** include only the new requirements under `## ADDED Requirements`\n- **AND** each requirement SHALL include its complete content\n- **AND** use the standard structured format for requirements and scenarios\n\n#### Scenario: Creating change proposals with modifications  \n\n- **WHEN** creating a change proposal that modifies existing requirements\n- **THEN** include the modified requirements under `## MODIFIED Requirements`\n- **AND** use the same header text as in the current spec (normalized)\n- **AND** include the complete modified requirement (not a diff)\n- **AND** optionally annotate what changed with inline comments like `← (was X)`\n\n#### Scenario: Creating change proposals with removals\n\n- **WHEN** creating a change proposal that removes requirements\n- **THEN** list them under `## REMOVED Requirements`\n- **AND** use the normalized header text for identification\n- **AND** include reason for removal\n- **AND** document any migration path if applicable\n\nThe `changes/[name]/specs/` directory SHALL contain:\n- Delta files showing only what changes\n- Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements\n- Normalized header matching for requirement identification\n- Complete requirements using the structured format\n- Clear indication of change type for each requirement\n\n#### Scenario: Using standard output symbols\n\n- **WHEN** displaying delta operations in CLI output\n- **THEN** use these standard symbols:\n  - `+` for ADDED (green)\n  - `~` for MODIFIED (yellow)\n  - `-` for REMOVED (red)\n  - `→` for RENAMED (cyan)\n\n### Requirement: Archive Process Enhancement\n\nThe archive process SHALL programmatically apply delta changes to current specifications using header-based matching.\n\n#### Scenario: Archiving changes with deltas\n\n- **WHEN** archiving a completed change\n- **THEN** the archive command SHALL:\n  1. Parse RENAMED sections first and apply renames\n  2. Parse REMOVED sections and remove by normalized header match\n  3. Parse MODIFIED sections and replace by normalized header match (using new names if renamed)\n  4. Parse ADDED sections and append new requirements\n- **AND** validate that all MODIFIED/REMOVED headers exist in current spec\n- **AND** validate that ADDED headers don't already exist\n- **AND** generate the updated spec in the main specs/ directory\n\n#### Scenario: Handling conflicts during archive\n\n- **WHEN** delta changes conflict with current spec state\n- **THEN** the archive command SHALL report specific conflicts\n- **AND** require manual resolution before proceeding\n- **AND** provide clear guidance on resolving conflicts\n\n### Requirement: Proposal Format\n\nProposals SHALL explicitly document all changes with clear from/to comparisons.\n\n#### Scenario: Documenting changes\n\n- **WHEN** documenting what changes\n- **THEN** the proposal SHALL explicitly describe each change:\n\n```markdown\n**[Section or Behavior Name]**\n- From: [current state/requirement]\n- To: [future state/requirement]\n- Reason: [why this change is needed]\n- Impact: [breaking/non-breaking, who's affected]\n```\n\nThis explicit format compensates for not having inline diffs and ensures reviewers understand exactly what will change.\n\n### Requirement: Change Review\n\nThe system SHALL support multiple methods for reviewing proposed changes.\n\n#### Scenario: Reviewing changes\n\n- **WHEN** reviewing proposed changes\n- **THEN** reviewers can compare using:\n- GitHub PR diff view when changes are committed\n- Command line: `diff -u specs/[capability]/spec.md changes/[name]/specs/[capability]/spec.md`\n- Any visual diff tool comparing current vs future state\n\n### Requirement: Structured Format Adoption\n\nBehavioral specifications SHALL adopt the structured format with `### Requirement:` and `#### Scenario:` headers as the default.\n\n#### Scenario: Use structured headings for behavior\n\n- **WHEN** documenting behavioral requirements\n- **THEN** use `### Requirement:` for requirements\n- **AND** use `#### Scenario:` for scenarios with bold WHEN/THEN/AND keywords\n\n### Requirement: Verb–Noun CLI Command Structure\nOpenSpec CLI design SHALL use verbs as top-level commands with nouns provided as arguments or flags for scoping.\n\n#### Scenario: Verb-first command discovery\n- **WHEN** a user runs a command like `openspec list`\n- **THEN** the verb communicates the action clearly\n- **AND** nouns refine scope via flags or arguments (e.g., `--changes`, `--specs`)\n\n#### Scenario: Backward compatibility for noun commands\n- **WHEN** users run noun-prefixed commands such as `openspec spec ...` or `openspec change ...`\n- **THEN** the CLI SHALL continue to support them for at least one release\n- **AND** display a deprecation warning that points to verb-first alternatives\n\n#### Scenario: Disambiguation guidance\n- **WHEN** item names are ambiguous between changes and specs\n- **THEN** `openspec show` and `openspec validate` SHALL accept `--type spec|change`\n- **AND** the help text SHALL document this clearly\n\n## Core Principles\n\nThe system SHALL follow these principles:\n- Specs reflect what IS currently built and deployed\n- Changes contain proposals for what SHOULD be changed\n- AI drives the documentation process\n- Specs are living documentation kept in sync with deployed code\n\n## Directory Structure\n\n### Requirement: Project Structure\n\nAn OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.\n\n#### Scenario: Initializing project structure\n\n- **WHEN** an OpenSpec project is initialized\n- **THEN** it SHALL have this structure:\n```\nopenspec/\n├── project.md              # Project-specific context\n├── AGENTS.md               # AI assistant instructions\n├── specs/                  # Current deployed capabilities\n│   └── [capability]/       # Single, focused capability\n│       ├── spec.md         # WHAT and WHY\n│       └── design.md       # HOW (optional, for established patterns)\n└── changes/                # Proposed changes\n    ├── [change-name]/      # Descriptive change identifier\n    │   ├── proposal.md     # Why, what, and impact\n    │   ├── tasks.md        # Implementation checklist\n    │   ├── design.md       # Technical decisions (optional)\n    │   └── specs/          # Complete future state\n    │       └── [capability]/\n    │           └── spec.md # Clean markdown (no diff syntax)\n    └── archive/            # Completed changes\n        └── YYYY-MM-DD-[name]/\n```\n\n## Specification Format\n\n### Requirement: Structured Format for Behavioral Specs\n\nBehavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability.\n\n#### Scenario: Writing requirement sections\n\n- **WHEN** documenting a requirement in a behavioral specification\n- **THEN** use a level-3 heading with format `### Requirement: [Name]`\n- **AND** immediately follow with a SHALL statement describing core behavior\n- **AND** keep requirement names descriptive and under 50 characters\n\n#### Scenario: Documenting scenarios\n\n- **WHEN** documenting specific behaviors or use cases\n- **THEN** use level-4 headings with format `#### Scenario: [Description]`\n- **AND** use bullet points with bold keywords for steps:\n  - **GIVEN** for initial state (optional)\n  - **WHEN** for conditions or triggers\n  - **THEN** for expected outcomes\n  - **AND** for additional outcomes or conditions\n\n#### Scenario: Adding implementation details\n\n- **WHEN** a step requires additional detail\n- **THEN** use sub-bullets under the main step\n- **AND** maintain consistent indentation\n  - Sub-bullets provide examples or specifics\n  - Keep sub-bullets concise\n\n## Change Storage Convention\n\n### Requirement: Header-Based Requirement Identification\n\nRequirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes.\n\n#### Scenario: Matching requirements programmatically\n\n- **WHEN** processing delta changes\n- **THEN** use the `### Requirement: [Name]` header as the unique identifier\n- **AND** match using normalized headers: `normalize(header) = trim(header)`\n- **AND** compare headers with case-sensitive equality after normalization\n\n#### Scenario: Handling requirement renames\n\n- **WHEN** renaming a requirement\n- **THEN** use a special `## RENAMED Requirements` section\n- **AND** specify both old and new names explicitly:\n  ```markdown\n  ## RENAMED Requirements\n  - FROM: `### Requirement: Old Name`\n  - TO: `### Requirement: New Name`\n  ```\n- **AND** if content also changes, include under MODIFIED using the NEW header\n\n#### Scenario: Validating header uniqueness\n\n- **WHEN** creating or modifying requirements\n- **THEN** ensure no duplicate headers exist within a spec\n- **AND** validation tools SHALL flag duplicate headers as errors\n\n### Requirement: Change Storage Convention\n\nChange proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states.\n\n#### Scenario: Creating change proposals with additions\n\n- **WHEN** creating a change proposal that adds new requirements\n- **THEN** include only the new requirements under `## ADDED Requirements`\n- **AND** each requirement SHALL include its complete content\n- **AND** use the standard structured format for requirements and scenarios\n\n#### Scenario: Creating change proposals with modifications  \n\n- **WHEN** creating a change proposal that modifies existing requirements\n- **THEN** include the modified requirements under `## MODIFIED Requirements`\n- **AND** use the same header text as in the current spec (normalized)\n- **AND** include the complete modified requirement (not a diff)\n- **AND** optionally annotate what changed with inline comments like `← (was X)`\n\n#### Scenario: Creating change proposals with removals\n\n- **WHEN** creating a change proposal that removes requirements\n- **THEN** list them under `## REMOVED Requirements`\n- **AND** use the normalized header text for identification\n- **AND** include reason for removal\n- **AND** document any migration path if applicable\n\nThe `changes/[name]/specs/` directory SHALL contain:\n- Delta files showing only what changes\n- Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements\n- Normalized header matching for requirement identification\n- Complete requirements using the structured format\n- Clear indication of change type for each requirement\n\n#### Scenario: Using standard output symbols\n\n- **WHEN** displaying delta operations in CLI output\n- **THEN** use these standard symbols:\n  - `+` for ADDED (green)\n  - `~` for MODIFIED (yellow)\n  - `-` for REMOVED (red)\n  - `→` for RENAMED (cyan)\n\n### Requirement: Archive Process Enhancement\n\nThe archive process SHALL programmatically apply delta changes to current specifications using header-based matching.\n\n#### Scenario: Archiving changes with deltas\n\n- **WHEN** archiving a completed change\n- **THEN** the archive command SHALL:\n  1. Parse RENAMED sections first and apply renames\n  2. Parse REMOVED sections and remove by normalized header match\n  3. Parse MODIFIED sections and replace by normalized header match (using new names if renamed)\n  4. Parse ADDED sections and append new requirements\n- **AND** validate that all MODIFIED/REMOVED headers exist in current spec\n- **AND** validate that ADDED headers don't already exist\n- **AND** generate the updated spec in the main specs/ directory\n\n#### Scenario: Handling conflicts during archive\n\n- **WHEN** delta changes conflict with current spec state\n- **THEN** the archive command SHALL report specific conflicts\n- **AND** require manual resolution before proceeding\n- **AND** provide clear guidance on resolving conflicts\n\n### Requirement: Proposal Format\n\nProposals SHALL explicitly document all changes with clear from/to comparisons.\n\n#### Scenario: Documenting changes\n\n- **WHEN** documenting what changes\n- **THEN** the proposal SHALL explicitly describe each change:\n\n```markdown\n**[Section or Behavior Name]**\n- From: [current state/requirement]\n- To: [future state/requirement]\n- Reason: [why this change is needed]\n- Impact: [breaking/non-breaking, who's affected]\n```\n\nThis explicit format compensates for not having inline diffs and ensures reviewers understand exactly what will change.\n\n## Change Lifecycle\n\nThe change process SHALL follow these states:\n\n1. **Propose**: AI creates change with future state specs and explicit proposal\n2. **Review**: Humans review proposal and future state\n3. **Approve**: Change is approved for implementation\n4. **Implement**: Follow tasks.md checklist (can span multiple PRs)\n5. **Deploy**: Changes are deployed to production\n6. **Update**: Specs in `specs/` are updated to match deployed reality\n7. **Archive**: Change is moved to `archive/YYYY-MM-DD-[name]/`\n\n## Viewing Changes\n\n### Requirement: Change Review\n\nThe system SHALL support multiple methods for reviewing proposed changes.\n\n#### Scenario: Reviewing changes\n\n- **WHEN** reviewing proposed changes\n- **THEN** reviewers can compare using:\n- GitHub PR diff view when changes are committed\n- Command line: `diff -u specs/[capability]/spec.md changes/[name]/specs/[capability]/spec.md`\n- Any visual diff tool comparing current vs future state\n\nThe system relies on tools to generate diffs rather than storing them.\n\n## Capability Naming\n\nCapabilities SHALL use:\n- Verb-noun patterns (e.g., `user-auth`, `payment-capture`)\n- Hyphenated lowercase names\n- Singular focus (one responsibility per capability)\n- No nesting (flat structure under `specs/`)\n\n## When Changes Require Proposals\n\nA proposal SHALL be created for:\n- New features or capabilities\n- Breaking changes to existing behavior\n- Architecture or pattern changes\n- Performance optimizations that change behavior\n- Security updates affecting access patterns\n\nA proposal is NOT required for:\n- Bug fixes restoring intended behavior\n- Typos or formatting fixes\n- Non-breaking dependency updates\n- Adding tests for existing behavior\n- Documentation clarifications\n\n## Why This Approach\n\nClean future state storage provides:\n- **Readability**: No diff syntax pollution\n- **AI-compatibility**: Standard markdown that AI tools understand\n- **Simplicity**: No special parsing or processing needed\n- **Tool-agnostic**: Any diff tool can show changes\n- **Clear intent**: Explicit proposals document reasoning\n\nThe structured format adds:\n- **Visual Consistency**: Requirement and Scenario prefixes make sections instantly recognizable\n- **Parseability**: Consistent structure enables tooling and automation\n- **Gradual Adoption**: Existing specs can migrate incrementally\n"
  },
  {
    "path": "openspec/specs/opsx-archive-skill/spec.md",
    "content": "# OPSX Archive Skill Spec\n\n## Purpose\n\nDefine the expected behavior for the `/opsx:archive` skill, including readiness checks, spec sync prompting, archive execution, and user-facing output.\n\n## Requirements\n\n### Requirement: OPSX Archive Skill\n\nThe system SHALL provide an `/opsx:archive` skill that archives completed changes in the experimental workflow.\n\n#### Scenario: Archive a change with all artifacts complete\n\n- **WHEN** agent executes `/opsx:archive` with a change name\n- **AND** all artifacts in the schema are complete\n- **AND** all tasks are complete\n- **THEN** the agent moves the change to `openspec/changes/archive/YYYY-MM-DD-<name>/`\n- **AND** displays success message with archived location\n\n#### Scenario: Change selection prompt\n\n- **WHEN** agent executes `/opsx:archive` without specifying a change\n- **THEN** the agent prompts user to select from available changes\n- **AND** shows only active changes (excludes archive/)\n\n### Requirement: Artifact Completion Check\n\nThe skill SHALL check artifact completion status using the artifact graph before archiving.\n\n#### Scenario: Incomplete artifacts warning\n\n- **WHEN** agent checks artifact status\n- **AND** one or more artifacts have status other than `done`\n- **THEN** display warning listing incomplete artifacts\n- **AND** prompt user for confirmation to continue\n- **AND** proceed if user confirms\n\n#### Scenario: All artifacts complete\n\n- **WHEN** agent checks artifact status\n- **AND** all artifacts have status `done`\n- **THEN** proceed without warning\n\n### Requirement: Task Completion Check\n\nThe skill SHALL check task completion status from tasks.md before archiving.\n\n#### Scenario: Incomplete tasks found\n\n- **WHEN** agent reads tasks.md\n- **AND** incomplete tasks are found (marked with `- [ ]`)\n- **THEN** display warning showing count of incomplete tasks\n- **AND** prompt user for confirmation to continue\n- **AND** proceed if user confirms\n\n#### Scenario: All tasks complete\n\n- **WHEN** agent reads tasks.md\n- **AND** all tasks are complete (marked with `- [x]`)\n- **THEN** proceed without task-related warning\n\n#### Scenario: No tasks file\n\n- **WHEN** tasks.md does not exist\n- **THEN** proceed without task-related warning\n\n### Requirement: Spec Sync Prompt\n\nThe skill SHALL prompt to sync delta specs before archiving if specs exist.\n\n#### Scenario: Delta specs exist\n\n- **WHEN** agent checks for delta specs\n- **AND** `specs/` directory exists in the change with spec files\n- **THEN** prompt user: \"This change has delta specs. Would you like to sync them to main specs before archiving?\"\n- **AND** if user confirms, execute `/opsx:sync` logic\n- **AND** proceed with archive regardless of sync choice\n\n#### Scenario: No delta specs\n\n- **WHEN** agent checks for delta specs\n- **AND** no `specs/` directory or no spec files exist\n- **THEN** proceed without sync prompt\n\n### Requirement: Archive Process\n\nThe skill SHALL move the change to the archive folder with date prefix.\n\n#### Scenario: Successful archive\n\n- **WHEN** archiving a change\n- **THEN** create `archive/` directory if it doesn't exist\n- **AND** generate target name as `YYYY-MM-DD-<change-name>` using current date\n- **AND** move entire change directory to archive location\n- **AND** preserve `.openspec.yaml` file in archived change\n\n#### Scenario: Archive already exists\n\n- **WHEN** target archive directory already exists\n- **THEN** fail with error message\n- **AND** suggest renaming existing archive or using different date\n\n### Requirement: Skill Output\n\nThe skill SHALL provide clear feedback about the archive operation.\n\n#### Scenario: Archive complete with sync\n\n- **WHEN** archive completes after syncing specs\n- **THEN** display summary:\n  - Specs synced (from `/opsx:sync` output)\n  - Change archived to location\n  - Schema that was used\n\n#### Scenario: Archive complete without sync\n\n- **WHEN** archive completes without syncing specs\n- **THEN** display summary:\n  - Note that specs were not synced (if applicable)\n  - Change archived to location\n  - Schema that was used\n\n#### Scenario: Archive complete with warnings\n\n- **WHEN** archive completes with incomplete artifacts or tasks\n- **THEN** include note about what was incomplete\n- **AND** suggest reviewing if archive was intentional\n"
  },
  {
    "path": "openspec/specs/opsx-onboard-skill/spec.md",
    "content": "# opsx-onboard-skill Specification\n\n## Purpose\nDefine `/opsx:onboard` behavior for guiding users through an end-to-end OpenSpec workflow on their real codebase.\n\n## Requirements\n### Requirement: OPSX Onboard Skill\n\nThe system SHALL provide an `/opsx:onboard` skill that guides users through their first complete OpenSpec workflow cycle with narration and real codebase work.\n\n#### Scenario: Skill invocation\n\n- **WHEN** user invokes `/opsx:onboard`\n- **THEN** agent checks if OpenSpec is initialized\n- **AND** if not initialized, prompts user to run `openspec init` first\n- **AND** if initialized, proceeds with onboarding flow\n\n#### Scenario: Welcome and expectations\n\n- **WHEN** onboarding begins\n- **THEN** agent displays welcome message explaining what will happen\n- **AND** sets expectation of ~15 minute duration\n- **AND** explains the workflow phases: explore → new → artifacts → apply → archive\n\n### Requirement: Codebase Analysis for Task Suggestions\n\nThe skill SHALL analyze the user's codebase to suggest appropriately-scoped starter tasks.\n\n#### Scenario: Codebase scanning\n\n- **WHEN** onboarding reaches task selection phase\n- **THEN** agent scans codebase for small improvement opportunities\n- **AND** looks for: TODO/FIXME comments, missing error handling, functions without tests, outdated dependencies, type: any in TypeScript, console.log in production code, missing input validation\n- **AND** checks recent git commits for context on current work\n\n#### Scenario: Task suggestion presentation\n\n- **WHEN** agent has analyzed codebase\n- **THEN** agent presents 3-4 specific task suggestions with scope estimates\n- **AND** each suggestion includes: task description, estimated scope (files/lines), why it's a good starter\n- **AND** offers option for user to specify their own task\n\n#### Scenario: Scope guardrail\n\n- **WHEN** user selects or describes a task that is too large\n- **THEN** agent gently redirects toward smaller scope\n- **AND** suggests breaking down or deferring the large task\n- **AND** offers appropriately-sized alternatives\n\n### Requirement: Explore Phase Demo\n\nThe skill SHALL briefly demonstrate explore mode before creating a change.\n\n#### Scenario: Brief explore demonstration\n\n- **WHEN** task is selected\n- **THEN** agent briefly demonstrates `/opsx:explore` by investigating relevant code\n- **AND** explains explore mode is for thinking before doing\n- **AND** keeps this phase short (not a full exploration session)\n- **AND** transitions to change creation\n\n### Requirement: Guided Artifact Creation\n\nThe skill SHALL guide users through each artifact with narration explaining the purpose.\n\n#### Scenario: Change creation with narration\n\n- **WHEN** creating the change directory\n- **THEN** agent runs `openspec new change \"<name>\"` with derived kebab-case name\n- **AND** explains what a \"change\" is (container for thinking and planning)\n- **AND** shows the folder structure that was created\n- **AND** pauses for user acknowledgment before proceeding\n\n#### Scenario: Proposal creation with narration\n\n- **WHEN** creating proposal.md\n- **THEN** agent explains proposals capture WHY we're making this change\n- **AND** drafts proposal based on selected task\n- **AND** shows draft to user for approval before saving\n- **AND** explains the sections (Why, What Changes, Capabilities, Impact)\n\n#### Scenario: Specs creation with narration\n\n- **WHEN** creating spec files\n- **THEN** agent explains specs define WHAT we're building in detail\n- **AND** explains the requirement/scenario format\n- **AND** creates spec file(s) based on proposal capabilities\n- **AND** notes that specs become documentation that stays in sync\n\n#### Scenario: Design creation with narration\n\n- **WHEN** creating design.md\n- **THEN** agent explains design captures HOW we'll build it\n- **AND** notes this is where technical decisions and tradeoffs live\n- **AND** for small changes, acknowledges design may be brief\n- **AND** creates design based on proposal and specs\n\n#### Scenario: Tasks creation with narration\n\n- **WHEN** creating tasks.md\n- **THEN** agent explains tasks break work into checkboxes\n- **AND** explains these drive the apply phase\n- **AND** generates task list from design and specs\n- **AND** shows tasks and asks if ready to implement\n\n### Requirement: Guided Implementation\n\nThe skill SHALL implement tasks with narration connecting back to artifacts.\n\n#### Scenario: Implementation with narration\n\n- **WHEN** implementing tasks\n- **THEN** agent announces each task before working on it\n- **AND** implements the change in the codebase\n- **AND** occasionally references how specs/design informed decisions\n- **AND** marks each task complete as it finishes\n- **AND** keeps narration light (not over-explaining)\n\n#### Scenario: Implementation completion\n\n- **WHEN** all tasks are complete\n- **THEN** agent announces completion\n- **AND** summarizes what was done\n- **AND** transitions to archive phase\n\n### Requirement: Archive with Explanation\n\nThe skill SHALL archive the completed change and explain what happened.\n\n#### Scenario: Archive with narration\n\n- **WHEN** archiving the change\n- **THEN** agent explains archive moves change to dated folder\n- **AND** runs archive process\n- **AND** shows where archived change lives\n- **AND** explains the long-term value (finding decisions later)\n\n### Requirement: Recap and Next Steps\n\nThe skill SHALL conclude with a recap and command reference.\n\n#### Scenario: Final recap\n\n- **WHEN** onboarding is complete\n- **THEN** agent summarizes the workflow phases completed\n- **AND** emphasizes this rhythm works for any size change\n- **AND** provides command reference table (/opsx:explore, /opsx:new, /opsx:ff, /opsx:continue, /opsx:apply, /opsx:verify, /opsx:archive)\n- **AND** suggests next actions (try /opsx:new or /opsx:ff on something)\n\n### Requirement: Graceful Exit Handling\n\nThe skill SHALL handle users who want to stop mid-way.\n\n#### Scenario: User wants to stop\n\n- **WHEN** user indicates they want to stop during onboarding\n- **THEN** agent acknowledges gracefully\n- **AND** notes that the in-progress change is saved\n- **AND** explains how to continue later with `/opsx:continue <name>`\n- **AND** exits without pressure\n\n#### Scenario: User wants quick reference only\n\n- **WHEN** user says they just want to see the commands\n- **THEN** agent provides command cheat sheet\n- **AND** exits gracefully with encouragement to try `/opsx:new`\n\n"
  },
  {
    "path": "openspec/specs/opsx-verify-skill/spec.md",
    "content": "# opsx-verify-skill Specification\n\n## Purpose\nDefine `/opsx:verify` behavior for assessing implementation completeness, correctness, and coherence against change artifacts.\n\n## Requirements\n### Requirement: Verify Skill Invocation\nThe system SHALL provide an `/opsx:verify` skill that validates implementation against change artifacts.\n\n#### Scenario: Verify with change name provided\n- **WHEN** agent executes `/opsx:verify <change-name>`\n- **THEN** the agent verifies implementation for that specific change\n- **AND** produces a verification report\n\n#### Scenario: Verify without change name\n- **WHEN** agent executes `/opsx:verify` without a change name\n- **THEN** the agent prompts user to select from available changes\n- **AND** shows only changes that have implementation tasks\n\n#### Scenario: Change has no tasks\n- **WHEN** selected change has no tasks.md or tasks are empty\n- **THEN** the agent reports \"No tasks to verify\"\n- **AND** suggests running `/opsx:continue` to create tasks\n\n### Requirement: Completeness Verification\nThe agent SHALL verify that all required work has been completed.\n\n#### Scenario: Task completion check\n- **WHEN** verifying completeness\n- **THEN** the agent reads tasks.md\n- **AND** counts tasks marked `- [x]` (complete) vs `- [ ]` (incomplete)\n- **AND** reports completion status with specific incomplete tasks listed\n\n#### Scenario: Spec coverage check\n- **WHEN** verifying completeness\n- **AND** delta specs exist in `openspec/changes/<name>/specs/`\n- **THEN** the agent extracts all requirements from delta specs\n- **AND** searches codebase for implementation of each requirement\n- **AND** reports which requirements appear to have implementation vs which are missing\n\n#### Scenario: All tasks complete\n- **WHEN** all tasks are marked complete\n- **THEN** report \"Tasks: N/N complete\"\n- **AND** mark completeness dimension as passed\n\n#### Scenario: Incomplete tasks found\n- **WHEN** some tasks are incomplete\n- **THEN** report \"Tasks: X/N complete\"\n- **AND** list each incomplete task\n- **AND** mark as CRITICAL issue\n- **AND** suggest: \"Complete remaining tasks or mark as done if already implemented\"\n\n### Requirement: Correctness Verification\nThe agent SHALL verify that implementation matches the specifications.\n\n#### Scenario: Requirement implementation mapping\n- **WHEN** verifying correctness\n- **THEN** for each requirement in delta specs:\n  - Search codebase for implementation\n  - Identify relevant files and line numbers\n  - Assess whether implementation satisfies the requirement\n\n#### Scenario: Scenario coverage check\n- **WHEN** verifying correctness\n- **THEN** for each scenario in delta specs:\n  - Check if the scenario's conditions are handled in code\n  - Check if tests exist that cover the scenario\n  - Report coverage status\n\n#### Scenario: Implementation matches spec\n- **WHEN** implementation appears to satisfy a requirement\n- **THEN** report which files/lines implement it\n- **AND** mark requirement as covered\n\n#### Scenario: Implementation diverges from spec\n- **WHEN** implementation exists but doesn't match spec intent\n- **THEN** report the divergence as WARNING\n- **AND** explain what differs\n- **AND** suggest: either update implementation or update spec to match reality\n\n#### Scenario: Missing implementation\n- **WHEN** no implementation found for a requirement\n- **THEN** report as CRITICAL issue\n- **AND** suggest: \"Implement requirement X\" with guidance on what's needed\n\n### Requirement: Coherence Verification\nThe agent SHALL verify that implementation is sensible and follows design decisions.\n\n#### Scenario: Design.md adherence check\n- **WHEN** verifying coherence\n- **AND** design.md exists for the change\n- **THEN** extract key decisions from design.md\n- **AND** verify implementation follows those decisions\n- **AND** report any deviations\n\n#### Scenario: No design.md\n- **WHEN** verifying coherence\n- **AND** no design.md exists\n- **THEN** skip design adherence check\n- **AND** note \"No design.md to verify against\"\n\n#### Scenario: Design decision followed\n- **WHEN** implementation follows a design decision\n- **THEN** report as confirmed\n- **AND** cite evidence from code\n\n#### Scenario: Design decision violated\n- **WHEN** implementation contradicts a design decision\n- **THEN** report as WARNING\n- **AND** explain the contradiction\n- **AND** suggest: either update implementation or update design.md\n\n#### Scenario: Code pattern consistency\n- **WHEN** verifying coherence\n- **THEN** check if new code follows existing project patterns\n- **AND** flag any significant deviations as suggestions\n\n### Requirement: Verification Report Format\nThe agent SHALL produce a structured, prioritized report.\n\n#### Scenario: Report summary\n- **WHEN** verification completes\n- **THEN** display summary scorecard:\n  ```text\n  ## Verification Report: <change-name>\n\n  ### Summary\n  | Dimension    | Status   |\n  |--------------|----------|\n  | Completeness | X/Y      |\n  | Correctness  | X/Y      |\n  | Coherence    | Followed |\n  ```\n\n#### Scenario: Issue prioritization\n- **WHEN** issues are found\n- **THEN** group and display in priority order:\n  1. CRITICAL - Must fix before archive (missing implementation, incomplete tasks)\n  2. WARNING - Should fix (divergence from spec/design, missing tests)\n  3. SUGGESTION - Nice to fix (pattern inconsistencies, minor improvements)\n\n#### Scenario: Actionable recommendations\n- **WHEN** reporting an issue\n- **THEN** include specific, actionable fix recommendation\n- **AND** reference relevant files and line numbers where applicable\n- **AND** avoid vague suggestions like \"consider reviewing\"\n\n#### Scenario: All checks pass\n- **WHEN** no issues found across all dimensions\n- **THEN** display:\n  ```text\n  All checks passed. Ready for archive.\n  ```\n\n#### Scenario: Critical issues found\n- **WHEN** CRITICAL issues exist\n- **THEN** display:\n  ```text\n  X critical issue(s) found. Fix before archiving.\n  ```\n- **AND** do NOT suggest running archive\n\n#### Scenario: Only warnings/suggestions\n- **WHEN** no CRITICAL issues but warnings exist\n- **THEN** display:\n  ```text\n  No critical issues. Y warning(s) to consider.\n  Ready for archive (with noted improvements).\n  ```\n\n### Requirement: Flexible Artifact Handling\nThe agent SHALL gracefully handle changes with varying artifact completeness.\n\n#### Scenario: Minimal change (tasks only)\n- **WHEN** change has only tasks.md\n- **THEN** verify task completion only\n- **AND** skip spec and design checks\n- **AND** note which checks were skipped\n\n#### Scenario: Change with specs but no design\n- **WHEN** change has tasks.md and delta specs but no design.md\n- **THEN** verify completeness and correctness\n- **AND** skip design adherence\n- **AND** still check code coherence against project patterns\n\n#### Scenario: Full change (all artifacts)\n- **WHEN** change has proposal, design, specs, and tasks\n- **THEN** perform all verification checks\n- **AND** cross-reference artifacts for consistency\n"
  },
  {
    "path": "openspec/specs/rules-injection/spec.md",
    "content": "# rules-injection Specification\n\n## Purpose\nDefine how per-artifact rules from project config are injected into generated instructions with deterministic formatting and validation.\n\n## Requirements\n### Requirement: Inject rules only for matching artifact\n\nThe system SHALL inject rules from config into instructions only when the artifact ID matches a key in the rules object.\n\n#### Scenario: Rules exist for the artifact\n- **WHEN** loading instructions for \"proposal\" and config has `rules: { proposal: [\"Rule 1\", \"Rule 2\"] }`\n- **THEN** instruction output includes rules section with both rules\n\n#### Scenario: No rules for the artifact\n- **WHEN** loading instructions for \"design\" and config has `rules: { proposal: [...] }`\n- **THEN** instruction output does not include `<rules>` tags\n\n#### Scenario: Rules object is undefined\n- **WHEN** config omits the rules field or rules is undefined\n- **THEN** instruction output does not include `<rules>` tags for any artifact\n\n#### Scenario: Rules array is empty for artifact\n- **WHEN** config has `rules: { proposal: [] }`\n- **THEN** instruction output does not include `<rules>` tags\n\n### Requirement: Format rules with XML-style tags and bullet list\n\nThe system SHALL wrap rules in `<rules>` tags with each rule as a bulleted list item.\n\n#### Scenario: Single rule for artifact\n- **WHEN** config has `rules: { proposal: [\"Include rollback plan\"] }`\n- **THEN** instruction output includes `<rules>\\n- Include rollback plan\\n</rules>\\n\\n`\n\n#### Scenario: Multiple rules for artifact\n- **WHEN** config has `rules: { proposal: [\"Rule 1\", \"Rule 2\", \"Rule 3\"] }`\n- **THEN** instruction output includes each rule as separate bullet point\n\n#### Scenario: Rules appear after context and before template\n- **WHEN** instructions are generated with both context and rules\n- **THEN** order is `<context>` then `<rules>` then `<template>`\n\n### Requirement: Preserve rule text exactly as provided\n\nThe system SHALL inject rule text without modification, escaping, or interpretation.\n\n#### Scenario: Rule contains markdown\n- **WHEN** rule includes markdown like \"Use **Given/When/Then** format\"\n- **THEN** markdown is preserved in the injected content\n\n#### Scenario: Rule contains special characters\n- **WHEN** rule includes characters like `<`, `>`, quotes\n- **THEN** characters are preserved exactly as written\n\n#### Scenario: Rule is multi-line string\n- **WHEN** rule text contains line breaks\n- **THEN** line breaks are preserved within the bullet point\n\n### Requirement: Support multiple artifacts with different rules\n\nThe system SHALL allow different rule sets for different artifacts in the same config.\n\n#### Scenario: Multiple artifacts have rules\n- **WHEN** config has `rules: { proposal: [\"P1\"], specs: [\"S1\", \"S2\"], tasks: [\"T1\"] }`\n- **THEN** proposal instructions show only [\"P1\"], specs show only [\"S1\", \"S2\"], tasks show only [\"T1\"]\n\n#### Scenario: Some artifacts have rules, others do not\n- **WHEN** config has rules for proposal and specs only\n- **THEN** design and tasks instructions have no `<rules>` section\n\n### Requirement: Rules are additive to schema guidance\n\nThe system SHALL add config rules to the schema's built-in artifact instruction, not replace it.\n\n#### Scenario: Artifact has schema instruction and config rules\n- **WHEN** artifact has built-in instruction from schema and config provides rules\n- **THEN** final instruction contains both schema guidance and config rules\n\n#### Scenario: Rules provide additional constraints\n- **WHEN** schema says \"create proposal\" and config rules say \"include rollback plan\"\n- **THEN** agent sees both the schema template and the additional rule\n\n### Requirement: Validate artifact IDs during instruction loading\n\nThe system SHALL validate artifact IDs in rules against the schema when instructions are loaded and emit warnings for unknown IDs.\n\n#### Scenario: All artifact IDs are valid\n- **WHEN** instructions loaded and config has `rules: { proposal: [...], specs: [...] }` for schema with those artifacts\n- **THEN** no validation warnings are emitted\n\n#### Scenario: Unknown artifact ID in rules\n- **WHEN** instructions loaded and config has `rules: { unknownartifact: [...] }`\n- **THEN** warning emitted: \"Unknown artifact ID in rules: 'unknownartifact'. Valid IDs for schema 'spec-driven': design, proposal, specs, tasks\"\n\n#### Scenario: Multiple unknown artifact IDs\n- **WHEN** instructions loaded and config has multiple unknown artifact IDs\n- **THEN** separate warning emitted for each unknown artifact ID\n\n#### Scenario: Validation warnings shown once per session\n- **WHEN** instructions loaded multiple times in same CLI session\n- **THEN** each unique validation warning is shown only once (cached)\n\n"
  },
  {
    "path": "openspec/specs/schema-fork-command/spec.md",
    "content": "# schema-fork-command Specification\n\n## Purpose\nDefine `openspec schema fork` behavior for cloning existing schemas into project-local schemas with safe overwrite controls.\n\n## Requirements\n### Requirement: Schema fork copies existing schema\nThe CLI SHALL provide an `openspec schema fork <source> [name]` command that copies an existing schema to the project's `openspec/schemas/` directory.\n\n#### Scenario: Fork with explicit name\n- **WHEN** user runs `openspec schema fork spec-driven my-custom`\n- **THEN** system locates `spec-driven` schema using resolution order (project → user → package)\n- **AND** copies all files to `openspec/schemas/my-custom/`\n- **AND** updates `name` field in `schema.yaml` to `my-custom`\n- **AND** displays success message with source and destination paths\n\n#### Scenario: Fork with default name\n- **WHEN** user runs `openspec schema fork spec-driven` without specifying a name\n- **THEN** system copies to `openspec/schemas/spec-driven-custom/`\n- **AND** updates `name` field in `schema.yaml` to `spec-driven-custom`\n\n#### Scenario: Source schema not found\n- **WHEN** user runs `openspec schema fork nonexistent`\n- **THEN** system displays error that schema was not found\n- **AND** lists available schemas\n- **AND** exits with non-zero code\n\n### Requirement: Schema fork prevents accidental overwrites\nThe CLI SHALL require confirmation or `--force` flag when the destination schema already exists.\n\n#### Scenario: Destination exists without force\n- **WHEN** user runs `openspec schema fork spec-driven my-custom` and `openspec/schemas/my-custom/` exists\n- **THEN** system displays error that destination already exists\n- **AND** suggests using `--force` to overwrite\n- **AND** exits with non-zero code\n\n#### Scenario: Destination exists with force flag\n- **WHEN** user runs `openspec schema fork spec-driven my-custom --force` and destination exists\n- **THEN** system removes existing destination directory\n- **AND** copies source schema to destination\n- **AND** displays success message\n\n#### Scenario: Interactive confirmation for overwrite\n- **WHEN** user runs `openspec schema fork spec-driven my-custom` in interactive mode and destination exists\n- **THEN** system prompts for confirmation to overwrite\n- **AND** proceeds based on user response\n\n### Requirement: Schema fork preserves all schema files\nThe CLI SHALL copy the complete schema directory including templates, configuration, and any additional files.\n\n#### Scenario: Copy includes template files\n- **WHEN** user forks a schema with template files (e.g., `proposal.md`, `design.md`)\n- **THEN** all template files are copied to the destination\n- **AND** template file contents are unchanged\n\n#### Scenario: Copy includes nested directories\n- **WHEN** user forks a schema with nested directories (e.g., `templates/specs/`)\n- **THEN** nested directory structure is preserved\n- **AND** all nested files are copied\n\n### Requirement: Schema fork outputs JSON format\nThe CLI SHALL support `--json` flag for machine-readable output.\n\n#### Scenario: JSON output on success\n- **WHEN** user runs `openspec schema fork spec-driven my-custom --json`\n- **THEN** system outputs JSON with `forked: true`, `source`, `destination`, and `sourcePath` fields\n\n#### Scenario: JSON output shows source location\n- **WHEN** user runs `openspec schema fork spec-driven --json`\n- **THEN** JSON output includes `sourceLocation` field indicating \"project\", \"user\", or \"package\"\n\n"
  },
  {
    "path": "openspec/specs/schema-init-command/spec.md",
    "content": "# schema-init-command Specification\n\n## Purpose\nDefine `openspec schema init` behavior for creating project-local schema skeletons in interactive and non-interactive modes.\n\n## Requirements\n### Requirement: Schema init command creates project-local schema\nThe 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.\n\n#### Scenario: Create schema with valid name\n- **WHEN** user runs `openspec schema init my-workflow`\n- **THEN** system creates directory `openspec/schemas/my-workflow/`\n- **AND** creates `schema.yaml` with name, version, description, and artifacts array\n- **AND** creates template files referenced by artifacts\n- **AND** displays success message with created path\n\n#### Scenario: Reject invalid schema name\n- **WHEN** user runs `openspec schema init \"My Workflow\"` (contains space)\n- **THEN** system displays error about invalid schema name\n- **AND** suggests using kebab-case format\n- **AND** exits with non-zero code\n\n#### Scenario: Schema name already exists\n- **WHEN** user runs `openspec schema init existing-schema` and `openspec/schemas/existing-schema/` already exists\n- **THEN** system displays error that schema already exists\n- **AND** suggests using `--force` to overwrite or `schema fork` to copy\n- **AND** exits with non-zero code\n\n### Requirement: Schema init supports interactive mode\nThe CLI SHALL prompt for schema configuration when run in an interactive terminal without explicit flags.\n\n#### Scenario: Interactive prompts for description\n- **WHEN** user runs `openspec schema init my-workflow` in an interactive terminal\n- **THEN** system prompts for schema description\n- **AND** uses provided description in generated `schema.yaml`\n\n#### Scenario: Interactive prompts for artifact selection\n- **WHEN** user runs `openspec schema init my-workflow` in an interactive terminal\n- **THEN** system displays multi-select prompt with common artifacts (proposal, specs, design, tasks)\n- **AND** each option includes a brief description\n- **AND** uses selected artifacts in generated `schema.yaml`\n\n#### Scenario: Non-interactive mode with flags\n- **WHEN** user runs `openspec schema init my-workflow --description \"My workflow\" --artifacts proposal,tasks`\n- **THEN** system creates schema without prompting\n- **AND** uses flag values for configuration\n\n### Requirement: Schema init supports setting project default\nThe CLI SHALL offer to set the newly created schema as the project default.\n\n#### Scenario: Set as default interactively\n- **WHEN** user runs `openspec schema init my-workflow` in interactive mode\n- **AND** user confirms setting as default\n- **THEN** system updates `openspec/config.yaml` with `defaultSchema: my-workflow`\n\n#### Scenario: Set as default via flag\n- **WHEN** user runs `openspec schema init my-workflow --default`\n- **THEN** system creates schema and updates `openspec/config.yaml` with `defaultSchema: my-workflow`\n\n#### Scenario: Skip setting default\n- **WHEN** user runs `openspec schema init my-workflow --no-default`\n- **THEN** system creates schema without modifying `openspec/config.yaml`\n\n### Requirement: Schema init outputs JSON format\nThe CLI SHALL support `--json` flag for machine-readable output.\n\n#### Scenario: JSON output on success\n- **WHEN** user runs `openspec schema init my-workflow --json --description \"Test\" --artifacts proposal`\n- **THEN** system outputs JSON with `created: true`, `path`, and `schema` fields\n- **AND** does not display interactive prompts or spinners\n\n#### Scenario: JSON output on error\n- **WHEN** user runs `openspec schema init \"invalid name\" --json`\n- **THEN** system outputs JSON with `error` field describing the issue\n- **AND** exits with non-zero code\n\n"
  },
  {
    "path": "openspec/specs/schema-resolution/spec.md",
    "content": "# schema-resolution Specification\n\n## Purpose\nDefine 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.\n\n## Requirements\n### Requirement: Project-local schema resolution\n\nThe system SHALL resolve schemas from the project-local directory (`./openspec/schemas/<name>/`) with highest priority when a `projectRoot` is provided.\n\n#### Scenario: Project-local schema takes precedence over user override\n- **WHEN** a schema named \"my-workflow\" exists at `./openspec/schemas/my-workflow/schema.yaml`\n- **AND** a schema named \"my-workflow\" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml`\n- **AND** `getSchemaDir(\"my-workflow\", projectRoot)` is called\n- **THEN** the system SHALL return the project-local path\n\n#### Scenario: Project-local schema takes precedence over package built-in\n- **WHEN** a schema named \"spec-driven\" exists at `./openspec/schemas/spec-driven/schema.yaml`\n- **AND** \"spec-driven\" is a package built-in schema\n- **AND** `getSchemaDir(\"spec-driven\", projectRoot)` is called\n- **THEN** the system SHALL return the project-local path\n\n#### Scenario: Falls back to user override when no project-local schema\n- **WHEN** no schema named \"my-workflow\" exists at `./openspec/schemas/my-workflow/`\n- **AND** a schema named \"my-workflow\" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml`\n- **AND** `getSchemaDir(\"my-workflow\", projectRoot)` is called\n- **THEN** the system SHALL return the user override path\n\n#### Scenario: Falls back to package built-in when no project-local or user schema\n- **WHEN** no schema named \"spec-driven\" exists at `./openspec/schemas/spec-driven/`\n- **AND** no schema named \"spec-driven\" exists at `~/.local/share/openspec/schemas/spec-driven/`\n- **AND** \"spec-driven\" is a package built-in schema\n- **AND** `getSchemaDir(\"spec-driven\", projectRoot)` is called\n- **THEN** the system SHALL return the package built-in path\n\n#### Scenario: Backward compatibility when projectRoot not provided\n- **WHEN** `getSchemaDir(\"my-workflow\")` is called without a `projectRoot` parameter\n- **THEN** the system SHALL only check user override and package built-in locations\n- **AND** the system SHALL NOT check project-local location\n\n### Requirement: Project schemas directory helper\n\nThe system SHALL provide a `getProjectSchemasDir(projectRoot)` function that returns the project-local schemas directory path.\n\n#### Scenario: Returns correct path\n- **WHEN** `getProjectSchemasDir(\"/path/to/project\")` is called\n- **THEN** the system SHALL return `/path/to/project/openspec/schemas`\n\n### Requirement: List schemas includes project-local\n\nThe system SHALL include project-local schemas when listing available schemas if `projectRoot` is provided.\n\n#### Scenario: Project-local schemas appear in list\n- **WHEN** a schema named \"team-flow\" exists at `./openspec/schemas/team-flow/schema.yaml`\n- **AND** `listSchemas(projectRoot)` is called\n- **THEN** the returned list SHALL include \"team-flow\"\n\n#### Scenario: Project-local schema shadows same-named user schema in list\n- **WHEN** a schema named \"custom\" exists at both project-local and user override locations\n- **AND** `listSchemas(projectRoot)` is called\n- **THEN** the returned list SHALL include \"custom\" exactly once\n\n#### Scenario: Backward compatibility for listSchemas\n- **WHEN** `listSchemas()` is called without a `projectRoot` parameter\n- **THEN** the system SHALL only include user override and package built-in schemas\n\n### Requirement: Schema info includes project source\n\nThe system SHALL indicate `source: 'project'` for project-local schemas in `listSchemasWithInfo()` results.\n\n#### Scenario: Project-local schema shows project source\n- **WHEN** a schema named \"team-flow\" exists at `./openspec/schemas/team-flow/schema.yaml`\n- **AND** `listSchemasWithInfo(projectRoot)` is called\n- **THEN** the schema info for \"team-flow\" SHALL have `source: 'project'`\n\n#### Scenario: User override schema shows user source\n- **WHEN** a schema named \"my-custom\" exists only at `~/.local/share/openspec/schemas/my-custom/`\n- **AND** `listSchemasWithInfo(projectRoot)` is called\n- **THEN** the schema info for \"my-custom\" SHALL have `source: 'user'`\n\n#### Scenario: Package built-in schema shows package source\n- **WHEN** \"spec-driven\" exists only as a package built-in\n- **AND** `listSchemasWithInfo(projectRoot)` is called\n- **THEN** the schema info for \"spec-driven\" SHALL have `source: 'package'`\n\n### Requirement: Schemas command shows source\n\nThe `openspec schemas` command SHALL display the source of each schema.\n\n#### Scenario: Display format includes source\n- **WHEN** user runs `openspec schemas`\n- **THEN** the output SHALL show each schema with its source label (project, user, or package)\n\n### Requirement: Use config schema as default for new changes\n\nThe system SHALL use the schema field from `openspec/config.yaml` as the default when creating new changes without explicit `--schema` flag.\n\n#### Scenario: Create change without --schema flag and config exists\n- **WHEN** user runs `openspec new change foo` and config contains `schema: \"tdd\"`\n- **THEN** system creates change with schema \"tdd\"\n\n#### Scenario: Create change without --schema flag and no config\n- **WHEN** user runs `openspec new change foo` and no config file exists\n- **THEN** system creates change with default schema \"spec-driven\"\n\n#### Scenario: Create change with explicit --schema flag\n- **WHEN** user runs `openspec new change foo --schema custom` and config contains `schema: \"tdd\"`\n- **THEN** system creates change with schema \"custom\" (CLI flag overrides config)\n\n### Requirement: Resolve schema with updated precedence order\n\nThe system SHALL resolve the schema for a change using the following precedence order: CLI flag, change metadata, project config, hardcoded default.\n\n#### Scenario: CLI flag is provided\n- **WHEN** user runs command with `--schema custom`\n- **THEN** system uses \"custom\" regardless of change metadata or config\n\n#### Scenario: Change metadata specifies schema\n- **WHEN** change has `.openspec.yaml` with `schema: bound` and config has `schema: tdd`\n- **THEN** system uses \"bound\" from change metadata\n\n#### Scenario: Only project config specifies schema\n- **WHEN** no CLI flag or change metadata, but config has `schema: tdd`\n- **THEN** system uses \"tdd\" from project config\n\n#### Scenario: No schema specified anywhere\n- **WHEN** no CLI flag, change metadata, or project config\n- **THEN** system uses hardcoded default \"spec-driven\"\n\n### Requirement: Support project-local schema names in config\n\nThe system SHALL allow the config schema field to reference project-local schemas defined in `openspec/schemas/`.\n\n#### Scenario: Config references project-local schema\n- **WHEN** config contains `schema: \"my-workflow\"` and `openspec/schemas/my-workflow/` exists\n- **THEN** system resolves to the project-local schema\n\n#### Scenario: Config references non-existent schema\n- **WHEN** config contains `schema: \"nonexistent\"` and that schema does not exist\n- **THEN** system shows error when attempting to load the schema with fuzzy match suggestions and list of all valid schemas\n\n### Requirement: Provide helpful error message for invalid schema\n\nThe system SHALL display schema error with fuzzy match suggestions, list of available schemas, and fix instructions.\n\n#### Scenario: Schema name with typo (close match)\n- **WHEN** config contains `schema: \"spce-driven\"` (typo)\n- **THEN** error message includes \"Did you mean: spec-driven (built-in)\" as suggestion\n\n#### Scenario: Schema name with no close matches\n- **WHEN** config contains `schema: \"completely-wrong\"`\n- **THEN** error message shows list of all available built-in and project-local schemas\n\n#### Scenario: Error message includes fix instructions\n- **WHEN** config references invalid schema\n- **THEN** error message includes \"Fix: Edit openspec/config.yaml and change 'schema: X' to a valid schema name\"\n\n#### Scenario: Error distinguishes built-in vs project-local schemas\n- **WHEN** error lists available schemas\n- **THEN** output clearly labels each as \"built-in\" or \"project-local\"\n\n### Requirement: Maintain backwards compatibility for existing changes\n\nThe system SHALL continue to work with existing changes that do not have project config.\n\n#### Scenario: Existing change without config\n- **WHEN** change was created before config feature and no config file exists\n- **THEN** system resolves schema using existing logic (change metadata or hardcoded default)\n\n#### Scenario: Existing change with config added later\n- **WHEN** config file is added to project with existing changes\n- **THEN** existing changes continue to use their bound schema from `.openspec.yaml`\n"
  },
  {
    "path": "openspec/specs/schema-validate-command/spec.md",
    "content": "# schema-validate-command Specification\n\n## Purpose\nDefine `openspec schema validate` behavior for validating schema syntax, structure, templates, and dependency graphs.\n\n## Requirements\n### Requirement: Schema validate checks schema structure\nThe CLI SHALL provide an `openspec schema validate [name]` command that validates schema configuration and reports errors.\n\n#### Scenario: Validate specific schema\n- **WHEN** user runs `openspec schema validate my-workflow`\n- **THEN** system locates schema using resolution order\n- **AND** validates `schema.yaml` against the schema Zod type\n- **AND** displays validation result (valid or list of errors)\n\n#### Scenario: Validate all project schemas\n- **WHEN** user runs `openspec schema validate` without a name\n- **THEN** system validates all schemas in `openspec/schemas/`\n- **AND** displays results for each schema\n- **AND** exits with non-zero code if any schema is invalid\n\n#### Scenario: Schema not found\n- **WHEN** user runs `openspec schema validate nonexistent`\n- **THEN** system displays error that schema was not found\n- **AND** exits with non-zero code\n\n### Requirement: Schema validate checks YAML syntax\nThe CLI SHALL report YAML parsing errors with line numbers when possible.\n\n#### Scenario: Invalid YAML syntax\n- **WHEN** user runs `openspec schema validate my-workflow` and `schema.yaml` has syntax errors\n- **THEN** system displays YAML parse error with line number\n- **AND** exits with non-zero code\n\n#### Scenario: Valid YAML but missing required fields\n- **WHEN** `schema.yaml` is valid YAML but missing `name` field\n- **THEN** system displays Zod validation error for missing required field\n- **AND** identifies the specific missing field\n\n### Requirement: Schema validate checks template existence\nThe CLI SHALL verify that all template files referenced by artifacts exist.\n\n#### Scenario: Missing template file\n- **WHEN** artifact references `template: proposal.md` but file doesn't exist in schema directory\n- **THEN** system reports error: \"Template file 'proposal.md' not found for artifact 'proposal'\"\n- **AND** exits with non-zero code\n\n#### Scenario: All templates exist\n- **WHEN** all artifact templates exist\n- **THEN** system reports that templates are valid\n- **AND** template existence is included in validation summary\n\n### Requirement: Schema validate checks dependency graph\nThe CLI SHALL verify that artifact dependencies form a valid directed acyclic graph.\n\n#### Scenario: Valid dependency graph\n- **WHEN** artifact dependencies form a valid DAG (e.g., tasks → specs → proposal)\n- **THEN** system reports dependency graph is valid\n\n#### Scenario: Circular dependency detected\n- **WHEN** artifact A requires B and artifact B requires A\n- **THEN** system reports circular dependency error\n- **AND** identifies the artifacts involved in the cycle\n- **AND** exits with non-zero code\n\n#### Scenario: Unknown dependency reference\n- **WHEN** artifact requires `nonexistent-artifact`\n- **THEN** system reports error: \"Artifact 'x' requires unknown artifact 'nonexistent-artifact'\"\n- **AND** exits with non-zero code\n\n### Requirement: Schema validate outputs JSON format\nThe CLI SHALL support `--json` flag for machine-readable validation results.\n\n#### Scenario: JSON output for valid schema\n- **WHEN** user runs `openspec schema validate my-workflow --json` and schema is valid\n- **THEN** system outputs JSON with `valid: true`, `name`, and `path` fields\n\n#### Scenario: JSON output for invalid schema\n- **WHEN** user runs `openspec schema validate my-workflow --json` and schema has errors\n- **THEN** system outputs JSON with `valid: false` and `issues` array\n- **AND** each issue includes `level`, `path`, and `message` fields\n- **AND** format matches existing `openspec validate` output structure\n\n### Requirement: Schema validate supports verbose mode\nThe CLI SHALL support `--verbose` flag for detailed validation information.\n\n#### Scenario: Verbose output shows all checks\n- **WHEN** user runs `openspec schema validate my-workflow --verbose`\n- **THEN** system displays each validation check as it runs\n- **AND** shows pass/fail status for: YAML parsing, Zod validation, template existence, dependency graph\n\n"
  },
  {
    "path": "openspec/specs/schema-which-command/spec.md",
    "content": "# schema-which-command Specification\n\n## Purpose\nDefine `openspec schema which` behavior for reporting resolved schema source, location, and fallback details.\n\n## Requirements\n### Requirement: Schema which shows resolution result\nThe CLI SHALL provide an `openspec schema which <name>` command that displays where a schema resolves from.\n\n#### Scenario: Schema resolves from project\n- **WHEN** user runs `openspec schema which my-workflow` and schema exists in `openspec/schemas/my-workflow/`\n- **THEN** system displays source as \"project\"\n- **AND** displays full path to schema directory\n\n#### Scenario: Schema resolves from user directory\n- **WHEN** user runs `openspec schema which my-workflow` and schema exists only in user data directory\n- **THEN** system displays source as \"user\"\n- **AND** displays full path including XDG data directory\n\n#### Scenario: Schema resolves from package\n- **WHEN** user runs `openspec schema which spec-driven` and no override exists\n- **THEN** system displays source as \"package\"\n- **AND** displays full path to package's schemas directory\n\n#### Scenario: Schema not found\n- **WHEN** user runs `openspec schema which nonexistent`\n- **THEN** system displays error that schema was not found\n- **AND** lists available schemas\n- **AND** exits with non-zero code\n\n### Requirement: Schema which shows shadowing information\nThe CLI SHALL indicate when a schema shadows another schema at a lower priority level.\n\n#### Scenario: Project schema shadows package\n- **WHEN** user runs `openspec schema which spec-driven` and both project and package have `spec-driven`\n- **THEN** system displays that project schema is active\n- **AND** indicates it shadows the package version\n- **AND** shows path to shadowed package schema\n\n#### Scenario: No shadowing\n- **WHEN** schema exists only in one location\n- **THEN** system does not display shadowing information\n\n#### Scenario: Multiple shadows\n- **WHEN** project schema shadows both user and package schemas\n- **THEN** system lists all shadowed locations in priority order\n\n### Requirement: Schema which outputs JSON format\nThe CLI SHALL support `--json` flag for machine-readable output.\n\n#### Scenario: JSON output basic\n- **WHEN** user runs `openspec schema which spec-driven --json`\n- **THEN** system outputs JSON with `name`, `source`, and `path` fields\n\n#### Scenario: JSON output with shadows\n- **WHEN** user runs `openspec schema which spec-driven --json` and schema has shadows\n- **THEN** JSON includes `shadows` array with `source` and `path` for each shadowed schema\n\n### Requirement: Schema which supports list mode\nThe CLI SHALL support listing all schemas with their resolution sources.\n\n#### Scenario: List all schemas\n- **WHEN** user runs `openspec schema which --all`\n- **THEN** system displays all available schemas grouped by source\n- **AND** indicates which schemas shadow others\n\n#### Scenario: List in JSON format\n- **WHEN** user runs `openspec schema which --all --json`\n- **THEN** system outputs JSON array with resolution info for each schema\n\n"
  },
  {
    "path": "openspec/specs/specs-sync-skill/spec.md",
    "content": "# specs-sync-skill Specification\n\n## Purpose\nDefines the agent skill for syncing delta specs from changes to main specs.\n\n## Requirements\n\n### Requirement: Specs Sync Skill\nThe system SHALL provide an `/opsx:sync` skill that syncs delta specs from a change to the main specs.\n\n#### Scenario: Sync delta specs to main specs\n- **WHEN** agent executes `/opsx:sync` with a change name\n- **THEN** the agent reads delta specs from `openspec/changes/<name>/specs/`\n- **AND** reads corresponding main specs from `openspec/specs/`\n- **AND** reconciles main specs to match what the deltas describe\n\n#### Scenario: Idempotent operation\n- **WHEN** agent executes `/opsx:sync` multiple times on the same change\n- **THEN** the result is the same as running it once\n- **AND** no duplicate requirements are created\n\n#### Scenario: Change selection prompt\n- **WHEN** agent executes `/opsx:sync` without specifying a change\n- **THEN** the agent prompts user to select from available changes\n- **AND** shows changes that have delta specs\n\n### Requirement: Delta Reconciliation Logic\nThe agent SHALL reconcile main specs with delta specs using the delta operation headers.\n\n#### Scenario: ADDED requirements\n- **WHEN** delta contains `## ADDED Requirements` with a requirement\n- **AND** the requirement does not exist in main spec\n- **THEN** add the requirement to main spec\n\n#### Scenario: ADDED requirement already exists\n- **WHEN** delta contains `## ADDED Requirements` with a requirement\n- **AND** a requirement with the same name already exists in main spec\n- **THEN** update the existing requirement to match the delta version\n\n#### Scenario: MODIFIED requirements\n- **WHEN** delta contains `## MODIFIED Requirements` with a requirement\n- **AND** the requirement exists in main spec\n- **THEN** replace the requirement in main spec with the delta version\n\n#### Scenario: REMOVED requirements\n- **WHEN** delta contains `## REMOVED Requirements` with a requirement name\n- **AND** the requirement exists in main spec\n- **THEN** remove the requirement from main spec\n\n#### Scenario: RENAMED requirements\n- **WHEN** delta contains `## RENAMED Requirements` with FROM:/TO: format\n- **AND** the FROM requirement exists in main spec\n- **THEN** rename the requirement to the TO name\n\n#### Scenario: New capability spec\n- **WHEN** delta spec exists for a capability not in main specs\n- **THEN** create new main spec file at `openspec/specs/<capability>/spec.md`\n\n### Requirement: Skill Output\nThe skill SHALL provide clear feedback on what was applied.\n\n#### Scenario: Show applied changes\n- **WHEN** reconciliation completes successfully\n- **THEN** display summary of changes per capability:\n  - Number of requirements added\n  - Number of requirements modified\n  - Number of requirements removed\n  - Number of requirements renamed\n\n#### Scenario: No changes needed\n- **WHEN** main specs already match delta specs\n- **THEN** display \"Specs already in sync - no changes needed\"\n"
  },
  {
    "path": "openspec/specs/telemetry/spec.md",
    "content": "# telemetry Specification\n\n## Purpose\n\nThis spec defines how OpenSpec collects anonymous usage telemetry to help improve the tool. It governs the `src/telemetry/` module, which handles PostHog integration, privacy-preserving event design, user opt-out mechanisms, and first-run notice display. The spec ensures telemetry is minimal, transparent, and respects user privacy.\n\n## Requirements\n\n### Requirement: Command execution tracking\nThe system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.\n\n#### Scenario: Standard command execution\n- **WHEN** a user runs any openspec command\n- **THEN** the system sends a `command_executed` event with `command` and `version` properties\n\n#### Scenario: Subcommand execution\n- **WHEN** a user runs a nested command like `openspec change apply`\n- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)\n\n### Requirement: Privacy-preserving event design\nThe system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.\n\n#### Scenario: Command with arguments\n- **WHEN** a user runs `openspec init my-project --force`\n- **THEN** the telemetry event contains only `command: \"init\"` and `version: \"<version>\"` without arguments\n\n#### Scenario: IP address exclusion\n- **WHEN** the system sends a telemetry event\n- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking\n\n### Requirement: Environment variable opt-out\nThe system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.\n\n#### Scenario: OPENSPEC_TELEMETRY opt-out\n- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment\n- **THEN** the system sends no telemetry events\n\n#### Scenario: DO_NOT_TRACK opt-out\n- **WHEN** `DO_NOT_TRACK=1` is set in the environment\n- **THEN** the system sends no telemetry events\n\n#### Scenario: Environment variable takes precedence\n- **WHEN** the user has previously used the CLI (config exists)\n- **AND** the user sets `OPENSPEC_TELEMETRY=0`\n- **THEN** telemetry is disabled regardless of config state\n\n### Requirement: CI environment auto-disable\nThe system SHALL automatically disable telemetry when `CI=true` environment variable is detected.\n\n#### Scenario: CI environment detection\n- **WHEN** `CI=true` is set in the environment\n- **THEN** the system sends no telemetry events\n\n#### Scenario: CI with explicit enable\n- **WHEN** `CI=true` is set\n- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set\n- **THEN** telemetry remains disabled (CI takes precedence for privacy)\n\n### Requirement: First-run telemetry notice\nThe system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.\n\n#### Scenario: First command execution\n- **WHEN** a user runs their first openspec command\n- **AND** telemetry is enabled\n- **THEN** the system displays: \"Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0\"\n\n#### Scenario: Subsequent command execution\n- **WHEN** a user has already seen the notice (noticeSeen: true in config)\n- **THEN** the system does not display the notice\n\n#### Scenario: Notice before telemetry\n- **WHEN** displaying the first-run notice\n- **THEN** the notice appears before any telemetry event is sent\n\n### Requirement: Anonymous user identification\nThe system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.\n\n#### Scenario: First telemetry event\n- **WHEN** the first telemetry event is sent\n- **AND** no anonymousId exists in config\n- **THEN** the system generates a random UUID v4 and stores it in config\n\n#### Scenario: Persistent identity\n- **WHEN** a user runs multiple commands across sessions\n- **THEN** the same anonymousId is used for all events\n\n#### Scenario: Lazy generation with opt-out\n- **WHEN** a user opts out before running any command\n- **THEN** no anonymousId is ever generated or stored\n\n### Requirement: Immediate event sending\nThe system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.\n\n#### Scenario: Event transmission timing\n- **WHEN** a command executes\n- **THEN** the telemetry event is sent immediately, not queued for batch transmission\n\n### Requirement: Graceful shutdown\nThe system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.\n\n#### Scenario: Normal exit\n- **WHEN** a command completes successfully\n- **THEN** the system awaits `shutdown()` before exiting\n\n#### Scenario: Error exit\n- **WHEN** a command fails with an error\n- **THEN** the system still awaits `shutdown()` before exiting\n\n### Requirement: Silent failure handling\nThe system SHALL silently ignore telemetry failures without affecting CLI functionality.\n\n#### Scenario: Network failure\n- **WHEN** the telemetry request fails due to network error\n- **THEN** the CLI command completes normally without error message\n\n#### Scenario: PostHog outage\n- **WHEN** PostHog service is unavailable\n- **THEN** the CLI command completes normally without error message\n\n#### Scenario: Shutdown failure\n- **WHEN** `shutdown()` fails or times out\n- **THEN** the CLI exits normally without error message\n"
  },
  {
    "path": "openspec-parallel-merge-plan.md",
    "content": "# OpenSpec Parallel Delta Remediation Plan\n\n## Problem Summary\n- Active changes apply requirement-level replacements when archiving. When two changes touch the same requirement, the second archive overwrites the first and silently drops scenarios (e.g., Windsurf vs. Kilo Code slash command updates).\n- The archive workflow (`src/core/archive.ts:191` and `src/core/archive.ts:501`) rebuilds main specs by replacing entire requirement blocks with the content contained in the change delta. The delta format (`src/core/parsers/requirement-blocks.ts:113`) has no notion of base versions or scenario-level operations.\n- The tooling cannot detect divergence between the change author’s starting point and the live spec, so parallel development corrupts the source of truth without warning.\n\n## Observed Failure Mode\n- Change A (`add-windsurf-workflows`) adds a Windsurf scenario under `Slash Command Configuration`.\n- Change B (`add-kilocode-workflows`) adds a Kilo Code scenario to the same requirement, starting from the pre-Windsurf spec.\n- After Change A archives, the main spec contains both scenarios.\n- When Change B archives, `buildUpdatedSpec` sees a `MODIFIED` block for `Slash Command Configuration` and replaces the requirement with the four-scenario variant shipped in that change. Because that file never learned about Windsurf, the Windsurf scenario disappears.\n- There is no warning, diff, or conflict indicator—the archive completes successfully, and the source-of-truth spec now omits a shipped scenario.\n\n## Root Causes\n1. **Replace-only semantics.** `buildUpdatedSpec` performs hash-map substitution of requirement blocks and cannot merge or compare individual scenarios (`src/core/archive.ts:455`-`src/core/archive.ts:526`).\n2. **Missing base fingerprint.** Changes do not persist the requirement content they were authored against, so the archive step cannot tell if the live spec diverged.\n3. **Single-level granularity.** The delta language only understands requirements. Even if we introduced scenario-level parsing, we would still lose sibling edits without an accompanying merge strategy.\n4. **Lack of conflict UX.** The CLI never forces contributors to reconcile parallel updates. There is no equivalent of `git merge`, `git rebase`, or conflict markers.\n\n## Design Objectives\n- Preserve every approved scenario regardless of archive order.\n- Detect and block speculative archives when the live spec diverges from the author’s base.\n- Provide a deterministic, reviewable conflict resolution flow that mirrors source-control best practices.\n- Keep the authoring experience ergonomic: deltas should remain human-editable markdown.\n- Support incremental adoption so existing repositories can roll forward without breaking active work.\n\n## Proposed Fix: Layered Remediation\n\n### Phase 0 – Stop the Bleeding (Detection & Guardrails)\n1. **Persist requirement fingerprints alongside each change.**\n   - When scaffolding or validating a change, capture the current requirement body for every `MODIFIED`/`REMOVED`/`RENAMED` entry and write it to `changes/<id>/meta.json`.\n   - Store a stable hash (e.g., SHA-256) of the base requirement content and the raw text itself for later merges.\n2. **Validate fingerprints during archive.**\n   - Before `buildUpdatedSpec` mutates specs, recompute the requirement hash from the live spec.\n   - If the hash differs from the stored base, abort and instruct the user to rebase. This makes the destructive path impossible.\n3. **Surface intent in CLI output.**\n   - Show which requirements are stale, when they diverged, and which change last touched them.\n4. **Document interim manual mitigation.**\n   - Update `openspec/AGENTS.md` and docs so contributors know to rerun `openspec change sync` (see Phase 1) whenever another change lands.\n\n_Outcome:_ We prevent data loss immediately while we work on a richer merge story.\n\n### Phase 1 – Add a Rebase Workflow (Author-Side Merge)\n1. **Introduce `openspec change sync <id>` (or `rebase`).**\n   - Reads the stored base snapshot, the current spec, and the author’s delta.\n   - Performs a 3-way merge per requirement. A naive diff3 on markdown lines is acceptable initially because we already operate on requirement-sized chunks.\n   - If the merge is clean, rewrite the `MODIFIED` block with the merged text and refresh the stored fingerprint.\n   - On conflict, write conflict markers inside the change delta (similar to Git) and require the author to hand-edit before re-running validation.\n2. **Enrich validator messages.**\n   - `openspec validate` should flag unresolved conflict markers or fingerprint mismatches so errors appear early in the workflow.\n3. **Optional:** Offer a `--rewrite-scenarios` helper that merges bullet lists of scenarios to reduce manual editing noise.\n\n_Outcome:_ Contributors can safely reconcile their work with the latest spec before archiving, restoring true parallel development.\n\n### Phase 2 – Increase Delta Granularity\n1. **Extend the delta language with scenario-level directives.**\n   - Allow `## MODIFIED Requirements` + `## ADDED Scenarios` / `## MODIFIED Scenarios` sections nested under the requirement header.\n   - Backed by stable scenario identifiers (explicit IDs or generated hashes) stored in `meta.json`. This lets the system reason about individual scenarios.\n2. **Teach the parser to understand nested operations.**\n   - Update `parseDeltaSpec` to emit scenario-level operations in addition to requirement blocks.\n   - Update `buildUpdatedSpec` (or its replacement) to merge scenario lists, preserving order while inserting new entries in a deterministic fashion.\n3. **Automate migration.**\n   - Provide a one-time command that inspects each existing spec, injects scenario IDs, and rewrites in-flight change deltas into the richer format.\n4. **Continue to rely on the Phase 1 rebase flow for conflicts when two changes edit the same scenario body or description.**\n\n_Outcome:_ Most concurrent updates become commutative, drastically reducing the odds of human merges.\n\n### Phase 3 – Structured Spec Graph (Long-Term)\n1. **Define stable requirement IDs.**\n   - Embed `Requirement ID: <uuid>` markers in specs so renames and moves are trackable.\n   - This enables future features like cross-capability references and better diff visualizations.\n2. **Model spec edits as operations over an AST.**\n   - Build an intermediate representation (IR) for requirements/scenarios/metadata.\n   - Use operational transforms or CRDT-like techniques to guarantee merge associativity.\n3. **Integrate with Git directly.**\n   - Offer optional `openspec branch` scaffolding that aligns spec changes with Git branches, letting teams leverage Git’s conflict editor for the markdown IR.\n\n_Outcome:_ OpenSpec graduates from replace-based updates to a resilient, intent-preserving spec management platform.\n\n## Migration & Product Impacts\n- **Backfill metadata:** add hashes for all active changes and the current main specs during the initial rollout.\n- **CLI UX:** new commands (`change sync`, enhanced `archive`) require documentation, help text, and release notes.\n- **Docs & AGENTS updates:** reinforce the rebase workflow and explain conflict resolution to AI assistants.\n- **Testing:** introduce fixtures covering divergent requirement fingerprints and merge resolution logic.\n- **Telemetry (optional):** log fingerprint mismatches so we can see how often teams hit conflicts after the rollout.\n\n## Open Questions / Risks\n- How should we order scenarios when multiple changes insert at different points? (Consider optional `position` metadata or deterministic alphabetical fallbacks.)\n- What is the graceful failure mode if contributors delete the `meta.json` file? (CLI should recreate fingerprints on demand.)\n- Do we need to support offline authors who cannot easily re-run the sync command before archiving? (Potential `--accept-outdated` escape hatch for emergencies.)\n- How will archived historical changes be handled? We may need a migration script to embed fingerprints retroactively so re-validation succeeds.\n\n## Immediate Next Steps\n1. Prototype fingerprint capture during `openspec change validate` and block archive on mismatches.\n2. Ship `openspec change sync` with line-based diff3 merging and conflict markers.\n3. Update contributor docs and AI instructions to mandate running `sync` before archiving.\n4. Plan the scenario-level delta extension and migration path as a follow-up RFC.\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"@fission-ai/openspec\",\n  \"version\": \"1.2.0\",\n  \"description\": \"AI-native system for spec-driven development\",\n  \"keywords\": [\n    \"openspec\",\n    \"specs\",\n    \"cli\",\n    \"ai\",\n    \"development\"\n  ],\n  \"homepage\": \"https://github.com/Fission-AI/OpenSpec\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/Fission-AI/OpenSpec\"\n  },\n  \"license\": \"MIT\",\n  \"author\": \"OpenSpec Contributors\",\n  \"type\": \"module\",\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/index.d.ts\",\n      \"default\": \"./dist/index.js\"\n    }\n  },\n  \"bin\": {\n    \"openspec\": \"./bin/openspec.js\"\n  },\n  \"files\": [\n    \"dist\",\n    \"bin\",\n    \"schemas\",\n    \"scripts/postinstall.js\",\n    \"!dist/**/*.test.js\",\n    \"!dist/**/__tests__\",\n    \"!dist/**/*.map\"\n  ],\n  \"scripts\": {\n    \"lint\": \"eslint src/\",\n    \"build\": \"node build.js\",\n    \"dev\": \"tsc --watch\",\n    \"dev:cli\": \"pnpm build && node bin/openspec.js\",\n    \"test\": \"vitest run\",\n    \"test:watch\": \"vitest\",\n    \"test:ui\": \"vitest --ui\",\n    \"test:coverage\": \"vitest --coverage\",\n    \"test:postinstall\": \"node scripts/postinstall.js\",\n    \"prepare\": \"pnpm run build\",\n    \"prepublishOnly\": \"pnpm run build\",\n    \"postinstall\": \"node scripts/postinstall.js\",\n    \"check:pack-version\": \"node scripts/pack-version-check.mjs\",\n    \"release\": \"pnpm run release:ci\",\n    \"release:ci\": \"pnpm run check:pack-version && pnpm exec changeset publish\",\n    \"changeset\": \"changeset\"\n  },\n  \"engines\": {\n    \"node\": \">=20.19.0\"\n  },\n  \"devDependencies\": {\n    \"@changesets/changelog-github\": \"^0.5.2\",\n    \"@changesets/cli\": \"^2.27.7\",\n    \"@types/node\": \"^24.2.0\",\n    \"@vitest/ui\": \"^3.2.4\",\n    \"eslint\": \"^9.39.2\",\n    \"typescript\": \"^5.9.3\",\n    \"typescript-eslint\": \"^8.50.1\",\n    \"vitest\": \"^3.2.4\"\n  },\n  \"dependencies\": {\n    \"@inquirer/core\": \"^10.2.2\",\n    \"@inquirer/prompts\": \"^7.8.0\",\n    \"chalk\": \"^5.5.0\",\n    \"commander\": \"^14.0.0\",\n    \"fast-glob\": \"^3.3.3\",\n    \"ora\": \"^8.2.0\",\n    \"posthog-node\": \"^5.20.0\",\n    \"yaml\": \"^2.8.2\",\n    \"zod\": \"^4.0.17\"\n  }\n}\n"
  },
  {
    "path": "schemas/spec-driven/schema.yaml",
    "content": "name: spec-driven\nversion: 1\ndescription: Default OpenSpec workflow - proposal → specs → design → tasks\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    description: Initial proposal document outlining the change\n    template: proposal.md\n    instruction: |\n      Create the proposal document that establishes WHY this change is needed.\n\n      Sections:\n      - **Why**: 1-2 sentences on the problem or opportunity. What problem does this solve? Why now?\n      - **What Changes**: Bullet list of changes. Be specific about new capabilities, modifications, or removals. Mark breaking changes with **BREAKING**.\n      - **Capabilities**: Identify which specs will be created or modified:\n        - **New Capabilities**: List capabilities being introduced. Each becomes a new `specs/<name>/spec.md`. Use kebab-case names (e.g., `user-auth`, `data-export`).\n        - **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.\n      - **Impact**: Affected code, APIs, dependencies, or systems.\n\n      IMPORTANT: The Capabilities section is critical. It creates the contract between\n      proposal and specs phases. Research existing specs before filling this in.\n      Each capability listed here will need a corresponding spec file.\n\n      Keep it concise (1-2 pages). Focus on the \"why\" not the \"how\" -\n      implementation details belong in design.md.\n\n      This is the foundation - specs, design, and tasks all build on this.\n    requires: []\n\n  - id: specs\n    generates: \"specs/**/*.md\"\n    description: Detailed specifications for the change\n    template: spec.md\n    instruction: |\n      Create specification files that define WHAT the system should do.\n\n      Create one spec file per capability listed in the proposal's Capabilities section.\n      - New capabilities: use the exact kebab-case name from the proposal (specs/<capability>/spec.md).\n      - Modified capabilities: use the existing spec folder name from openspec/specs/<capability>/ when creating the delta spec at specs/<capability>/spec.md.\n\n      Delta operations (use ## headers):\n      - **ADDED Requirements**: New capabilities\n      - **MODIFIED Requirements**: Changed behavior - MUST include full updated content\n      - **REMOVED Requirements**: Deprecated features - MUST include **Reason** and **Migration**\n      - **RENAMED Requirements**: Name changes only - use FROM:/TO: format\n\n      Format requirements:\n      - Each requirement: `### Requirement: <name>` followed by description\n      - Use SHALL/MUST for normative requirements (avoid should/may)\n      - Each scenario: `#### Scenario: <name>` with WHEN/THEN format\n      - **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.\n      - Every requirement MUST have at least one scenario.\n\n      MODIFIED requirements workflow:\n      1. Locate the existing requirement in openspec/specs/<capability>/spec.md\n      2. Copy the ENTIRE requirement block (from `### Requirement:` through all scenarios)\n      3. Paste under `## MODIFIED Requirements` and edit to reflect new behavior\n      4. Ensure header text matches exactly (whitespace-insensitive)\n\n      Common pitfall: Using MODIFIED with partial content loses detail at archive time.\n      If adding new concerns without changing existing behavior, use ADDED instead.\n\n      Example:\n      ```\n      ## ADDED Requirements\n\n      ### Requirement: User can export data\n      The system SHALL allow users to export their data in CSV format.\n\n      #### Scenario: Successful export\n      - **WHEN** user clicks \"Export\" button\n      - **THEN** system downloads a CSV file with all user data\n\n      ## REMOVED Requirements\n\n      ### Requirement: Legacy export\n      **Reason**: Replaced by new export system\n      **Migration**: Use new export endpoint at /api/v2/export\n      ```\n\n      Specs should be testable - each scenario is a potential test case.\n    requires:\n      - proposal\n\n  - id: design\n    generates: design.md\n    description: Technical design document with implementation details\n    template: design.md\n    instruction: |\n      Create the design document that explains HOW to implement the change.\n\n      When to include design.md (create only if any apply):\n      - Cross-cutting change (multiple services/modules) or new architectural pattern\n      - New external dependency or significant data model changes\n      - Security, performance, or migration complexity\n      - Ambiguity that benefits from technical decisions before coding\n\n      Sections:\n      - **Context**: Background, current state, constraints, stakeholders\n      - **Goals / Non-Goals**: What this design achieves and explicitly excludes\n      - **Decisions**: Key technical choices with rationale (why X over Y?). Include alternatives considered for each decision.\n      - **Risks / Trade-offs**: Known limitations, things that could go wrong. Format: [Risk] → Mitigation\n      - **Migration Plan**: Steps to deploy, rollback strategy (if applicable)\n      - **Open Questions**: Outstanding decisions or unknowns to resolve\n\n      Focus on architecture and approach, not line-by-line implementation.\n      Reference the proposal for motivation and specs for requirements.\n\n      Good design docs explain the \"why\" behind technical decisions.\n    requires:\n      - proposal\n\n  - id: tasks\n    generates: tasks.md\n    description: Implementation checklist with trackable tasks\n    template: tasks.md\n    instruction: |\n      Create the task list that breaks down the implementation work.\n\n      **IMPORTANT: Follow the template below exactly.** The apply phase parses\n      checkbox format to track progress. Tasks not using `- [ ]` won't be tracked.\n\n      Guidelines:\n      - Group related tasks under ## numbered headings\n      - Each task MUST be a checkbox: `- [ ] X.Y Task description`\n      - Tasks should be small enough to complete in one session\n      - Order tasks by dependency (what must be done first?)\n\n      Example:\n      ```\n      ## 1. Setup\n\n      - [ ] 1.1 Create new module structure\n      - [ ] 1.2 Add dependencies to package.json\n\n      ## 2. Core Implementation\n\n      - [ ] 2.1 Implement data export function\n      - [ ] 2.2 Add CSV formatting utilities\n      ```\n\n      Reference specs for what needs to be built, design for how to build it.\n      Each task should be verifiable - you know when it's done.\n    requires:\n      - specs\n      - design\n\napply:\n  requires: [tasks]\n  tracks: tasks.md\n  instruction: |\n    Read context files, work through pending tasks, mark complete as you go.\n    Pause if you hit blockers or need clarification.\n"
  },
  {
    "path": "schemas/spec-driven/templates/design.md",
    "content": "## Context\n\n<!-- Background and current state -->\n\n## Goals / Non-Goals\n\n**Goals:**\n<!-- What this design aims to achieve -->\n\n**Non-Goals:**\n<!-- What is explicitly out of scope -->\n\n## Decisions\n\n<!-- Key design decisions and rationale -->\n\n## Risks / Trade-offs\n\n<!-- Known risks and trade-offs -->\n"
  },
  {
    "path": "schemas/spec-driven/templates/proposal.md",
    "content": "## Why\n\n<!-- Explain the motivation for this change. What problem does this solve? Why now? -->\n\n## What Changes\n\n<!-- Describe what will change. Be specific about new capabilities, modifications, or removals. -->\n\n## Capabilities\n\n### New Capabilities\n<!-- Capabilities being introduced. Replace <name> with kebab-case identifier (e.g., user-auth, data-export, api-rate-limiting). Each creates specs/<name>/spec.md -->\n- `<name>`: <brief description of what this capability covers>\n\n### Modified Capabilities\n<!-- Existing capabilities whose REQUIREMENTS are changing (not just implementation).\n     Only list here if spec-level behavior changes. Each needs a delta spec file.\n     Use existing spec names from openspec/specs/. Leave empty if no requirement changes. -->\n- `<existing-name>`: <what requirement is changing>\n\n## Impact\n\n<!-- Affected code, APIs, dependencies, systems -->\n"
  },
  {
    "path": "schemas/spec-driven/templates/spec.md",
    "content": "## ADDED Requirements\n\n### Requirement: <!-- requirement name -->\n<!-- requirement text -->\n\n#### Scenario: <!-- scenario name -->\n- **WHEN** <!-- condition -->\n- **THEN** <!-- expected outcome -->\n"
  },
  {
    "path": "schemas/spec-driven/templates/tasks.md",
    "content": "## 1. <!-- Task Group Name -->\n\n- [ ] 1.1 <!-- Task description -->\n- [ ] 1.2 <!-- Task description -->\n\n## 2. <!-- Task Group Name -->\n\n- [ ] 2.1 <!-- Task description -->\n- [ ] 2.2 <!-- Task description -->\n"
  },
  {
    "path": "scripts/README.md",
    "content": "# OpenSpec Scripts\n\nUtility scripts for OpenSpec maintenance and development.\n\n## update-flake.sh\n\nUpdates `flake.nix` pnpm dependency hash automatically.\n\n**When to use**: After updating dependencies (`pnpm install`, `pnpm update`).\n\n**Usage**:\n```bash\n./scripts/update-flake.sh\n```\n\n**What it does**:\n1. Reads version from `package.json` (dynamically used by `flake.nix`)\n2. Automatically determines the correct pnpm dependency hash\n3. Updates the hash in `flake.nix`\n4. Verifies the build succeeds\n\n**Example workflow**:\n```bash\n# After dependency updates\npnpm install\n./scripts/update-flake.sh\ngit add flake.nix\ngit commit -m \"chore: update flake.nix dependency hash\"\n```\n\n## postinstall.js\n\nPost-installation script that runs after package installation.\n\n## pack-version-check.mjs\n\nValidates package version consistency before publishing.\n"
  },
  {
    "path": "scripts/pack-version-check.mjs",
    "content": "#!/usr/bin/env node\n// Guard: Ensure the packed tarball's CLI `--version` matches package.json.\n//\n// Notes:\n// - We intentionally use `npm pack` (not pnpm) because `npm pack --json` is\n//   consistently supported and returns the tarball metadata we need. The\n//   project uses pnpm for install/publish, but this guard only needs to pack\n//   locally and verify the installed CLI output.\n// - `npm pack` triggers the package's `prepare` script (build), and\n//   `changeset publish` triggers `prepublishOnly` (also builds here). This\n//   means an explicit build is not strictly necessary for the guard.\n\nimport { execFileSync } from 'child_process';\nimport { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';\nimport { tmpdir } from 'os';\nimport path from 'path';\n\nfunction log(msg) {\n  if (process.env.CI) return; // keep CI logs quiet by default\n  console.log(msg);\n}\n\nfunction run(cmd, args, opts = {}) {\n  return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });\n}\n\nfunction npmPack() {\n  try {\n    const jsonOut = run('npm', ['pack', '--json', '--silent']);\n    const arr = JSON.parse(jsonOut);\n    if (Array.isArray(arr) && arr.length > 0) {\n      const last = arr[arr.length - 1];\n      const file = (last && typeof last === 'object' && last.filename) || (typeof last === 'string' ? last : null);\n      if (file) return String(file).trim();\n    }\n    // Unexpected JSON shape or empty array; fallback to plain output\n    const out = run('npm', ['pack', '--silent']).trim();\n    const lines = out.split(/\\r?\\n/);\n    return lines[lines.length - 1].trim();\n  } catch (e) {\n    // Fallback for environments not supporting --json\n    const out = run('npm', ['pack', '--silent']).trim();\n    const lines = out.split(/\\r?\\n/);\n    return lines[lines.length - 1].trim();\n  }\n}\n\nfunction main() {\n  const pkg = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));\n  const expected = pkg.version;\n\n  let work;\n  let tgzPath;\n\n  try {\n    log(`Packing @fission-ai/openspec@${expected}...`);\n    const filename = npmPack();\n    tgzPath = path.resolve(filename);\n    log(`Created: ${tgzPath}`);\n\n    work = mkdtempSync(path.join(tmpdir(), 'openspec-pack-check-'));\n    log(`Temp dir: ${work}`);\n\n    // Make a tiny project\n    writeFileSync(\n      path.join(work, 'package.json'),\n      JSON.stringify({ name: 'pack-check', private: true }, null, 2)\n    );\n\n    // Try to avoid noisy output and speed up\n    const env = {\n      ...process.env,\n      npm_config_loglevel: 'silent',\n      npm_config_audit: 'false',\n      npm_config_fund: 'false',\n      npm_config_progress: 'false',\n    };\n\n    // Install the tarball\n    run('npm', ['install', tgzPath, '--silent', '--no-audit', '--no-fund'], { cwd: work, env });\n\n    // Run the installed CLI via Node to avoid bin resolution/platform issues\n    const binRel = path.join('node_modules', '@fission-ai', 'openspec', 'bin', 'openspec.js');\n    const actual = run(process.execPath, [binRel, '--version'], { cwd: work }).trim();\n\n    if (actual !== expected) {\n      throw new Error(\n        `Packed CLI version mismatch: expected ${expected}, got ${actual}. ` +\n          'Ensure the dist is built and the CLI reads version from package.json.'\n      );\n    }\n\n    log('Version check passed.');\n  } finally {\n    // Always attempt cleanup\n    if (work) {\n      try { rmSync(work, { recursive: true, force: true }); } catch {}\n    }\n    if (tgzPath) {\n      try { rmSync(tgzPath, { force: true }); } catch {}\n    }\n  }\n}\n\ntry {\n  main();\n  console.log('✅ pack-version-check: OK');\n} catch (err) {\n  console.error(`❌ pack-version-check: ${err.message}`);\n  process.exit(1);\n}\n"
  },
  {
    "path": "scripts/postinstall.js",
    "content": "#!/usr/bin/env node\n\n/**\n * Postinstall script for auto-installing shell completions\n *\n * This script runs automatically after npm install unless:\n * - CI=true environment variable is set\n * - OPENSPEC_NO_COMPLETIONS=1 environment variable is set\n * - dist/ directory doesn't exist (dev setup scenario)\n *\n * The script never fails npm install - all errors are caught and handled gracefully.\n */\n\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n/**\n * Check if we should skip installation\n */\nfunction shouldSkipInstallation() {\n  // Skip in CI environments\n  if (process.env.CI === 'true' || process.env.CI === '1') {\n    return { skip: true, reason: 'CI environment detected' };\n  }\n\n  // Skip if user opted out\n  if (process.env.OPENSPEC_NO_COMPLETIONS === '1') {\n    return { skip: true, reason: 'OPENSPEC_NO_COMPLETIONS=1 set' };\n  }\n\n  return { skip: false };\n}\n\n/**\n * Check if dist/ directory exists\n */\nasync function distExists() {\n  const distPath = path.join(__dirname, '..', 'dist');\n  try {\n    const stat = await fs.stat(distPath);\n    return stat.isDirectory();\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Detect the user's shell\n */\nasync function detectShell() {\n  try {\n    const { detectShell } = await import('../dist/utils/shell-detection.js');\n    const result = detectShell();\n    return result.shell;\n  } catch (error) {\n    // Fail silently if detection module doesn't exist\n    return undefined;\n  }\n}\n\n/**\n * Install completions for the detected shell\n */\nasync function installCompletions(shell) {\n  try {\n    const { CompletionFactory } = await import('../dist/core/completions/factory.js');\n    const { COMMAND_REGISTRY } = await import('../dist/core/completions/command-registry.js');\n\n    // Check if shell is supported\n    if (!CompletionFactory.isSupported(shell)) {\n      console.log(`\\nTip: Run 'openspec completion install' for shell completions`);\n      return;\n    }\n\n    // Generate completion script\n    const generator = CompletionFactory.createGenerator(shell);\n    const script = generator.generate(COMMAND_REGISTRY);\n\n    // Install completion script\n    const installer = CompletionFactory.createInstaller(shell);\n    const result = await installer.install(script);\n\n    if (result.success) {\n      // Show success message based on installation type\n      if (result.isOhMyZsh) {\n        console.log(`✓ Shell completions installed`);\n        console.log(`  Restart shell: exec zsh`);\n      } else if (result.zshrcConfigured) {\n        console.log(`✓ Shell completions installed and configured`);\n        console.log(`  Restart shell: exec zsh`);\n      } else {\n        console.log(`✓ Shell completions installed to ~/.zsh/completions/`);\n        console.log(`  Add to ~/.zshrc: fpath=(~/.zsh/completions $fpath)`);\n        console.log(`  Then: exec zsh`);\n      }\n    } else {\n      // Installation failed, show tip for manual install\n      console.log(`\\nTip: Run 'openspec completion install' for shell completions`);\n    }\n  } catch (error) {\n    // Fail gracefully - show tip for manual install\n    console.log(`\\nTip: Run 'openspec completion install' for shell completions`);\n  }\n}\n\n/**\n * Main function\n */\nasync function main() {\n  try {\n    // Check if we should skip\n    const skipCheck = shouldSkipInstallation();\n    if (skipCheck.skip) {\n      // Silent skip - no output\n      return;\n    }\n\n    // Check if dist/ exists (skip silently if not - expected during dev setup)\n    if (!(await distExists())) {\n      return;\n    }\n\n    // Detect shell\n    const shell = await detectShell();\n    if (!shell) {\n      console.log(`\\nTip: Run 'openspec completion install' for shell completions`);\n      return;\n    }\n\n    // Install completions\n    await installCompletions(shell);\n  } catch (error) {\n    // Fail gracefully - never break npm install\n    // Show tip for manual install\n    console.log(`\\nTip: Run 'openspec completion install' for shell completions`);\n  }\n}\n\n// Run main and handle any unhandled errors\nmain().catch(() => {\n  // Silent failure - never break npm install\n  process.exit(0);\n});\n"
  },
  {
    "path": "scripts/test-postinstall.sh",
    "content": "#!/bin/bash\n\n# Test script for postinstall.js\n# Tests different scenarios: normal install, CI, opt-out\n\nset -e\n\necho \"======================================\"\necho \"Testing OpenSpec Postinstall Script\"\necho \"======================================\"\necho \"\"\n\n# Save original environment\nORIGINAL_CI=\"${CI:-}\"\nORIGINAL_OPENSPEC_NO_COMPLETIONS=\"${OPENSPEC_NO_COMPLETIONS:-}\"\n\n# Test 1: Normal install\necho \"Test 1: Normal install (should attempt to install completions)\"\necho \"--------------------------------------\"\nunset CI\nunset OPENSPEC_NO_COMPLETIONS\nnode scripts/postinstall.js\necho \"\"\n\n# Test 2: CI environment (should skip silently)\necho \"Test 2: CI=true (should skip silently)\"\necho \"--------------------------------------\"\nexport CI=true\nnode scripts/postinstall.js\necho \"[No output expected - skipped due to CI]\"\necho \"\"\n\n# Test 3: Opt-out flag (should skip silently)\necho \"Test 3: OPENSPEC_NO_COMPLETIONS=1 (should skip silently)\"\necho \"--------------------------------------\"\nunset CI\nexport OPENSPEC_NO_COMPLETIONS=1\nnode scripts/postinstall.js\necho \"[No output expected - skipped due to opt-out]\"\necho \"\"\n\n# Restore original environment\nif [ -n \"$ORIGINAL_CI\" ]; then\n  export CI=\"$ORIGINAL_CI\"\nelse\n  unset CI\nfi\n\nif [ -n \"$ORIGINAL_OPENSPEC_NO_COMPLETIONS\" ]; then\n  export OPENSPEC_NO_COMPLETIONS=\"$ORIGINAL_OPENSPEC_NO_COMPLETIONS\"\nelse\n  unset OPENSPEC_NO_COMPLETIONS\nfi\n\necho \"======================================\"\necho \"All tests completed successfully!\"\necho \"======================================\"\n"
  },
  {
    "path": "scripts/update-flake.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# Updates pnpm dependency hash in flake.nix after pnpm-lock.yaml changes.\n# Version is read dynamically from package.json.\n# Usage: ./scripts/update-flake.sh\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPROJECT_ROOT=\"$(cd \"$SCRIPT_DIR/..\" && pwd)\"\nFLAKE_FILE=\"$PROJECT_ROOT/flake.nix\"\nPACKAGE_JSON=\"$PROJECT_ROOT/package.json\"\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Detect OS and set sed in-place flag\nif [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n  # macOS (BSD sed) requires empty string argument for -i\n  SED_INPLACE=(-i '')\nelse\n  # Linux (GNU sed)\n  SED_INPLACE=(-i)\nfi\n\necho -e \"${BLUE}==> Updating flake.nix pnpm dependency hash...${NC}\"\necho \"\"\n\n# Extract version from package.json\nVERSION=$(node -p \"require('$PACKAGE_JSON').version\")\necho -e \"${BLUE}📦 Detected package version:${NC} $VERSION\"\n\n# Verify flake.nix uses dynamic version\nif ! grep -q \"(builtins.fromJSON (builtins.readFile ./package.json)).version\" \"$FLAKE_FILE\"; then\n  echo -e \"${YELLOW}⚠️  Warning: flake.nix doesn't use dynamic version from package.json${NC}\"\n  echo -e \"   Expected pattern: version = (builtins.fromJSON (builtins.readFile ./package.json)).version;\"\n  echo \"\"\nfi\n\n# Check if pnpm-lock.yaml exists\nif [ ! -f \"$PROJECT_ROOT/pnpm-lock.yaml\" ]; then\n  echo -e \"${RED}❌ Error: pnpm-lock.yaml not found${NC}\"\n  exit 1\nfi\n\necho -e \"${BLUE}🔧 Current pnpm-lock.yaml:${NC} $(stat -c%y \"$PROJECT_ROOT/pnpm-lock.yaml\" 2>/dev/null || stat -f%Sm \"$PROJECT_ROOT/pnpm-lock.yaml\")\"\necho \"\"\n\n# Get current hash from flake.nix\nCURRENT_HASH=$(sed -nE 's/.*hash = \"(sha256-[^\"]+)\".*/\\1/p' \"$FLAKE_FILE\" | head -1)\necho -e \"${BLUE}📌 Current hash:${NC} $CURRENT_HASH\"\necho \"\"\n\n# Set placeholder hash to trigger error\necho -e \"${YELLOW}⏳ Setting placeholder hash to calculate correct value...${NC}\"\nPLACEHOLDER=\"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\nsed \"${SED_INPLACE[@]}\" \"s|hash = \\\"sha256-[^\\\"]*\\\"|hash = \\\"$PLACEHOLDER\\\"|\" \"$FLAKE_FILE\"\n\n# Try to build and capture the correct hash\necho -e \"${BLUE}🔨 Building to determine correct hash (expected to fail)...${NC}\"\nBUILD_OUTPUT=$(nix build --no-link 2>&1 || true)\n\n# Extract the correct hash from error output\n# Try multiple patterns for compatibility with different Nix versions\nCORRECT_HASH=$(echo \"$BUILD_OUTPUT\" | sed -nE 's/.*got:[[:space:]]*(sha256-[A-Za-z0-9+/=]+).*/\\1/p' | head -1)\nif [ -z \"$CORRECT_HASH\" ]; then\n  CORRECT_HASH=$(echo \"$BUILD_OUTPUT\" | sed -nE 's/.*got:.*(sha256-[A-Za-z0-9+/=]+).*/\\1/p' | head -1)\nfi\n\nif [ -z \"$CORRECT_HASH\" ]; then\n  echo -e \"${RED}❌ Error: Could not extract hash from build output${NC}\"\n  echo \"\"\n  echo -e \"${YELLOW}Build output:${NC}\"\n  echo \"$BUILD_OUTPUT\"\n  echo \"\"\n  echo -e \"${YELLOW}Restoring original hash...${NC}\"\n  sed \"${SED_INPLACE[@]}\" \"s|hash = \\\"$PLACEHOLDER\\\"|hash = \\\"$CURRENT_HASH\\\"|\" \"$FLAKE_FILE\"\n  exit 1\nfi\n\necho -e \"${GREEN}✓ Calculated hash:${NC} $CORRECT_HASH\"\necho \"\"\n\n# Check if hash changed\nif [ \"$CURRENT_HASH\" = \"$CORRECT_HASH\" ]; then\n  echo -e \"${GREEN}✓ Hash is already up-to-date!${NC}\"\n  sed \"${SED_INPLACE[@]}\" \"s|hash = \\\"$PLACEHOLDER\\\"|hash = \\\"$CORRECT_HASH\\\"|\" \"$FLAKE_FILE\"\n  echo \"\"\n  echo -e \"${BLUE}ℹ️  No changes needed. Your flake is in sync with pnpm-lock.yaml${NC}\"\n  exit 0\nfi\n\necho -e \"${YELLOW}🔄 Updating hash in flake.nix...${NC}\"\nsed \"${SED_INPLACE[@]}\" \"s|hash = \\\"$PLACEHOLDER\\\"|hash = \\\"$CORRECT_HASH\\\"|\" \"$FLAKE_FILE\"\n\n# Verify the build works\necho -e \"${BLUE}🔍 Verifying build with new hash...${NC}\"\nBUILD_OUTPUT=$(nix build --no-link 2>&1) && BUILD_SUCCESS=true || BUILD_SUCCESS=false\nif [ \"$BUILD_SUCCESS\" = false ]; then\n  echo -e \"${RED}❌ Build verification failed!${NC}\"\n  echo \"\"\n  echo \"$BUILD_OUTPUT\"\n  exit 1\nfi\nif echo \"$BUILD_OUTPUT\" | grep -q \"warning: Git tree.*is dirty\"; then\n  echo -e \"${YELLOW}⚠️  Git tree is dirty, but build succeeded${NC}\"\nelse\n  echo -e \"${GREEN}✓ Build verification successful${NC}\"\nfi\necho \"\"\n\necho -e \"${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\"\necho -e \"${GREEN}✅ flake.nix updated successfully!${NC}\"\necho -e \"${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\"\necho \"\"\necho -e \"${BLUE}📋 Summary:${NC}\"\necho -e \"   Version:    $VERSION ${YELLOW}(read dynamically from package.json)${NC}\"\necho -e \"   Old hash:   $CURRENT_HASH\"\necho -e \"   New hash:   $CORRECT_HASH\"\necho \"\"\necho -e \"${BLUE}📝 Next steps:${NC}\"\necho -e \"   1. Test:   ${GREEN}nix run . -- --version${NC}\"\necho -e \"   2. Verify: ${GREEN}nix flake check${NC}\"\necho -e \"   3. Commit: ${GREEN}git add flake.nix${NC}\"\necho \"\"\n"
  },
  {
    "path": "src/cli/index.ts",
    "content": "import { Command } from 'commander';\nimport { createRequire } from 'module';\nimport ora from 'ora';\nimport path from 'path';\nimport { promises as fs } from 'fs';\nimport { AI_TOOLS } from '../core/config.js';\nimport { UpdateCommand } from '../core/update.js';\nimport { ListCommand } from '../core/list.js';\nimport { ArchiveCommand } from '../core/archive.js';\nimport { ViewCommand } from '../core/view.js';\nimport { registerSpecCommand } from '../commands/spec.js';\nimport { ChangeCommand } from '../commands/change.js';\nimport { ValidateCommand } from '../commands/validate.js';\nimport { ShowCommand } from '../commands/show.js';\nimport { CompletionCommand } from '../commands/completion.js';\nimport { FeedbackCommand } from '../commands/feedback.js';\nimport { registerConfigCommand } from '../commands/config.js';\nimport { registerSchemaCommand } from '../commands/schema.js';\nimport {\n  statusCommand,\n  instructionsCommand,\n  applyInstructionsCommand,\n  templatesCommand,\n  schemasCommand,\n  newChangeCommand,\n  DEFAULT_SCHEMA,\n  type StatusOptions,\n  type InstructionsOptions,\n  type TemplatesOptions,\n  type SchemasOptions,\n  type NewChangeOptions,\n} from '../commands/workflow/index.js';\nimport { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';\n\nconst program = new Command();\nconst require = createRequire(import.meta.url);\nconst { version } = require('../../package.json');\n\n/**\n * Get the full command path for nested commands.\n * For example: 'change show' -> 'change:show'\n */\nfunction getCommandPath(command: Command): string {\n  const names: string[] = [];\n  let current: Command | null = command;\n\n  while (current) {\n    const name = current.name();\n    // Skip the root 'openspec' command\n    if (name && name !== 'openspec') {\n      names.unshift(name);\n    }\n    current = current.parent;\n  }\n\n  return names.join(':') || 'openspec';\n}\n\nprogram\n  .name('openspec')\n  .description('AI-native system for spec-driven development')\n  .version(version);\n\n// Global options\nprogram.option('--no-color', 'Disable color output');\n\n// Apply global flags and telemetry before any command runs\n// Note: preAction receives (thisCommand, actionCommand) where:\n// - thisCommand: the command where hook was added (root program)\n// - actionCommand: the command actually being executed (subcommand)\nprogram.hook('preAction', async (thisCommand, actionCommand) => {\n  const opts = thisCommand.opts();\n  if (opts.color === false) {\n    process.env.NO_COLOR = '1';\n  }\n\n  // Show first-run telemetry notice (if not seen)\n  await maybeShowTelemetryNotice();\n\n  // Track command execution (use actionCommand to get the actual subcommand)\n  const commandPath = getCommandPath(actionCommand);\n  await trackCommand(commandPath, version);\n});\n\n// Shutdown telemetry after command completes\nprogram.hook('postAction', async () => {\n  await shutdown();\n});\n\nconst availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value);\nconst toolsOptionDescription = `Configure AI tools non-interactively. Use \"all\", \"none\", or a comma-separated list of: ${availableToolIds.join(', ')}`;\n\nprogram\n  .command('init [path]')\n  .description('Initialize OpenSpec in your project')\n  .option('--tools <tools>', toolsOptionDescription)\n  .option('--force', 'Auto-cleanup legacy files without prompting')\n  .option('--profile <profile>', 'Override global config profile (core or custom)')\n  .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string }) => {\n    try {\n      // Validate that the path is a valid directory\n      const resolvedPath = path.resolve(targetPath);\n\n      try {\n        const stats = await fs.stat(resolvedPath);\n        if (!stats.isDirectory()) {\n          throw new Error(`Path \"${targetPath}\" is not a directory`);\n        }\n      } catch (error: any) {\n        if (error.code === 'ENOENT') {\n          // Directory doesn't exist, but we can create it\n          console.log(`Directory \"${targetPath}\" doesn't exist, it will be created.`);\n        } else if (error.message && error.message.includes('not a directory')) {\n          throw error;\n        } else {\n          throw new Error(`Cannot access path \"${targetPath}\": ${error.message}`);\n        }\n      }\n\n      const { InitCommand } = await import('../core/init.js');\n      const initCommand = new InitCommand({\n        tools: options?.tools,\n        force: options?.force,\n        profile: options?.profile,\n      });\n      await initCommand.execute(targetPath);\n    } catch (error) {\n      console.log(); // Empty line for spacing\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\n// Hidden alias: 'experimental' -> 'init' for backwards compatibility\nprogram\n  .command('experimental', { hidden: true })\n  .description('Alias for init (deprecated)')\n  .option('--tool <tool-id>', 'Target AI tool (maps to --tools)')\n  .option('--no-interactive', 'Disable interactive prompts')\n  .action(async (options?: { tool?: string; noInteractive?: boolean }) => {\n    try {\n      console.log('Note: \"openspec experimental\" is deprecated. Use \"openspec init\" instead.');\n      const { InitCommand } = await import('../core/init.js');\n      const initCommand = new InitCommand({\n        tools: options?.tool,\n        interactive: options?.noInteractive === true ? false : undefined,\n      });\n      await initCommand.execute('.');\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\nprogram\n  .command('update [path]')\n  .description('Update OpenSpec instruction files')\n  .option('--force', 'Force update even when tools are up to date')\n  .action(async (targetPath = '.', options?: { force?: boolean }) => {\n    try {\n      const resolvedPath = path.resolve(targetPath);\n      const updateCommand = new UpdateCommand({ force: options?.force });\n      await updateCommand.execute(resolvedPath);\n    } catch (error) {\n      console.log(); // Empty line for spacing\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\nprogram\n  .command('list')\n  .description('List items (changes by default). Use --specs to list specs.')\n  .option('--specs', 'List specs instead of changes')\n  .option('--changes', 'List changes explicitly (default)')\n  .option('--sort <order>', 'Sort order: \"recent\" (default) or \"name\"', 'recent')\n  .option('--json', 'Output as JSON (for programmatic use)')\n  .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean }) => {\n    try {\n      const listCommand = new ListCommand();\n      const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes';\n      const sort = options?.sort === 'name' ? 'name' : 'recent';\n      await listCommand.execute('.', mode, { sort, json: options?.json });\n    } catch (error) {\n      console.log(); // Empty line for spacing\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\nprogram\n  .command('view')\n  .description('Display an interactive dashboard of specs and changes')\n  .action(async () => {\n    try {\n      const viewCommand = new ViewCommand();\n      await viewCommand.execute('.');\n    } catch (error) {\n      console.log(); // Empty line for spacing\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\n// Change command with subcommands\nconst changeCmd = program\n  .command('change')\n  .description('Manage OpenSpec change proposals');\n\n// Deprecation notice for noun-based commands\nchangeCmd.hook('preAction', () => {\n  console.error('Warning: The \"openspec change ...\" commands are deprecated. Prefer verb-first commands (e.g., \"openspec list\", \"openspec validate --changes\").');\n});\n\nchangeCmd\n  .command('show [change-name]')\n  .description('Show a change proposal in JSON or markdown format')\n  .option('--json', 'Output as JSON')\n  .option('--deltas-only', 'Show only deltas (JSON only)')\n  .option('--requirements-only', 'Alias for --deltas-only (deprecated)')\n  .option('--no-interactive', 'Disable interactive prompts')\n  .action(async (changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }) => {\n    try {\n      const changeCommand = new ChangeCommand();\n      await changeCommand.show(changeName, options);\n    } catch (error) {\n      console.error(`Error: ${(error as Error).message}`);\n      process.exitCode = 1;\n    }\n  });\n\nchangeCmd\n  .command('list')\n  .description('List all active changes (DEPRECATED: use \"openspec list\" instead)')\n  .option('--json', 'Output as JSON')\n  .option('--long', 'Show id and title with counts')\n  .action(async (options?: { json?: boolean; long?: boolean }) => {\n    try {\n      console.error('Warning: \"openspec change list\" is deprecated. Use \"openspec list\".');\n      const changeCommand = new ChangeCommand();\n      await changeCommand.list(options);\n    } catch (error) {\n      console.error(`Error: ${(error as Error).message}`);\n      process.exitCode = 1;\n    }\n  });\n\nchangeCmd\n  .command('validate [change-name]')\n  .description('Validate a change proposal')\n  .option('--strict', 'Enable strict validation mode')\n  .option('--json', 'Output validation report as JSON')\n  .option('--no-interactive', 'Disable interactive prompts')\n  .action(async (changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {\n    try {\n      const changeCommand = new ChangeCommand();\n      await changeCommand.validate(changeName, options);\n      if (typeof process.exitCode === 'number' && process.exitCode !== 0) {\n        process.exit(process.exitCode);\n      }\n    } catch (error) {\n      console.error(`Error: ${(error as Error).message}`);\n      process.exitCode = 1;\n    }\n  });\n\nprogram\n  .command('archive [change-name]')\n  .description('Archive a completed change and update main specs')\n  .option('-y, --yes', 'Skip confirmation prompts')\n  .option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)')\n  .option('--no-validate', 'Skip validation (not recommended, requires confirmation)')\n  .action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean }) => {\n    try {\n      const archiveCommand = new ArchiveCommand();\n      await archiveCommand.execute(changeName, options);\n    } catch (error) {\n      console.log(); // Empty line for spacing\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\nregisterSpecCommand(program);\nregisterConfigCommand(program);\nregisterSchemaCommand(program);\n\n// Top-level validate command\nprogram\n  .command('validate [item-name]')\n  .description('Validate changes and specs')\n  .option('--all', 'Validate all changes and specs')\n  .option('--changes', 'Validate all changes')\n  .option('--specs', 'Validate all specs')\n  .option('--type <type>', 'Specify item type when ambiguous: change|spec')\n  .option('--strict', 'Enable strict validation mode')\n  .option('--json', 'Output validation results as JSON')\n  .option('--concurrency <n>', 'Max concurrent validations (defaults to env OPENSPEC_CONCURRENCY or 6)')\n  .option('--no-interactive', 'Disable interactive prompts')\n  .action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string }) => {\n    try {\n      const validateCommand = new ValidateCommand();\n      await validateCommand.execute(itemName, options);\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\n// Top-level show command\nprogram\n  .command('show [item-name]')\n  .description('Show a change or spec')\n  .option('--json', 'Output as JSON')\n  .option('--type <type>', 'Specify item type when ambiguous: change|spec')\n  .option('--no-interactive', 'Disable interactive prompts')\n  // change-only flags\n  .option('--deltas-only', 'Show only deltas (JSON only, change)')\n  .option('--requirements-only', 'Alias for --deltas-only (deprecated, change)')\n  // spec-only flags\n  .option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')\n  .option('--no-scenarios', 'JSON only: Exclude scenario content')\n  .option('-r, --requirement <id>', 'JSON only: Show specific requirement by ID (1-based)')\n  // allow unknown options to pass-through to underlying command implementation\n  .allowUnknownOption(true)\n  .action(async (itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any }) => {\n    try {\n      const showCommand = new ShowCommand();\n      await showCommand.execute(itemName, options ?? {});\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\n// Feedback command\nprogram\n  .command('feedback <message>')\n  .description('Submit feedback about OpenSpec')\n  .option('--body <text>', 'Detailed description for the feedback')\n  .action(async (message: string, options?: { body?: string }) => {\n    try {\n      const feedbackCommand = new FeedbackCommand();\n      await feedbackCommand.execute(message, options);\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\n// Completion command with subcommands\nconst completionCmd = program\n  .command('completion')\n  .description('Manage shell completions for OpenSpec CLI');\n\ncompletionCmd\n  .command('generate [shell]')\n  .description('Generate completion script for a shell (outputs to stdout)')\n  .action(async (shell?: string) => {\n    try {\n      const completionCommand = new CompletionCommand();\n      await completionCommand.generate({ shell });\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\ncompletionCmd\n  .command('install [shell]')\n  .description('Install completion script for a shell')\n  .option('--verbose', 'Show detailed installation output')\n  .action(async (shell?: string, options?: { verbose?: boolean }) => {\n    try {\n      const completionCommand = new CompletionCommand();\n      await completionCommand.install({ shell, verbose: options?.verbose });\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\ncompletionCmd\n  .command('uninstall [shell]')\n  .description('Uninstall completion script for a shell')\n  .option('-y, --yes', 'Skip confirmation prompts')\n  .action(async (shell?: string, options?: { yes?: boolean }) => {\n    try {\n      const completionCommand = new CompletionCommand();\n      await completionCommand.uninstall({ shell, yes: options?.yes });\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\n// Hidden command for machine-readable completion data\nprogram\n  .command('__complete <type>', { hidden: true })\n  .description('Output completion data in machine-readable format (internal use)')\n  .action(async (type: string) => {\n    try {\n      const completionCommand = new CompletionCommand();\n      await completionCommand.complete({ type });\n    } catch (error) {\n      // Silently fail for graceful shell completion experience\n      process.exitCode = 1;\n    }\n  });\n\n// ═══════════════════════════════════════════════════════════\n// Workflow Commands (formerly experimental)\n// ═══════════════════════════════════════════════════════════\n\n// Status command\nprogram\n  .command('status')\n  .description('Display artifact completion status for a change')\n  .option('--change <id>', 'Change name to show status for')\n  .option('--schema <name>', 'Schema override (auto-detected from config.yaml)')\n  .option('--json', 'Output as JSON')\n  .action(async (options: StatusOptions) => {\n    try {\n      await statusCommand(options);\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\n// Instructions command\nprogram\n  .command('instructions [artifact]')\n  .description('Output enriched instructions for creating an artifact or applying tasks')\n  .option('--change <id>', 'Change name')\n  .option('--schema <name>', 'Schema override (auto-detected from config.yaml)')\n  .option('--json', 'Output as JSON')\n  .action(async (artifactId: string | undefined, options: InstructionsOptions) => {\n    try {\n      // Special case: \"apply\" is not an artifact, but a command to get apply instructions\n      if (artifactId === 'apply') {\n        await applyInstructionsCommand(options);\n      } else {\n        await instructionsCommand(artifactId, options);\n      }\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\n// Templates command\nprogram\n  .command('templates')\n  .description('Show resolved template paths for all artifacts in a schema')\n  .option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)\n  .option('--json', 'Output as JSON mapping artifact IDs to template paths')\n  .action(async (options: TemplatesOptions) => {\n    try {\n      await templatesCommand(options);\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\n// Schemas command\nprogram\n  .command('schemas')\n  .description('List available workflow schemas with descriptions')\n  .option('--json', 'Output as JSON (for agent use)')\n  .action(async (options: SchemasOptions) => {\n    try {\n      await schemasCommand(options);\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\n// New command group with change subcommand\nconst newCmd = program.command('new').description('Create new items');\n\nnewCmd\n  .command('change <name>')\n  .description('Create a new change directory')\n  .option('--description <text>', 'Description to add to README.md')\n  .option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`)\n  .action(async (name: string, options: NewChangeOptions) => {\n    try {\n      await newChangeCommand(name, options);\n    } catch (error) {\n      console.log();\n      ora().fail(`Error: ${(error as Error).message}`);\n      process.exit(1);\n    }\n  });\n\nprogram.parse();\n"
  },
  {
    "path": "src/commands/change.ts",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\nimport { JsonConverter } from '../core/converters/json-converter.js';\nimport { Validator } from '../core/validation/validator.js';\nimport { ChangeParser } from '../core/parsers/change-parser.js';\nimport { Change } from '../core/schemas/index.js';\nimport { isInteractive } from '../utils/interactive.js';\nimport { getActiveChangeIds } from '../utils/item-discovery.js';\n\n// Constants for better maintainability\nconst ARCHIVE_DIR = 'archive';\nconst TASK_PATTERN = /^[-*]\\s+\\[[\\sx]\\]/i;\nconst COMPLETED_TASK_PATTERN = /^[-*]\\s+\\[x\\]/i;\n\nexport class ChangeCommand {\n  private converter: JsonConverter;\n\n  constructor() {\n    this.converter = new JsonConverter();\n  }\n\n  /**\n   * Show a change proposal.\n   * - Text mode: raw markdown passthrough (no filters)\n   * - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas\n   *   Note: --requirements-only is deprecated alias for --deltas-only\n   */\n  async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }): Promise<void> {\n    const changesPath = path.join(process.cwd(), 'openspec', 'changes');\n\n    if (!changeName) {\n      const canPrompt = isInteractive(options);\n      const changes = await this.getActiveChanges(changesPath);\n      if (canPrompt && changes.length > 0) {\n        const { select } = await import('@inquirer/prompts');\n        const selected = await select({\n          message: 'Select a change to show',\n          choices: changes.map(id => ({ name: id, value: id })),\n        });\n        changeName = selected;\n      } else {\n        if (changes.length === 0) {\n          console.error('No change specified. No active changes found.');\n        } else {\n          console.error(`No change specified. Available IDs: ${changes.join(', ')}`);\n        }\n        console.error('Hint: use \"openspec change list\" to view available changes.');\n        process.exitCode = 1;\n        return;\n      }\n    }\n\n    const proposalPath = path.join(changesPath, changeName, 'proposal.md');\n\n    try {\n      await fs.access(proposalPath);\n    } catch {\n      throw new Error(`Change \"${changeName}\" not found at ${proposalPath}`);\n    }\n\n    if (options?.json) {\n      const jsonOutput = await this.converter.convertChangeToJson(proposalPath);\n\n      if (options.requirementsOnly) {\n        console.error('Flag --requirements-only is deprecated; use --deltas-only instead.');\n      }\n\n      const parsed: Change = JSON.parse(jsonOutput);\n      const contentForTitle = await fs.readFile(proposalPath, 'utf-8');\n      const title = this.extractTitle(contentForTitle, changeName);\n      const id = parsed.name;\n      const deltas = parsed.deltas || [];\n\n      if (options.requirementsOnly || options.deltasOnly) {\n        const output = { id, title, deltaCount: deltas.length, deltas };\n        console.log(JSON.stringify(output, null, 2));\n      } else {\n        const output = {\n          id,\n          title,\n          deltaCount: deltas.length,\n          deltas,\n        };\n        console.log(JSON.stringify(output, null, 2));\n      }\n    } else {\n      const content = await fs.readFile(proposalPath, 'utf-8');\n      console.log(content);\n    }\n  }\n\n  /**\n   * List active changes.\n   * - Text default: IDs only; --long prints minimal details (title, counts)\n   * - JSON: array of { id, title, deltaCount, taskStatus }, sorted by id\n   */\n  async list(options?: { json?: boolean; long?: boolean }): Promise<void> {\n    const changesPath = path.join(process.cwd(), 'openspec', 'changes');\n    \n    const changes = await this.getActiveChanges(changesPath);\n    \n    if (options?.json) {\n      const changeDetails = await Promise.all(\n        changes.map(async (changeName) => {\n          const proposalPath = path.join(changesPath, changeName, 'proposal.md');\n          const tasksPath = path.join(changesPath, changeName, 'tasks.md');\n          \n          try {\n            const content = await fs.readFile(proposalPath, 'utf-8');\n            const changeDir = path.join(changesPath, changeName);\n            const parser = new ChangeParser(content, changeDir);\n            const change = await parser.parseChangeWithDeltas(changeName);\n            \n            let taskStatus = { total: 0, completed: 0 };\n            try {\n              const tasksContent = await fs.readFile(tasksPath, 'utf-8');\n              taskStatus = this.countTasks(tasksContent);\n            } catch (error) {\n              // Tasks file may not exist, which is okay\n              if (process.env.DEBUG) {\n                console.error(`Failed to read tasks file at ${tasksPath}:`, error);\n              }\n            }\n            \n            return {\n              id: changeName,\n              title: this.extractTitle(content, changeName),\n              deltaCount: change.deltas.length,\n              taskStatus,\n            };\n          } catch (error) {\n            return {\n              id: changeName,\n              title: 'Unknown',\n              deltaCount: 0,\n              taskStatus: { total: 0, completed: 0 },\n            };\n          }\n        })\n      );\n      \n      const sorted = changeDetails.sort((a, b) => a.id.localeCompare(b.id));\n      console.log(JSON.stringify(sorted, null, 2));\n    } else {\n      if (changes.length === 0) {\n        console.log('No items found');\n        return;\n      }\n      const sorted = [...changes].sort();\n      if (!options?.long) {\n        // IDs only\n        sorted.forEach(id => console.log(id));\n        return;\n      }\n\n      // Long format: id: title and minimal counts\n      for (const changeName of sorted) {\n        const proposalPath = path.join(changesPath, changeName, 'proposal.md');\n        const tasksPath = path.join(changesPath, changeName, 'tasks.md');\n        try {\n          const content = await fs.readFile(proposalPath, 'utf-8');\n          const title = this.extractTitle(content, changeName);\n          let taskStatusText = '';\n          try {\n            const tasksContent = await fs.readFile(tasksPath, 'utf-8');\n            const { total, completed } = this.countTasks(tasksContent);\n            taskStatusText = ` [tasks ${completed}/${total}]`;\n          } catch (error) {\n            if (process.env.DEBUG) {\n              console.error(`Failed to read tasks file at ${tasksPath}:`, error);\n            }\n          }\n          const changeDir = path.join(changesPath, changeName);\n          const parser = new ChangeParser(await fs.readFile(proposalPath, 'utf-8'), changeDir);\n          const change = await parser.parseChangeWithDeltas(changeName);\n          const deltaCountText = ` [deltas ${change.deltas.length}]`;\n          console.log(`${changeName}: ${title}${deltaCountText}${taskStatusText}`);\n        } catch {\n          console.log(`${changeName}: (unable to read)`);\n        }\n      }\n    }\n  }\n\n  async validate(changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }): Promise<void> {\n    const changesPath = path.join(process.cwd(), 'openspec', 'changes');\n    \n    if (!changeName) {\n      const canPrompt = isInteractive(options);\n      const changes = await getActiveChangeIds();\n      if (canPrompt && changes.length > 0) {\n        const { select } = await import('@inquirer/prompts');\n        const selected = await select({\n          message: 'Select a change to validate',\n          choices: changes.map(id => ({ name: id, value: id })),\n        });\n        changeName = selected;\n      } else {\n        if (changes.length === 0) {\n          console.error('No change specified. No active changes found.');\n        } else {\n          console.error(`No change specified. Available IDs: ${changes.join(', ')}`);\n        }\n        console.error('Hint: use \"openspec change list\" to view available changes.');\n        process.exitCode = 1;\n        return;\n      }\n    }\n    \n    const changeDir = path.join(changesPath, changeName);\n    \n    try {\n      await fs.access(changeDir);\n    } catch {\n      throw new Error(`Change \"${changeName}\" not found at ${changeDir}`);\n    }\n    \n    const validator = new Validator(options?.strict || false);\n    const report = await validator.validateChangeDeltaSpecs(changeDir);\n    \n    if (options?.json) {\n      console.log(JSON.stringify(report, null, 2));\n    } else {\n      if (report.valid) {\n        console.log(`Change \"${changeName}\" is valid`);\n      } else {\n        console.error(`Change \"${changeName}\" has issues`);\n        report.issues.forEach(issue => {\n          const label = issue.level === 'ERROR' ? 'ERROR' : 'WARNING';\n          const prefix = issue.level === 'ERROR' ? '✗' : '⚠';\n          console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);\n        });\n        // Next steps footer to guide fixing issues\n        this.printNextSteps();\n        if (!options?.json) {\n          process.exitCode = 1;\n        }\n      }\n    }\n  }\n\n  private async getActiveChanges(changesPath: string): Promise<string[]> {\n    try {\n      const entries = await fs.readdir(changesPath, { withFileTypes: true });\n      const result: string[] = [];\n      for (const entry of entries) {\n        if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === ARCHIVE_DIR) continue;\n        const proposalPath = path.join(changesPath, entry.name, 'proposal.md');\n        try {\n          await fs.access(proposalPath);\n          result.push(entry.name);\n        } catch {\n          // skip directories without proposal.md\n        }\n      }\n      return result.sort();\n    } catch {\n      return [];\n    }\n  }\n\n  private extractTitle(content: string, changeName: string): string {\n    const match = content.match(/^#\\s+(?:Change:\\s+)?(.+)$/im);\n    return match ? match[1].trim() : changeName;\n  }\n\n  private countTasks(content: string): { total: number; completed: number } {\n    const lines = content.split('\\n');\n    let total = 0;\n    let completed = 0;\n    \n    for (const line of lines) {\n      if (line.match(TASK_PATTERN)) {\n        total++;\n        if (line.match(COMPLETED_TASK_PATTERN)) {\n          completed++;\n        }\n      }\n    }\n    \n    return { total, completed };\n  }\n\n  private printNextSteps(): void {\n    const bullets: string[] = [];\n    bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements');\n    bullets.push('- Each requirement MUST include at least one #### Scenario: block');\n    bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only');\n    console.error('Next steps:');\n    bullets.forEach(b => console.error(`  ${b}`));\n  }\n}\n"
  },
  {
    "path": "src/commands/completion.ts",
    "content": "import ora from 'ora';\nimport { CompletionFactory } from '../core/completions/factory.js';\nimport { COMMAND_REGISTRY } from '../core/completions/command-registry.js';\nimport { detectShell, SupportedShell } from '../utils/shell-detection.js';\nimport { CompletionProvider } from '../core/completions/completion-provider.js';\nimport { getArchivedChangeIds } from '../utils/item-discovery.js';\n\ninterface GenerateOptions {\n  shell?: string;\n}\n\ninterface InstallOptions {\n  shell?: string;\n  verbose?: boolean;\n}\n\ninterface UninstallOptions {\n  shell?: string;\n  yes?: boolean;\n}\n\ninterface CompleteOptions {\n  type: string;\n}\n\n/**\n * Command for managing shell completions for OpenSpec CLI\n */\nexport class CompletionCommand {\n  private completionProvider: CompletionProvider;\n\n  constructor() {\n    this.completionProvider = new CompletionProvider();\n  }\n  /**\n   * Resolve shell parameter or exit with error\n   *\n   * @param shell - The shell parameter (may be undefined)\n   * @param operationName - Name of the operation (for error messages)\n   * @returns Resolved shell or null if should exit\n   */\n  private resolveShellOrExit(shell: string | undefined, operationName: string): SupportedShell | null {\n    const normalizedShell = this.normalizeShell(shell);\n\n    if (!normalizedShell) {\n      const detectionResult = detectShell();\n\n      if (detectionResult.shell && CompletionFactory.isSupported(detectionResult.shell)) {\n        return detectionResult.shell;\n      }\n\n      // Shell was detected but not supported\n      if (detectionResult.detected && !detectionResult.shell) {\n        console.error(`Error: Shell '${detectionResult.detected}' is not supported yet. Currently supported: ${CompletionFactory.getSupportedShells().join(', ')}`);\n        process.exitCode = 1;\n        return null;\n      }\n\n      // No shell specified and cannot auto-detect\n      console.error('Error: Could not auto-detect shell. Please specify shell explicitly.');\n      console.error(`Usage: openspec completion ${operationName} [shell]`);\n      console.error(`Currently supported: ${CompletionFactory.getSupportedShells().join(', ')}`);\n      process.exitCode = 1;\n      return null;\n    }\n\n    if (!CompletionFactory.isSupported(normalizedShell)) {\n      console.error(`Error: Shell '${normalizedShell}' is not supported yet. Currently supported: ${CompletionFactory.getSupportedShells().join(', ')}`);\n      process.exitCode = 1;\n      return null;\n    }\n\n    return normalizedShell;\n  }\n\n  /**\n   * Generate completion script and output to stdout\n   *\n   * @param options - Options for generation (shell type)\n   */\n  async generate(options: GenerateOptions = {}): Promise<void> {\n    const shell = this.resolveShellOrExit(options.shell, 'generate');\n    if (!shell) return;\n\n    await this.generateForShell(shell);\n  }\n\n  /**\n   * Install completion script to the appropriate location\n   *\n   * @param options - Options for installation (shell type, verbose output)\n   */\n  async install(options: InstallOptions = {}): Promise<void> {\n    const shell = this.resolveShellOrExit(options.shell, 'install');\n    if (!shell) return;\n\n    await this.installForShell(shell, options.verbose || false);\n  }\n\n  /**\n   * Uninstall completion script from the installation location\n   *\n   * @param options - Options for uninstallation (shell type, yes flag)\n   */\n  async uninstall(options: UninstallOptions = {}): Promise<void> {\n    const shell = this.resolveShellOrExit(options.shell, 'uninstall');\n    if (!shell) return;\n\n    await this.uninstallForShell(shell, options.yes || false);\n  }\n\n  /**\n   * Generate completion script for a specific shell\n   */\n  private async generateForShell(shell: SupportedShell): Promise<void> {\n    const generator = CompletionFactory.createGenerator(shell);\n    const script = generator.generate(COMMAND_REGISTRY);\n    console.log(script);\n  }\n\n  /**\n   * Install completion script for a specific shell\n   */\n  private async installForShell(shell: SupportedShell, verbose: boolean): Promise<void> {\n    const generator = CompletionFactory.createGenerator(shell);\n    const installer = CompletionFactory.createInstaller(shell);\n\n    const spinner = ora(`Installing ${shell} completion script...`).start();\n\n    try {\n      // Generate the completion script\n      const script = generator.generate(COMMAND_REGISTRY);\n\n      // Install it\n      const result = await installer.install(script);\n\n      spinner.stop();\n\n      if (result.success) {\n        console.log(`✓ ${result.message}`);\n\n        if (verbose && result.installedPath) {\n          console.log(`  Installed to: ${result.installedPath}`);\n          if (result.backupPath) {\n            console.log(`  Backup created: ${result.backupPath}`);\n          }\n\n          // Check if any shell config was updated\n          const configWasUpdated = result.zshrcConfigured || result.bashrcConfigured || result.profileConfigured;\n\n          if (configWasUpdated) {\n            const configPaths: Record<string, string> = {\n              zsh: '~/.zshrc',\n              bash: '~/.bashrc',\n              fish: '~/.config/fish/config.fish',\n              powershell: '$PROFILE',\n            };\n            const configPath = configPaths[shell] || 'config file';\n            console.log(`  ${configPath} configured automatically`);\n          }\n        }\n\n        // Display warnings if present\n        if (result.warnings && result.warnings.length > 0) {\n          console.log('');\n          for (const warning of result.warnings) {\n            console.log(warning);\n          }\n        }\n\n        // Print instructions (only shown if .zshrc wasn't auto-configured)\n        if (result.instructions && result.instructions.length > 0) {\n          console.log('');\n          for (const instruction of result.instructions) {\n            console.log(instruction);\n          }\n        } else {\n          // Check if any shell config was updated (InstallationResult has: zshrcConfigured, bashrcConfigured, profileConfigured)\n          const configWasUpdated = result.zshrcConfigured || result.bashrcConfigured || result.profileConfigured;\n\n          if (configWasUpdated) {\n            console.log('');\n\n            // Shell-specific reload instructions\n            const reloadCommands: Record<string, string> = {\n              zsh: 'exec zsh',\n              bash: 'exec bash',\n              fish: 'exec fish',\n              powershell: '. $PROFILE',\n            };\n            const reloadCmd = reloadCommands[shell] || `restart your ${shell} shell`;\n\n            console.log(`Restart your shell or run: ${reloadCmd}`);\n          }\n        }\n      } else {\n        console.error(`✗ ${result.message}`);\n        process.exitCode = 1;\n      }\n    } catch (error) {\n      spinner.stop();\n      console.error(`✗ Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`);\n      process.exitCode = 1;\n    }\n  }\n\n  /**\n   * Uninstall completion script for a specific shell\n   */\n  private async uninstallForShell(shell: SupportedShell, skipConfirmation: boolean): Promise<void> {\n    const installer = CompletionFactory.createInstaller(shell);\n\n    // Prompt for confirmation unless --yes flag is provided\n    if (!skipConfirmation) {\n      const { confirm } = await import('@inquirer/prompts');\n\n      // Get shell-specific config file path\n      const configPaths: Record<string, string> = {\n        zsh: '~/.zshrc',\n        bash: '~/.bashrc',\n        fish: 'Fish configuration',  // Fish doesn't modify profile, just removes script file\n        powershell: '$PROFILE',\n      };\n      const configPath = configPaths[shell] || `${shell} configuration`;\n\n      const confirmed = await confirm({\n        message: `Remove OpenSpec configuration from ${configPath}?`,\n        default: false,\n      });\n\n      if (!confirmed) {\n        console.log('Uninstall cancelled.');\n        return;\n      }\n    }\n\n    const spinner = ora(`Uninstalling ${shell} completion script...`).start();\n\n    try {\n      const result = await installer.uninstall();\n\n      spinner.stop();\n\n      if (result.success) {\n        console.log(`✓ ${result.message}`);\n      } else {\n        console.error(`✗ ${result.message}`);\n        process.exitCode = 1;\n      }\n    } catch (error) {\n      spinner.stop();\n      console.error(`✗ Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`);\n      process.exitCode = 1;\n    }\n  }\n\n  /**\n   * Output machine-readable completion data for shell consumption\n   * Format: tab-separated \"id\\tdescription\" per line\n   *\n   * @param options - Options specifying completion type\n   */\n  async complete(options: CompleteOptions): Promise<void> {\n    const type = options.type.toLowerCase();\n\n    try {\n      switch (type) {\n        case 'changes': {\n          const changeIds = await this.completionProvider.getChangeIds();\n          for (const id of changeIds) {\n            console.log(`${id}\\tactive change`);\n          }\n          break;\n        }\n        case 'specs': {\n          const specIds = await this.completionProvider.getSpecIds();\n          for (const id of specIds) {\n            console.log(`${id}\\tspecification`);\n          }\n          break;\n        }\n        case 'archived-changes': {\n          const archivedIds = await getArchivedChangeIds();\n          for (const id of archivedIds) {\n            console.log(`${id}\\tarchived change`);\n          }\n          break;\n        }\n        default:\n          // Invalid type - silently exit with no output for graceful shell completion failure\n          process.exitCode = 1;\n          break;\n      }\n    } catch {\n      // Silently fail for graceful shell completion experience\n      process.exitCode = 1;\n    }\n  }\n\n  /**\n   * Normalize shell parameter to lowercase\n   */\n  private normalizeShell(shell?: string): string | undefined {\n    return shell?.toLowerCase();\n  }\n}\n"
  },
  {
    "path": "src/commands/config.ts",
    "content": "import { Command } from 'commander';\nimport { spawn, execSync } from 'node:child_process';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport {\n  getGlobalConfigPath,\n  getGlobalConfig,\n  saveGlobalConfig,\n  GlobalConfig,\n} from '../core/global-config.js';\nimport type { Profile, Delivery } from '../core/global-config.js';\nimport {\n  getNestedValue,\n  setNestedValue,\n  deleteNestedValue,\n  coerceValue,\n  formatValueYaml,\n  validateConfigKeyPath,\n  validateConfig,\n  DEFAULT_CONFIG,\n} from '../core/config-schema.js';\nimport { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js';\nimport { OPENSPEC_DIR_NAME } from '../core/config.js';\nimport { hasProjectConfigDrift } from '../core/profile-sync-drift.js';\n\ntype ProfileAction = 'both' | 'delivery' | 'workflows' | 'keep';\n\ninterface ProfileState {\n  profile: Profile;\n  delivery: Delivery;\n  workflows: string[];\n}\n\ninterface ProfileStateDiff {\n  hasChanges: boolean;\n  lines: string[];\n}\n\ninterface WorkflowPromptMeta {\n  name: string;\n  description: string;\n}\n\nconst WORKFLOW_PROMPT_META: Record<string, WorkflowPromptMeta> = {\n  propose: {\n    name: 'Propose change',\n    description: 'Create proposal, design, and tasks from a request',\n  },\n  explore: {\n    name: 'Explore ideas',\n    description: 'Investigate a problem before implementation',\n  },\n  new: {\n    name: 'New change',\n    description: 'Create a new change scaffold quickly',\n  },\n  continue: {\n    name: 'Continue change',\n    description: 'Resume work on an existing change',\n  },\n  apply: {\n    name: 'Apply tasks',\n    description: 'Implement tasks from the current change',\n  },\n  ff: {\n    name: 'Fast-forward',\n    description: 'Run a faster implementation workflow',\n  },\n  sync: {\n    name: 'Sync specs',\n    description: 'Sync change artifacts with specs',\n  },\n  archive: {\n    name: 'Archive change',\n    description: 'Finalize and archive a completed change',\n  },\n  'bulk-archive': {\n    name: 'Bulk archive',\n    description: 'Archive multiple completed changes together',\n  },\n  verify: {\n    name: 'Verify change',\n    description: 'Run verification checks against a change',\n  },\n  onboard: {\n    name: 'Onboard',\n    description: 'Guided onboarding flow for OpenSpec',\n  },\n};\n\nfunction isPromptCancellationError(error: unknown): boolean {\n  return (\n    error instanceof Error &&\n    (error.name === 'ExitPromptError' || error.message.includes('force closed the prompt with SIGINT'))\n  );\n}\n\n/**\n * Resolve the effective current profile state from global config defaults.\n */\nexport function resolveCurrentProfileState(config: GlobalConfig): ProfileState {\n  const profile = config.profile || 'core';\n  const delivery = config.delivery || 'both';\n  const workflows = [\n    ...getProfileWorkflows(profile, config.workflows ? [...config.workflows] : undefined),\n  ];\n  return { profile, delivery, workflows };\n}\n\n/**\n * Derive profile type from selected workflows.\n */\nexport function deriveProfileFromWorkflowSelection(selectedWorkflows: string[]): Profile {\n  const isCoreMatch =\n    selectedWorkflows.length === CORE_WORKFLOWS.length &&\n    CORE_WORKFLOWS.every((w) => selectedWorkflows.includes(w));\n  return isCoreMatch ? 'core' : 'custom';\n}\n\n/**\n * Format a compact workflow summary for the profile header.\n */\nexport function formatWorkflowSummary(workflows: readonly string[], profile: Profile): string {\n  return `${workflows.length} selected (${profile})`;\n}\n\nfunction stableWorkflowOrder(workflows: readonly string[]): string[] {\n  const seen = new Set<string>();\n  const ordered: string[] = [];\n\n  for (const workflow of ALL_WORKFLOWS) {\n    if (workflows.includes(workflow) && !seen.has(workflow)) {\n      ordered.push(workflow);\n      seen.add(workflow);\n    }\n  }\n\n  const extras = workflows.filter((w) => !ALL_WORKFLOWS.includes(w as (typeof ALL_WORKFLOWS)[number]));\n  extras.sort();\n  for (const extra of extras) {\n    if (!seen.has(extra)) {\n      ordered.push(extra);\n      seen.add(extra);\n    }\n  }\n\n  return ordered;\n}\n\n/**\n * Build a user-facing diff summary between two profile states.\n */\nexport function diffProfileState(before: ProfileState, after: ProfileState): ProfileStateDiff {\n  const lines: string[] = [];\n\n  if (before.delivery !== after.delivery) {\n    lines.push(`delivery: ${before.delivery} -> ${after.delivery}`);\n  }\n\n  if (before.profile !== after.profile) {\n    lines.push(`profile: ${before.profile} -> ${after.profile}`);\n  }\n\n  const beforeOrdered = stableWorkflowOrder(before.workflows);\n  const afterOrdered = stableWorkflowOrder(after.workflows);\n  const beforeSet = new Set(beforeOrdered);\n  const afterSet = new Set(afterOrdered);\n\n  const added = afterOrdered.filter((w) => !beforeSet.has(w));\n  const removed = beforeOrdered.filter((w) => !afterSet.has(w));\n\n  if (added.length > 0 || removed.length > 0) {\n    const tokens: string[] = [];\n    if (added.length > 0) {\n      tokens.push(`added ${added.join(', ')}`);\n    }\n    if (removed.length > 0) {\n      tokens.push(`removed ${removed.join(', ')}`);\n    }\n    lines.push(`workflows: ${tokens.join('; ')}`);\n  }\n\n  return {\n    hasChanges: lines.length > 0,\n    lines,\n  };\n}\n\nfunction maybeWarnConfigDrift(\n  projectDir: string,\n  state: ProfileState,\n  colorize: (message: string) => string\n): void {\n  const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME);\n  if (!fs.existsSync(openspecDir)) {\n    return;\n  }\n  if (!hasProjectConfigDrift(projectDir, state.workflows, state.delivery)) {\n    return;\n  }\n  console.log(colorize('Warning: Global config is not applied to this project. Run `openspec update` to sync.'));\n}\n\n/**\n * Register the config command and all its subcommands.\n *\n * @param program - The Commander program instance\n */\nexport function registerConfigCommand(program: Command): void {\n  const configCmd = program\n    .command('config')\n    .description('View and modify global OpenSpec configuration')\n    .option('--scope <scope>', 'Config scope (only \"global\" supported currently)')\n    .hook('preAction', (thisCommand) => {\n      const opts = thisCommand.opts();\n      if (opts.scope && opts.scope !== 'global') {\n        console.error('Error: Project-local config is not yet implemented');\n        process.exit(1);\n      }\n    });\n\n  // config path\n  configCmd\n    .command('path')\n    .description('Show config file location')\n    .action(() => {\n      console.log(getGlobalConfigPath());\n    });\n\n  // config list\n  configCmd\n    .command('list')\n    .description('Show all current settings')\n    .option('--json', 'Output as JSON')\n    .action((options: { json?: boolean }) => {\n      const config = getGlobalConfig();\n\n      if (options.json) {\n        console.log(JSON.stringify(config, null, 2));\n      } else {\n        // Read raw config to determine which values are explicit vs defaults\n        const configPath = getGlobalConfigPath();\n        let rawConfig: Record<string, unknown> = {};\n        try {\n          if (fs.existsSync(configPath)) {\n            rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));\n          }\n        } catch {\n          // If reading fails, treat all as defaults\n        }\n\n        console.log(formatValueYaml(config));\n\n        // Annotate profile settings\n        const profileSource = rawConfig.profile !== undefined ? '(explicit)' : '(default)';\n        const deliverySource = rawConfig.delivery !== undefined ? '(explicit)' : '(default)';\n        console.log(`\\nProfile settings:`);\n        console.log(`  profile: ${config.profile} ${profileSource}`);\n        console.log(`  delivery: ${config.delivery} ${deliverySource}`);\n        if (config.profile === 'core') {\n          console.log(`  workflows: ${CORE_WORKFLOWS.join(', ')} (from core profile)`);\n        } else if (config.workflows && config.workflows.length > 0) {\n          console.log(`  workflows: ${config.workflows.join(', ')} (explicit)`);\n        } else {\n          console.log(`  workflows: (none)`);\n        }\n      }\n    });\n\n  // config get\n  configCmd\n    .command('get <key>')\n    .description('Get a specific value (raw, scriptable)')\n    .action((key: string) => {\n      const config = getGlobalConfig();\n      const value = getNestedValue(config as Record<string, unknown>, key);\n\n      if (value === undefined) {\n        process.exitCode = 1;\n        return;\n      }\n\n      if (typeof value === 'object' && value !== null) {\n        console.log(JSON.stringify(value));\n      } else {\n        console.log(String(value));\n      }\n    });\n\n  // config set\n  configCmd\n    .command('set <key> <value>')\n    .description('Set a value (auto-coerce types)')\n    .option('--string', 'Force value to be stored as string')\n    .option('--allow-unknown', 'Allow setting unknown keys')\n    .action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => {\n      const allowUnknown = Boolean(options.allowUnknown);\n      const keyValidation = validateConfigKeyPath(key);\n      if (!keyValidation.valid && !allowUnknown) {\n        const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : '';\n        console.error(`Error: Invalid configuration key \"${key}\".${reason}`);\n        console.error('Use \"openspec config list\" to see available keys.');\n        console.error('Pass --allow-unknown to bypass this check.');\n        process.exitCode = 1;\n        return;\n      }\n\n      const config = getGlobalConfig() as Record<string, unknown>;\n      const coercedValue = coerceValue(value, options.string || false);\n\n      // Create a copy to validate before saving\n      const newConfig = JSON.parse(JSON.stringify(config));\n      setNestedValue(newConfig, key, coercedValue);\n\n      // Validate the new config\n      const validation = validateConfig(newConfig);\n      if (!validation.success) {\n        console.error(`Error: Invalid configuration - ${validation.error}`);\n        process.exitCode = 1;\n        return;\n      }\n\n      // Apply changes and save\n      setNestedValue(config, key, coercedValue);\n      saveGlobalConfig(config as GlobalConfig);\n\n      const displayValue =\n        typeof coercedValue === 'string' ? `\"${coercedValue}\"` : String(coercedValue);\n      console.log(`Set ${key} = ${displayValue}`);\n    });\n\n  // config unset\n  configCmd\n    .command('unset <key>')\n    .description('Remove a key (revert to default)')\n    .action((key: string) => {\n      const config = getGlobalConfig() as Record<string, unknown>;\n      const existed = deleteNestedValue(config, key);\n\n      if (existed) {\n        saveGlobalConfig(config as GlobalConfig);\n        console.log(`Unset ${key} (reverted to default)`);\n      } else {\n        console.log(`Key \"${key}\" was not set`);\n      }\n    });\n\n  // config reset\n  configCmd\n    .command('reset')\n    .description('Reset configuration to defaults')\n    .option('--all', 'Reset all configuration (required)')\n    .option('-y, --yes', 'Skip confirmation prompts')\n    .action(async (options: { all?: boolean; yes?: boolean }) => {\n      if (!options.all) {\n        console.error('Error: --all flag is required for reset');\n        console.error('Usage: openspec config reset --all [-y]');\n        process.exitCode = 1;\n        return;\n      }\n\n      if (!options.yes) {\n        const { confirm } = await import('@inquirer/prompts');\n        let confirmed: boolean;\n        try {\n          confirmed = await confirm({\n            message: 'Reset all configuration to defaults?',\n            default: false,\n          });\n        } catch (error) {\n          if (isPromptCancellationError(error)) {\n            console.log('Reset cancelled.');\n            process.exitCode = 130;\n            return;\n          }\n          throw error;\n        }\n\n        if (!confirmed) {\n          console.log('Reset cancelled.');\n          return;\n        }\n      }\n\n      saveGlobalConfig({ ...DEFAULT_CONFIG });\n      console.log('Configuration reset to defaults');\n    });\n\n  // config edit\n  configCmd\n    .command('edit')\n    .description('Open config in $EDITOR')\n    .action(async () => {\n      const editor = process.env.EDITOR || process.env.VISUAL;\n\n      if (!editor) {\n        console.error('Error: No editor configured');\n        console.error('Set the EDITOR or VISUAL environment variable to your preferred editor');\n        console.error('Example: export EDITOR=vim');\n        process.exitCode = 1;\n        return;\n      }\n\n      const configPath = getGlobalConfigPath();\n\n      // Ensure config file exists with defaults\n      if (!fs.existsSync(configPath)) {\n        saveGlobalConfig({ ...DEFAULT_CONFIG });\n      }\n\n      // Spawn editor and wait for it to close\n      // Avoid shell parsing to correctly handle paths with spaces in both\n      // the editor path and config path\n      const child = spawn(editor, [configPath], {\n        stdio: 'inherit',\n        shell: false,\n      });\n\n      await new Promise<void>((resolve, reject) => {\n        child.on('close', (code) => {\n          if (code === 0) {\n            resolve();\n          } else {\n            reject(new Error(`Editor exited with code ${code}`));\n          }\n        });\n        child.on('error', reject);\n      });\n\n      try {\n        const rawConfig = fs.readFileSync(configPath, 'utf-8');\n        const parsedConfig = JSON.parse(rawConfig);\n        const validation = validateConfig(parsedConfig);\n\n        if (!validation.success) {\n          console.error(`Error: Invalid configuration - ${validation.error}`);\n          process.exitCode = 1;\n        }\n      } catch (error) {\n        if ((error as NodeJS.ErrnoException).code === 'ENOENT') {\n          console.error(`Error: Config file not found at ${configPath}`);\n        } else if (error instanceof SyntaxError) {\n          console.error(`Error: Invalid JSON in ${configPath}`);\n          console.error(error.message);\n        } else {\n          console.error(`Error: Unable to validate configuration - ${error instanceof Error ? error.message : String(error)}`);\n        }\n        process.exitCode = 1;\n      }\n    });\n\n  // config profile [preset]\n  configCmd\n    .command('profile [preset]')\n    .description('Configure workflow profile (interactive picker or preset shortcut)')\n    .action(async (preset?: string) => {\n      // Preset shortcut: `openspec config profile core`\n      if (preset === 'core') {\n        const config = getGlobalConfig();\n        config.profile = 'core';\n        config.workflows = [...CORE_WORKFLOWS];\n        // Preserve delivery setting\n        saveGlobalConfig(config);\n        console.log('Config updated. Run `openspec update` in your projects to apply.');\n        return;\n      }\n\n      if (preset) {\n        console.error(`Error: Unknown profile preset \"${preset}\". Available presets: core`);\n        process.exitCode = 1;\n        return;\n      }\n\n      // Non-interactive check\n      if (!process.stdout.isTTY) {\n        console.error('Interactive mode required. Use `openspec config profile core` or set config via environment/flags.');\n        process.exitCode = 1;\n        return;\n      }\n\n      // Interactive picker\n      const { select, checkbox, confirm } = await import('@inquirer/prompts');\n      const chalk = (await import('chalk')).default;\n\n      try {\n        const config = getGlobalConfig();\n        const currentState = resolveCurrentProfileState(config);\n\n        console.log(chalk.bold('\\nCurrent profile settings'));\n        console.log(`  Delivery: ${currentState.delivery}`);\n        console.log(`  Workflows: ${formatWorkflowSummary(currentState.workflows, currentState.profile)}`);\n        console.log(chalk.dim('  Delivery = where workflows are installed (skills, commands, or both)'));\n        console.log(chalk.dim('  Workflows = which actions are available (propose, explore, apply, etc.)'));\n        console.log();\n\n        const action = await select<ProfileAction>({\n          message: 'What do you want to configure?',\n          choices: [\n            {\n              value: 'both',\n              name: 'Delivery and workflows',\n              description: 'Update install mode and available actions together',\n            },\n            {\n              value: 'delivery',\n              name: 'Delivery only',\n              description: 'Change where workflows are installed',\n            },\n            {\n              value: 'workflows',\n              name: 'Workflows only',\n              description: 'Change which workflow actions are available',\n            },\n            {\n              value: 'keep',\n              name: 'Keep current settings (exit)',\n              description: 'Leave configuration unchanged and exit',\n            },\n          ],\n        });\n\n        if (action === 'keep') {\n          console.log('No config changes.');\n          maybeWarnConfigDrift(process.cwd(), currentState, chalk.yellow);\n          return;\n        }\n\n        const nextState: ProfileState = {\n          profile: currentState.profile,\n          delivery: currentState.delivery,\n          workflows: [...currentState.workflows],\n        };\n\n        if (action === 'both' || action === 'delivery') {\n          const deliveryChoices: { value: Delivery; name: string; description: string }[] = [\n            {\n              value: 'both' as Delivery,\n              name: 'Both (skills + commands)',\n              description: 'Install workflows as both skills and slash commands',\n            },\n            {\n              value: 'skills' as Delivery,\n              name: 'Skills only',\n              description: 'Install workflows only as skills',\n            },\n            {\n              value: 'commands' as Delivery,\n              name: 'Commands only',\n              description: 'Install workflows only as slash commands',\n            },\n          ];\n          for (const choice of deliveryChoices) {\n            if (choice.value === currentState.delivery) {\n              choice.name += ' [current]';\n            }\n          }\n\n          nextState.delivery = await select<Delivery>({\n            message: 'Delivery mode (how workflows are installed):',\n            choices: deliveryChoices,\n            default: currentState.delivery,\n          });\n        }\n\n        if (action === 'both' || action === 'workflows') {\n          const formatWorkflowChoice = (workflow: string) => {\n            const metadata = WORKFLOW_PROMPT_META[workflow] ?? {\n              name: workflow,\n              description: `Workflow: ${workflow}`,\n            };\n            return {\n              value: workflow,\n              name: metadata.name,\n              description: metadata.description,\n              short: metadata.name,\n              checked: currentState.workflows.includes(workflow),\n            };\n          };\n\n          const selectedWorkflows = await checkbox<string>({\n            message: 'Select workflows to make available:',\n            instructions: 'Space to toggle, Enter to confirm',\n            pageSize: ALL_WORKFLOWS.length,\n            theme: {\n              icon: {\n                checked: '[x]',\n                unchecked: '[ ]',\n              },\n            },\n            choices: ALL_WORKFLOWS.map(formatWorkflowChoice),\n          });\n          nextState.workflows = selectedWorkflows;\n          nextState.profile = deriveProfileFromWorkflowSelection(selectedWorkflows);\n        }\n\n        const diff = diffProfileState(currentState, nextState);\n        if (!diff.hasChanges) {\n          console.log('No config changes.');\n          maybeWarnConfigDrift(process.cwd(), nextState, chalk.yellow);\n          return;\n        }\n\n        console.log(chalk.bold('\\nConfig changes:'));\n        for (const line of diff.lines) {\n          console.log(`  ${line}`);\n        }\n        console.log();\n\n        config.profile = nextState.profile;\n        config.delivery = nextState.delivery;\n        config.workflows = nextState.workflows;\n        saveGlobalConfig(config);\n\n        // Check if inside an OpenSpec project\n        const projectDir = process.cwd();\n        const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME);\n        if (fs.existsSync(openspecDir)) {\n          const applyNow = await confirm({\n            message: 'Apply changes to this project now?',\n            default: true,\n          });\n\n          if (applyNow) {\n            try {\n              execSync('npx openspec update', { stdio: 'inherit', cwd: projectDir });\n              console.log('Run `openspec update` in your other projects to apply.');\n            } catch {\n              console.error('`openspec update` failed. Please run it manually to apply the profile changes.');\n              process.exitCode = 1;\n            }\n            return;\n          }\n        }\n\n        console.log('Config updated. Run `openspec update` in your projects to apply.');\n      } catch (error) {\n        if (isPromptCancellationError(error)) {\n          console.log('Config profile cancelled.');\n          process.exitCode = 130;\n          return;\n        }\n        throw error;\n      }\n    });\n}\n"
  },
  {
    "path": "src/commands/feedback.ts",
    "content": "import { execSync, execFileSync } from 'child_process';\nimport { createRequire } from 'module';\nimport os from 'os';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * Check if gh CLI is installed and available in PATH\n * Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS\n */\nfunction isGhInstalled(): boolean {\n  try {\n    const command = process.platform === 'win32' ? 'where gh' : 'which gh';\n    execSync(command, { stdio: 'pipe' });\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Check if gh CLI is authenticated\n */\nfunction isGhAuthenticated(): boolean {\n  try {\n    execSync('gh auth status', { stdio: 'pipe' });\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Get OpenSpec version from package.json\n */\nfunction getVersion(): string {\n  try {\n    const { version } = require('../../package.json');\n    return version;\n  } catch {\n    return 'unknown';\n  }\n}\n\n/**\n * Get platform name\n */\nfunction getPlatform(): string {\n  return os.platform();\n}\n\n/**\n * Get current timestamp in ISO format\n */\nfunction getTimestamp(): string {\n  return new Date().toISOString();\n}\n\n/**\n * Generate metadata footer for feedback\n */\nfunction generateMetadata(): string {\n  const version = getVersion();\n  const platform = getPlatform();\n  const timestamp = getTimestamp();\n\n  return `---\nSubmitted via OpenSpec CLI\n- Version: ${version}\n- Platform: ${platform}\n- Timestamp: ${timestamp}`;\n}\n\n/**\n * Format the feedback title\n */\nfunction formatTitle(message: string): string {\n  return `Feedback: ${message}`;\n}\n\n/**\n * Format the full feedback body\n */\nfunction formatBody(bodyText?: string): string {\n  const parts: string[] = [];\n\n  if (bodyText) {\n    parts.push(bodyText);\n    parts.push(''); // Empty line before metadata\n  }\n\n  parts.push(generateMetadata());\n\n  return parts.join('\\n');\n}\n\n/**\n * Generate a pre-filled GitHub issue URL for manual submission\n */\nfunction generateManualSubmissionUrl(title: string, body: string): string {\n  const repo = 'Fission-AI/OpenSpec';\n  const encodedTitle = encodeURIComponent(title);\n  const encodedBody = encodeURIComponent(body);\n  const encodedLabels = encodeURIComponent('feedback');\n\n  return `https://github.com/${repo}/issues/new?title=${encodedTitle}&body=${encodedBody}&labels=${encodedLabels}`;\n}\n\n/**\n * Display formatted feedback content for manual submission\n */\nfunction displayFormattedFeedback(title: string, body: string): void {\n  console.log('\\n--- FORMATTED FEEDBACK ---');\n  console.log(`Title: ${title}`);\n  console.log(`Labels: feedback`);\n  console.log('\\nBody:');\n  console.log(body);\n  console.log('--- END FEEDBACK ---\\n');\n}\n\n/**\n * Submit feedback via gh CLI\n * Uses execFileSync to prevent shell injection vulnerabilities\n */\nfunction submitViaGhCli(title: string, body: string): void {\n  try {\n    const result = execFileSync(\n      'gh',\n      [\n        'issue',\n        'create',\n        '--repo',\n        'Fission-AI/OpenSpec',\n        '--title',\n        title,\n        '--body',\n        body,\n        '--label',\n        'feedback',\n      ],\n      { encoding: 'utf-8', stdio: 'pipe' }\n    );\n\n    const issueUrl = result.trim();\n    console.log(`\\n✓ Feedback submitted successfully!`);\n    console.log(`Issue URL: ${issueUrl}\\n`);\n  } catch (error: any) {\n    // Display the error output from gh CLI\n    if (error.stderr) {\n      console.error(error.stderr.toString());\n    } else if (error.message) {\n      console.error(error.message);\n    }\n\n    // Exit with the same code as gh CLI\n    process.exit(error.status ?? 1);\n  }\n}\n\n/**\n * Handle fallback when gh CLI is not available or not authenticated\n */\nfunction handleFallback(title: string, body: string, reason: 'missing' | 'unauthenticated'): void {\n  if (reason === 'missing') {\n    console.log('⚠️  GitHub CLI not found. Manual submission required.');\n  } else {\n    console.log('⚠️  GitHub authentication required. Manual submission required.');\n  }\n\n  displayFormattedFeedback(title, body);\n\n  const manualUrl = generateManualSubmissionUrl(title, body);\n  console.log('Please submit your feedback manually:');\n  console.log(manualUrl);\n\n  if (reason === 'unauthenticated') {\n    console.log('\\nTo auto-submit in the future: gh auth login');\n  }\n\n  // Exit with success code (fallback is successful)\n  process.exit(0);\n}\n\n/**\n * Feedback command implementation\n */\nexport class FeedbackCommand {\n  async execute(message: string, options?: { body?: string }): Promise<void> {\n    // Format title and body once for all code paths\n    const title = formatTitle(message);\n    const body = formatBody(options?.body);\n\n    // Check if gh CLI is installed\n    if (!isGhInstalled()) {\n      handleFallback(title, body, 'missing');\n      return;\n    }\n\n    // Check if gh CLI is authenticated\n    if (!isGhAuthenticated()) {\n      handleFallback(title, body, 'unauthenticated');\n      return;\n    }\n\n    // Submit via gh CLI\n    submitViaGhCli(title, body);\n  }\n}\n"
  },
  {
    "path": "src/commands/schema.ts",
    "content": "import { Command } from 'commander';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport ora from 'ora';\nimport { stringify as stringifyYaml } from 'yaml';\nimport {\n  getSchemaDir,\n  getProjectSchemasDir,\n  getUserSchemasDir,\n  getPackageSchemasDir,\n  listSchemas,\n} from '../core/artifact-graph/resolver.js';\nimport { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';\nimport type { SchemaYaml, Artifact } from '../core/artifact-graph/types.js';\n\n/**\n * Schema source location type\n */\ntype SchemaSource = 'project' | 'user' | 'package';\n\n/**\n * Result of checking a schema location\n */\ninterface SchemaLocation {\n  source: SchemaSource;\n  path: string;\n  exists: boolean;\n}\n\n/**\n * Schema resolution info with shadowing details\n */\ninterface SchemaResolution {\n  name: string;\n  source: SchemaSource;\n  path: string;\n  shadows: Array<{ source: SchemaSource; path: string }>;\n}\n\n/**\n * Validation issue structure\n */\ninterface ValidationIssue {\n  level: 'error' | 'warning';\n  path: string;\n  message: string;\n}\n\n/**\n * Check all three locations for a schema and return which ones exist.\n */\nfunction checkAllLocations(\n  name: string,\n  projectRoot: string\n): SchemaLocation[] {\n  const locations: SchemaLocation[] = [];\n\n  // Project location\n  const projectDir = path.join(getProjectSchemasDir(projectRoot), name);\n  const projectSchemaPath = path.join(projectDir, 'schema.yaml');\n  locations.push({\n    source: 'project',\n    path: projectDir,\n    exists: fs.existsSync(projectSchemaPath),\n  });\n\n  // User location\n  const userDir = path.join(getUserSchemasDir(), name);\n  const userSchemaPath = path.join(userDir, 'schema.yaml');\n  locations.push({\n    source: 'user',\n    path: userDir,\n    exists: fs.existsSync(userSchemaPath),\n  });\n\n  // Package location\n  const packageDir = path.join(getPackageSchemasDir(), name);\n  const packageSchemaPath = path.join(packageDir, 'schema.yaml');\n  locations.push({\n    source: 'package',\n    path: packageDir,\n    exists: fs.existsSync(packageSchemaPath),\n  });\n\n  return locations;\n}\n\n/**\n * Get resolution info for a schema including shadow detection.\n */\nfunction getSchemaResolution(\n  name: string,\n  projectRoot: string\n): SchemaResolution | null {\n  const locations = checkAllLocations(name, projectRoot);\n  const existingLocations = locations.filter((loc) => loc.exists);\n\n  if (existingLocations.length === 0) {\n    return null;\n  }\n\n  const active = existingLocations[0];\n  const shadows = existingLocations.slice(1).map((loc) => ({\n    source: loc.source,\n    path: loc.path,\n  }));\n\n  return {\n    name,\n    source: active.source,\n    path: active.path,\n    shadows,\n  };\n}\n\n/**\n * Get all schemas with resolution info.\n */\nfunction getAllSchemasWithResolution(\n  projectRoot: string\n): SchemaResolution[] {\n  const schemaNames = listSchemas(projectRoot);\n  const results: SchemaResolution[] = [];\n\n  for (const name of schemaNames) {\n    const resolution = getSchemaResolution(name, projectRoot);\n    if (resolution) {\n      results.push(resolution);\n    }\n  }\n\n  return results;\n}\n\n/**\n * Validate a schema and return issues.\n */\nfunction validateSchema(\n  schemaDir: string,\n  verbose: boolean = false\n): { valid: boolean; issues: ValidationIssue[] } {\n  const issues: ValidationIssue[] = [];\n  const schemaPath = path.join(schemaDir, 'schema.yaml');\n\n  // Check schema.yaml exists\n  if (verbose) {\n    console.log('  Checking schema.yaml exists...');\n  }\n  if (!fs.existsSync(schemaPath)) {\n    issues.push({\n      level: 'error',\n      path: 'schema.yaml',\n      message: 'schema.yaml not found',\n    });\n    return { valid: false, issues };\n  }\n\n  // Parse YAML\n  if (verbose) {\n    console.log('  Parsing YAML...');\n  }\n  let content: string;\n  try {\n    content = fs.readFileSync(schemaPath, 'utf-8');\n  } catch (err) {\n    issues.push({\n      level: 'error',\n      path: 'schema.yaml',\n      message: `Failed to read file: ${(err as Error).message}`,\n    });\n    return { valid: false, issues };\n  }\n\n  // Validate against Zod schema\n  if (verbose) {\n    console.log('  Validating schema structure...');\n  }\n  let schema: SchemaYaml;\n  try {\n    schema = parseSchema(content);\n  } catch (err) {\n    if (err instanceof SchemaValidationError) {\n      issues.push({\n        level: 'error',\n        path: 'schema.yaml',\n        message: err.message,\n      });\n    } else {\n      issues.push({\n        level: 'error',\n        path: 'schema.yaml',\n        message: `Parse error: ${(err as Error).message}`,\n      });\n    }\n    return { valid: false, issues };\n  }\n\n  // Check template files exist\n  // Templates can be in schemaDir directly or in a templates/ subdirectory\n  if (verbose) {\n    console.log('  Checking template files...');\n  }\n  for (const artifact of schema.artifacts) {\n    // Try templates subdirectory first (standard location), then root\n    const templatePathInTemplates = path.join(schemaDir, 'templates', artifact.template);\n    const templatePathInRoot = path.join(schemaDir, artifact.template);\n\n    if (!fs.existsSync(templatePathInTemplates) && !fs.existsSync(templatePathInRoot)) {\n      issues.push({\n        level: 'error',\n        path: `artifacts.${artifact.id}.template`,\n        message: `Template file '${artifact.template}' not found for artifact '${artifact.id}'`,\n      });\n    }\n  }\n\n  // Dependency graph validation is already done by parseSchema\n  // (it throws on cycles and invalid references)\n  if (verbose) {\n    console.log('  Dependency graph validation passed (via parseSchema)');\n  }\n\n  return { valid: issues.length === 0, issues };\n}\n\n/**\n * Validate schema name format (kebab-case).\n */\nfunction isValidSchemaName(name: string): boolean {\n  return /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name);\n}\n\n/**\n * Copy a directory recursively.\n */\nfunction copyDirRecursive(src: string, dest: string): void {\n  fs.mkdirSync(dest, { recursive: true });\n\n  const entries = fs.readdirSync(src, { withFileTypes: true });\n  for (const entry of entries) {\n    const srcPath = path.join(src, entry.name);\n    const destPath = path.join(dest, entry.name);\n\n    if (entry.isDirectory()) {\n      copyDirRecursive(srcPath, destPath);\n    } else {\n      fs.copyFileSync(srcPath, destPath);\n    }\n  }\n}\n\n/**\n * Default artifacts with descriptions for schema init.\n */\nconst DEFAULT_ARTIFACTS: Array<{\n  id: string;\n  description: string;\n  generates: string;\n  template: string;\n}> = [\n  {\n    id: 'proposal',\n    description: 'High-level description of the change, its motivation, and scope',\n    generates: 'proposal.md',\n    template: 'proposal.md',\n  },\n  {\n    id: 'specs',\n    description: 'Detailed specifications with requirements and scenarios',\n    generates: 'specs/**/*.md',\n    template: 'specs/spec.md',\n  },\n  {\n    id: 'design',\n    description: 'Technical design decisions and implementation approach',\n    generates: 'design.md',\n    template: 'design.md',\n  },\n  {\n    id: 'tasks',\n    description: 'Implementation checklist with trackable tasks',\n    generates: 'tasks.md',\n    template: 'tasks.md',\n  },\n];\n\n/**\n * Register the schema command and all its subcommands.\n */\nexport function registerSchemaCommand(program: Command): void {\n  const schemaCmd = program\n    .command('schema')\n    .description('Manage workflow schemas [experimental]');\n\n  // Experimental warning\n  schemaCmd.hook('preAction', () => {\n    console.error('Note: Schema commands are experimental and may change.');\n  });\n\n  // schema which\n  schemaCmd\n    .command('which [name]')\n    .description('Show where a schema resolves from')\n    .option('--json', 'Output as JSON')\n    .option('--all', 'List all schemas with their resolution sources')\n    .action(async (name?: string, options?: { json?: boolean; all?: boolean }) => {\n      try {\n        const projectRoot = process.cwd();\n\n        if (options?.all) {\n          // List all schemas\n          const schemas = getAllSchemasWithResolution(projectRoot);\n\n          if (options?.json) {\n            console.log(JSON.stringify(schemas, null, 2));\n          } else {\n            if (schemas.length === 0) {\n              console.log('No schemas found.');\n              return;\n            }\n\n            // Group by source\n            const bySource = {\n              project: schemas.filter((s) => s.source === 'project'),\n              user: schemas.filter((s) => s.source === 'user'),\n              package: schemas.filter((s) => s.source === 'package'),\n            };\n\n            if (bySource.project.length > 0) {\n              console.log('\\nProject schemas:');\n              for (const schema of bySource.project) {\n                const shadowInfo = schema.shadows.length > 0\n                  ? ` (shadows: ${schema.shadows.map((s) => s.source).join(', ')})`\n                  : '';\n                console.log(`  ${schema.name}${shadowInfo}`);\n              }\n            }\n\n            if (bySource.user.length > 0) {\n              console.log('\\nUser schemas:');\n              for (const schema of bySource.user) {\n                const shadowInfo = schema.shadows.length > 0\n                  ? ` (shadows: ${schema.shadows.map((s) => s.source).join(', ')})`\n                  : '';\n                console.log(`  ${schema.name}${shadowInfo}`);\n              }\n            }\n\n            if (bySource.package.length > 0) {\n              console.log('\\nPackage schemas:');\n              for (const schema of bySource.package) {\n                console.log(`  ${schema.name}`);\n              }\n            }\n          }\n          return;\n        }\n\n        if (!name) {\n          console.error('Error: Schema name is required (or use --all to list all schemas)');\n          process.exitCode = 1;\n          return;\n        }\n\n        const resolution = getSchemaResolution(name, projectRoot);\n\n        if (!resolution) {\n          const available = listSchemas(projectRoot);\n          if (options?.json) {\n            console.log(JSON.stringify({\n              error: `Schema '${name}' not found`,\n              available,\n            }, null, 2));\n          } else {\n            console.error(`Error: Schema '${name}' not found`);\n            console.error(`Available schemas: ${available.join(', ')}`);\n          }\n          process.exitCode = 1;\n          return;\n        }\n\n        if (options?.json) {\n          console.log(JSON.stringify(resolution, null, 2));\n        } else {\n          console.log(`Schema: ${resolution.name}`);\n          console.log(`Source: ${resolution.source}`);\n          console.log(`Path: ${resolution.path}`);\n\n          if (resolution.shadows.length > 0) {\n            console.log('\\nShadows:');\n            for (const shadow of resolution.shadows) {\n              console.log(`  ${shadow.source}: ${shadow.path}`);\n            }\n          }\n        }\n      } catch (error) {\n        console.error(`Error: ${(error as Error).message}`);\n        process.exitCode = 1;\n      }\n    });\n\n  // schema validate\n  schemaCmd\n    .command('validate [name]')\n    .description('Validate a schema structure and templates')\n    .option('--json', 'Output as JSON')\n    .option('--verbose', 'Show detailed validation steps')\n    .action(async (name?: string, options?: { json?: boolean; verbose?: boolean }) => {\n      try {\n        const projectRoot = process.cwd();\n\n        if (!name) {\n          // Validate all project schemas\n          const projectSchemasDir = getProjectSchemasDir(projectRoot);\n\n          if (!fs.existsSync(projectSchemasDir)) {\n            if (options?.json) {\n              console.log(JSON.stringify({\n                valid: true,\n                message: 'No project schemas directory found',\n                schemas: [],\n              }, null, 2));\n            } else {\n              console.log('No project schemas directory found.');\n            }\n            return;\n          }\n\n          const entries = fs.readdirSync(projectSchemasDir, { withFileTypes: true });\n          const schemaResults: Array<{\n            name: string;\n            path: string;\n            valid: boolean;\n            issues: ValidationIssue[];\n          }> = [];\n\n          let anyInvalid = false;\n\n          for (const entry of entries) {\n            if (!entry.isDirectory()) continue;\n\n            const schemaDir = path.join(projectSchemasDir, entry.name);\n            const schemaPath = path.join(schemaDir, 'schema.yaml');\n\n            if (!fs.existsSync(schemaPath)) continue;\n\n            if (options?.verbose && !options?.json) {\n              console.log(`\\nValidating ${entry.name}...`);\n            }\n\n            const result = validateSchema(schemaDir, options?.verbose && !options?.json);\n            schemaResults.push({\n              name: entry.name,\n              path: schemaDir,\n              valid: result.valid,\n              issues: result.issues,\n            });\n\n            if (!result.valid) {\n              anyInvalid = true;\n            }\n          }\n\n          if (options?.json) {\n            console.log(JSON.stringify({\n              valid: !anyInvalid,\n              schemas: schemaResults,\n            }, null, 2));\n          } else {\n            if (schemaResults.length === 0) {\n              console.log('No schemas found in project.');\n              return;\n            }\n\n            console.log('\\nValidation Results:');\n            for (const result of schemaResults) {\n              const status = result.valid ? '✓' : '✗';\n              console.log(`  ${status} ${result.name}`);\n              for (const issue of result.issues) {\n                console.log(`    ${issue.level}: ${issue.message}`);\n              }\n            }\n\n            if (anyInvalid) {\n              process.exitCode = 1;\n            }\n          }\n          return;\n        }\n\n        // Validate specific schema\n        const schemaDir = getSchemaDir(name, projectRoot);\n\n        if (!schemaDir) {\n          const available = listSchemas(projectRoot);\n          if (options?.json) {\n            console.log(JSON.stringify({\n              valid: false,\n              error: `Schema '${name}' not found`,\n              available,\n            }, null, 2));\n          } else {\n            console.error(`Error: Schema '${name}' not found`);\n            console.error(`Available schemas: ${available.join(', ')}`);\n          }\n          process.exitCode = 1;\n          return;\n        }\n\n        if (options?.verbose && !options?.json) {\n          console.log(`Validating ${name}...`);\n        }\n\n        const result = validateSchema(schemaDir, options?.verbose && !options?.json);\n\n        if (options?.json) {\n          console.log(JSON.stringify({\n            name,\n            path: schemaDir,\n            valid: result.valid,\n            issues: result.issues,\n          }, null, 2));\n        } else {\n          if (result.valid) {\n            console.log(`✓ Schema '${name}' is valid`);\n          } else {\n            console.log(`✗ Schema '${name}' has errors:`);\n            for (const issue of result.issues) {\n              console.log(`  ${issue.level}: ${issue.message}`);\n            }\n            process.exitCode = 1;\n          }\n        }\n      } catch (error) {\n        if (options?.json) {\n          console.log(JSON.stringify({\n            valid: false,\n            error: (error as Error).message,\n          }, null, 2));\n        } else {\n          console.error(`Error: ${(error as Error).message}`);\n        }\n        process.exitCode = 1;\n      }\n    });\n\n  // schema fork\n  schemaCmd\n    .command('fork <source> [name]')\n    .description('Copy an existing schema to project for customization')\n    .option('--json', 'Output as JSON')\n    .option('--force', 'Overwrite existing destination')\n    .action(async (source: string, name?: string, options?: { json?: boolean; force?: boolean }) => {\n      const spinner = options?.json ? null : ora();\n\n      try {\n        const projectRoot = process.cwd();\n        const destinationName = name || `${source}-custom`;\n\n        // Validate destination name\n        if (!isValidSchemaName(destinationName)) {\n          if (options?.json) {\n            console.log(JSON.stringify({\n              forked: false,\n              error: `Invalid schema name '${destinationName}'. Use kebab-case (e.g., my-workflow)`,\n            }, null, 2));\n          } else {\n            console.error(`Error: Invalid schema name '${destinationName}'`);\n            console.error('Schema names must be kebab-case (e.g., my-workflow)');\n          }\n          process.exitCode = 1;\n          return;\n        }\n\n        // Find source schema\n        const sourceDir = getSchemaDir(source, projectRoot);\n        if (!sourceDir) {\n          const available = listSchemas(projectRoot);\n          if (options?.json) {\n            console.log(JSON.stringify({\n              forked: false,\n              error: `Schema '${source}' not found`,\n              available,\n            }, null, 2));\n          } else {\n            console.error(`Error: Schema '${source}' not found`);\n            console.error(`Available schemas: ${available.join(', ')}`);\n          }\n          process.exitCode = 1;\n          return;\n        }\n\n        // Determine source location\n        const sourceResolution = getSchemaResolution(source, projectRoot);\n        const sourceLocation = sourceResolution?.source || 'package';\n\n        // Check destination\n        const destinationDir = path.join(getProjectSchemasDir(projectRoot), destinationName);\n\n        if (fs.existsSync(destinationDir)) {\n          if (!options?.force) {\n            if (options?.json) {\n              console.log(JSON.stringify({\n                forked: false,\n                error: `Schema '${destinationName}' already exists`,\n                suggestion: 'Use --force to overwrite',\n              }, null, 2));\n            } else {\n              console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`);\n              console.error('Use --force to overwrite');\n            }\n            process.exitCode = 1;\n            return;\n          }\n\n          // Remove existing\n          if (spinner) spinner.start(`Removing existing schema '${destinationName}'...`);\n          fs.rmSync(destinationDir, { recursive: true });\n        }\n\n        // Copy schema\n        if (spinner) spinner.start(`Forking '${source}' to '${destinationName}'...`);\n        copyDirRecursive(sourceDir, destinationDir);\n\n        // Update name in schema.yaml\n        const destSchemaPath = path.join(destinationDir, 'schema.yaml');\n        const schemaContent = fs.readFileSync(destSchemaPath, 'utf-8');\n        const schema = parseSchema(schemaContent);\n        schema.name = destinationName;\n\n        fs.writeFileSync(destSchemaPath, stringifyYaml(schema));\n\n        if (spinner) spinner.succeed(`Forked '${source}' to '${destinationName}'`);\n\n        if (options?.json) {\n          console.log(JSON.stringify({\n            forked: true,\n            source,\n            sourcePath: sourceDir,\n            sourceLocation,\n            destination: destinationName,\n            destinationPath: destinationDir,\n          }, null, 2));\n        } else {\n          console.log(`\\nSource: ${sourceDir} (${sourceLocation})`);\n          console.log(`Destination: ${destinationDir}`);\n          console.log(`\\nYou can now customize the schema at:`);\n          console.log(`  ${destinationDir}/schema.yaml`);\n        }\n      } catch (error) {\n        if (spinner) spinner.fail(`Fork failed`);\n        if (options?.json) {\n          console.log(JSON.stringify({\n            forked: false,\n            error: (error as Error).message,\n          }, null, 2));\n        } else {\n          console.error(`Error: ${(error as Error).message}`);\n        }\n        process.exitCode = 1;\n      }\n    });\n\n  // schema init\n  schemaCmd\n    .command('init <name>')\n    .description('Create a new project-local schema')\n    .option('--json', 'Output as JSON')\n    .option('--description <text>', 'Schema description')\n    .option('--artifacts <list>', 'Comma-separated artifact IDs (proposal,specs,design,tasks)')\n    .option('--default', 'Set as project default schema')\n    .option('--no-default', 'Do not prompt to set as default')\n    .option('--force', 'Overwrite existing schema')\n    .action(async (\n      name: string,\n      options?: {\n        json?: boolean;\n        description?: string;\n        artifacts?: string;\n        default?: boolean;\n        force?: boolean;\n      }\n    ) => {\n      const spinner = options?.json ? null : ora();\n\n      try {\n        const projectRoot = process.cwd();\n\n        // Validate name\n        if (!isValidSchemaName(name)) {\n          if (options?.json) {\n            console.log(JSON.stringify({\n              created: false,\n              error: `Invalid schema name '${name}'. Use kebab-case (e.g., my-workflow)`,\n            }, null, 2));\n          } else {\n            console.error(`Error: Invalid schema name '${name}'`);\n            console.error('Schema names must be kebab-case (e.g., my-workflow)');\n          }\n          process.exitCode = 1;\n          return;\n        }\n\n        const schemaDir = path.join(getProjectSchemasDir(projectRoot), name);\n\n        // Check if exists\n        if (fs.existsSync(schemaDir)) {\n          if (!options?.force) {\n            if (options?.json) {\n              console.log(JSON.stringify({\n                created: false,\n                error: `Schema '${name}' already exists`,\n                suggestion: 'Use --force to overwrite or \"openspec schema fork\" to copy',\n              }, null, 2));\n            } else {\n              console.error(`Error: Schema '${name}' already exists at ${schemaDir}`);\n              console.error('Use --force to overwrite or \"openspec schema fork\" to copy');\n            }\n            process.exitCode = 1;\n            return;\n          }\n\n          if (spinner) spinner.start(`Removing existing schema '${name}'...`);\n          fs.rmSync(schemaDir, { recursive: true });\n        }\n\n        // Determine artifacts and description\n        let description: string;\n        let selectedArtifactIds: string[];\n\n        // Check if we have explicit flags (non-interactive mode)\n        const hasExplicitOptions = options?.description !== undefined || options?.artifacts !== undefined;\n        const isInteractive = !options?.json && !hasExplicitOptions && process.stdout.isTTY;\n\n        if (isInteractive) {\n          // Interactive mode\n          const { input, checkbox, confirm } = await import('@inquirer/prompts');\n\n          description = await input({\n            message: 'Schema description:',\n            default: `Custom workflow schema for ${name}`,\n          });\n\n          const artifactChoices = DEFAULT_ARTIFACTS.map((a) => ({\n            name: a.id,\n            value: a.id,\n            checked: true,\n          }));\n\n          selectedArtifactIds = await checkbox({\n            message: 'Select artifacts to include:',\n            choices: artifactChoices,\n          });\n\n          if (selectedArtifactIds.length === 0) {\n            console.error('Error: At least one artifact must be selected');\n            process.exitCode = 1;\n            return;\n          }\n\n          // Ask about setting as default (unless --no-default was passed)\n          if (options?.default === undefined) {\n            const setAsDefault = await confirm({\n              message: 'Set as project default schema?',\n              default: false,\n            });\n\n            if (setAsDefault) {\n              options = { ...options, default: true };\n            }\n          }\n        } else {\n          // Non-interactive mode\n          description = options?.description || `Custom workflow schema for ${name}`;\n\n          if (options?.artifacts) {\n            selectedArtifactIds = options.artifacts.split(',').map((a) => a.trim());\n\n            // Validate artifact IDs\n            const validIds = DEFAULT_ARTIFACTS.map((a) => a.id);\n            for (const id of selectedArtifactIds) {\n              if (!validIds.includes(id)) {\n                if (options?.json) {\n                  console.log(JSON.stringify({\n                    created: false,\n                    error: `Unknown artifact '${id}'`,\n                    valid: validIds,\n                  }, null, 2));\n                } else {\n                  console.error(`Error: Unknown artifact '${id}'`);\n                  console.error(`Valid artifacts: ${validIds.join(', ')}`);\n                }\n                process.exitCode = 1;\n                return;\n              }\n            }\n          } else {\n            // Default to all artifacts\n            selectedArtifactIds = DEFAULT_ARTIFACTS.map((a) => a.id);\n          }\n        }\n\n        // Create schema directory\n        if (spinner) spinner.start(`Creating schema '${name}'...`);\n        fs.mkdirSync(schemaDir, { recursive: true });\n\n        // Build artifacts array with proper dependencies\n        const selectedArtifacts = selectedArtifactIds.map((id) => {\n          const template = DEFAULT_ARTIFACTS.find((a) => a.id === id)!;\n          const artifact: Artifact = {\n            id: template.id,\n            generates: template.generates,\n            description: template.description,\n            template: template.template,\n            requires: [],\n          };\n\n          // Set up dependencies based on typical workflow\n          if (id === 'specs' && selectedArtifactIds.includes('proposal')) {\n            artifact.requires = ['proposal'];\n          } else if (id === 'design' && selectedArtifactIds.includes('specs')) {\n            artifact.requires = ['specs'];\n          } else if (id === 'tasks') {\n            const requires: string[] = [];\n            if (selectedArtifactIds.includes('design')) requires.push('design');\n            else if (selectedArtifactIds.includes('specs')) requires.push('specs');\n            artifact.requires = requires;\n          }\n\n          return artifact;\n        });\n\n        // Create schema.yaml\n        const schema: SchemaYaml = {\n          name,\n          version: 1,\n          description,\n          artifacts: selectedArtifacts,\n        };\n\n        // Add apply phase if tasks is included\n        if (selectedArtifactIds.includes('tasks')) {\n          schema.apply = {\n            requires: ['tasks'],\n            tracks: 'tasks.md',\n          };\n        }\n\n        fs.writeFileSync(\n          path.join(schemaDir, 'schema.yaml'),\n          stringifyYaml(schema)\n        );\n\n        // Create template files in templates/ subdirectory (standard location)\n        const templatesDir = path.join(schemaDir, 'templates');\n        for (const artifact of selectedArtifacts) {\n          const templatePath = path.join(templatesDir, artifact.template);\n          const templateDir = path.dirname(templatePath);\n\n          if (!fs.existsSync(templateDir)) {\n            fs.mkdirSync(templateDir, { recursive: true });\n          }\n\n          // Create default template content\n          const templateContent = createDefaultTemplate(artifact.id);\n          fs.writeFileSync(templatePath, templateContent);\n        }\n\n        // Update config if --default\n        if (options?.default) {\n          const configPath = path.join(projectRoot, 'openspec', 'config.yaml');\n\n          if (fs.existsSync(configPath)) {\n            const { parse: parseYaml, stringify: stringifyYaml2 } = await import('yaml');\n            const configContent = fs.readFileSync(configPath, 'utf-8');\n            const config = parseYaml(configContent) || {};\n            config.defaultSchema = name;\n            fs.writeFileSync(configPath, stringifyYaml2(config));\n          } else {\n            // Create config file\n            const configDir = path.dirname(configPath);\n            if (!fs.existsSync(configDir)) {\n              fs.mkdirSync(configDir, { recursive: true });\n            }\n            fs.writeFileSync(configPath, stringifyYaml({ defaultSchema: name }));\n          }\n        }\n\n        if (spinner) spinner.succeed(`Created schema '${name}'`);\n\n        if (options?.json) {\n          console.log(JSON.stringify({\n            created: true,\n            path: schemaDir,\n            schema: name,\n            artifacts: selectedArtifactIds,\n            setAsDefault: options?.default || false,\n          }, null, 2));\n        } else {\n          console.log(`\\nSchema created at: ${schemaDir}`);\n          console.log(`\\nArtifacts: ${selectedArtifactIds.join(', ')}`);\n          if (options?.default) {\n            console.log(`\\nSet as project default schema.`);\n          }\n          console.log(`\\nNext steps:`);\n          console.log(`  1. Edit ${schemaDir}/schema.yaml to customize artifacts`);\n          console.log(`  2. Modify templates in the schema directory`);\n          console.log(`  3. Use with: openspec new --schema ${name}`);\n        }\n      } catch (error) {\n        if (spinner) spinner.fail(`Creation failed`);\n        if (options?.json) {\n          console.log(JSON.stringify({\n            created: false,\n            error: (error as Error).message,\n          }, null, 2));\n        } else {\n          console.error(`Error: ${(error as Error).message}`);\n        }\n        process.exitCode = 1;\n      }\n    });\n}\n\n/**\n * Create default template content for an artifact.\n */\nfunction createDefaultTemplate(artifactId: string): string {\n  switch (artifactId) {\n    case 'proposal':\n      return `## Why\n\n<!-- Describe the motivation for this change -->\n\n## What Changes\n\n<!-- Describe what will change -->\n\n## Capabilities\n\n### New Capabilities\n<!-- List new capabilities -->\n\n### Modified Capabilities\n<!-- List modified capabilities -->\n\n## Impact\n\n<!-- Describe the impact on existing functionality -->\n`;\n\n    case 'specs':\n      return `## ADDED Requirements\n\n### Requirement: Example requirement\n\nDescription of the requirement.\n\n#### Scenario: Example scenario\n- **WHEN** some condition\n- **THEN** some outcome\n`;\n\n    case 'design':\n      return `## Context\n\n<!-- Background and context -->\n\n## Goals / Non-Goals\n\n**Goals:**\n<!-- List goals -->\n\n**Non-Goals:**\n<!-- List non-goals -->\n\n## Decisions\n\n### 1. Decision Name\n\nDescription and rationale.\n\n**Alternatives considered:**\n- Alternative 1: Rejected because...\n\n## Risks / Trade-offs\n\n<!-- List risks and trade-offs -->\n`;\n\n    case 'tasks':\n      return `## Implementation Tasks\n\n- [ ] Task 1\n- [ ] Task 2\n- [ ] Task 3\n`;\n\n    default:\n      return `## ${artifactId}\n\n<!-- Add content here -->\n`;\n  }\n}\n"
  },
  {
    "path": "src/commands/show.ts",
    "content": "import path from 'path';\nimport { isInteractive } from '../utils/interactive.js';\nimport { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';\nimport { ChangeCommand } from './change.js';\nimport { SpecCommand } from './spec.js';\nimport { nearestMatches } from '../utils/match.js';\n\ntype ItemType = 'change' | 'spec';\n\nconst CHANGE_FLAG_KEYS = new Set(['deltasOnly', 'requirementsOnly']);\nconst SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']);\n\nexport class ShowCommand {\n  async execute(itemName?: string, options: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any } = {}): Promise<void> {\n    const interactive = isInteractive(options);\n    const typeOverride = this.normalizeType(options.type);\n\n    if (!itemName) {\n      if (interactive) {\n        const { select } = await import('@inquirer/prompts');\n        const type = await select<ItemType>({\n          message: 'What would you like to show?',\n          choices: [\n            { name: 'Change', value: 'change' as const },\n            { name: 'Spec', value: 'spec' as const },\n          ],\n        });\n        await this.runInteractiveByType(type, options);\n        return;\n      }\n      this.printNonInteractiveHint();\n      process.exitCode = 1;\n      return;\n    }\n\n    await this.showDirect(itemName, { typeOverride, options });\n  }\n\n  private normalizeType(value?: string): ItemType | undefined {\n    if (!value) return undefined;\n    const v = value.toLowerCase();\n    if (v === 'change' || v === 'spec') return v;\n    return undefined;\n  }\n\n  private async runInteractiveByType(type: ItemType, options: { json?: boolean; noInteractive?: boolean; [k: string]: any }): Promise<void> {\n    const { select } = await import('@inquirer/prompts');\n    if (type === 'change') {\n      const changes = await getActiveChangeIds();\n      if (changes.length === 0) {\n        console.error('No changes found.');\n        process.exitCode = 1;\n        return;\n      }\n      const picked = await select<string>({ message: 'Pick a change', choices: changes.map(id => ({ name: id, value: id })) });\n      const cmd = new ChangeCommand();\n      await cmd.show(picked, options as any);\n      return;\n    }\n\n    const specs = await getSpecIds();\n    if (specs.length === 0) {\n      console.error('No specs found.');\n      process.exitCode = 1;\n      return;\n    }\n    const picked = await select<string>({ message: 'Pick a spec', choices: specs.map(id => ({ name: id, value: id })) });\n    const cmd = new SpecCommand();\n    await cmd.show(picked, options as any);\n  }\n\n  private async showDirect(itemName: string, params: { typeOverride?: ItemType; options: { json?: boolean; [k: string]: any } }): Promise<void> {\n    // Optimize lookups when type is pre-specified\n    let isChange = false;\n    let isSpec = false;\n    let changes: string[] = [];\n    let specs: string[] = [];\n    if (params.typeOverride === 'change') {\n      changes = await getActiveChangeIds();\n      isChange = changes.includes(itemName);\n    } else if (params.typeOverride === 'spec') {\n      specs = await getSpecIds();\n      isSpec = specs.includes(itemName);\n    } else {\n      [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);\n      isChange = changes.includes(itemName);\n      isSpec = specs.includes(itemName);\n    }\n\n    const resolvedType = params.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined);\n\n    if (!resolvedType) {\n      console.error(`Unknown item '${itemName}'`);\n      const suggestions = nearestMatches(itemName, [...changes, ...specs]);\n      if (suggestions.length) console.error(`Did you mean: ${suggestions.join(', ')}?`);\n      process.exitCode = 1;\n      return;\n    }\n\n    if (!params.typeOverride && isChange && isSpec) {\n      console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`);\n      console.error('Pass --type change|spec, or use: openspec change show / openspec spec show');\n      process.exitCode = 1;\n      return;\n    }\n\n    this.warnIrrelevantFlags(resolvedType, params.options);\n    if (resolvedType === 'change') {\n      const cmd = new ChangeCommand();\n      await cmd.show(itemName, params.options as any);\n      return;\n    }\n    const cmd = new SpecCommand();\n    await cmd.show(itemName, params.options as any);\n  }\n\n  private printNonInteractiveHint(): void {\n    console.error('Nothing to show. Try one of:');\n    console.error('  openspec show <item>');\n    console.error('  openspec change show');\n    console.error('  openspec spec show');\n    console.error('Or run in an interactive terminal.');\n  }\n\n  private warnIrrelevantFlags(type: ItemType, options: { [k: string]: any }): boolean {\n    const irrelevant: string[] = [];\n    if (type === 'change') {\n      for (const k of SPEC_FLAG_KEYS) if (k in options) irrelevant.push(k);\n    } else {\n      for (const k of CHANGE_FLAG_KEYS) if (k in options) irrelevant.push(k);\n    }\n    if (irrelevant.length > 0) {\n      console.error(`Warning: Ignoring flags not applicable to ${type}: ${irrelevant.join(', ')}`);\n      return true;\n    }\n    return false;\n  }\n}\n"
  },
  {
    "path": "src/commands/spec.ts",
    "content": "import { program } from 'commander';\nimport { existsSync, readdirSync, readFileSync } from 'fs';\nimport { join } from 'path';\nimport { MarkdownParser } from '../core/parsers/markdown-parser.js';\nimport { Validator } from '../core/validation/validator.js';\nimport type { Spec } from '../core/schemas/index.js';\nimport { isInteractive } from '../utils/interactive.js';\nimport { getSpecIds } from '../utils/item-discovery.js';\n\nconst SPECS_DIR = 'openspec/specs';\n\ninterface ShowOptions {\n  json?: boolean;\n  // JSON-only filters (raw-first text has no filters)\n  requirements?: boolean;\n  scenarios?: boolean; // --no-scenarios sets this to false (JSON only)\n  requirement?: string; // JSON only\n  noInteractive?: boolean;\n}\n\nfunction parseSpecFromFile(specPath: string, specId: string): Spec {\n  const content = readFileSync(specPath, 'utf-8');\n  const parser = new MarkdownParser(content);\n  return parser.parseSpec(specId);\n}\n\nfunction validateRequirementIndex(spec: Spec, requirementOpt?: string): number | undefined {\n  if (!requirementOpt) return undefined;\n  const index = Number.parseInt(requirementOpt, 10);\n  if (!Number.isInteger(index) || index < 1 || index > spec.requirements.length) {\n    throw new Error(`Requirement ${requirementOpt} not found`);\n  }\n  return index - 1; // convert to 0-based\n}\n\nfunction filterSpec(spec: Spec, options: ShowOptions): Spec {\n  const requirementIndex = validateRequirementIndex(spec, options.requirement);\n  const includeScenarios = options.scenarios !== false && !options.requirements;\n\n  const filteredRequirements = (requirementIndex !== undefined\n    ? [spec.requirements[requirementIndex]]\n    : spec.requirements\n  ).map(req => ({\n    text: req.text,\n    scenarios: includeScenarios ? req.scenarios : [],\n  }));\n\n  const metadata = spec.metadata ?? { version: '1.0.0', format: 'openspec' as const };\n\n  return {\n    name: spec.name,\n    overview: spec.overview,\n    requirements: filteredRequirements,\n    metadata,\n  };\n}\n\n/**\n * Print the raw markdown content for a spec file without any formatting.\n * Raw-first behavior ensures text mode is a passthrough for deterministic output.\n */\nfunction printSpecTextRaw(specPath: string): void {\n  const content = readFileSync(specPath, 'utf-8');\n  console.log(content);\n}\n\nexport class SpecCommand {\n  private SPECS_DIR = 'openspec/specs';\n\n  async show(specId?: string, options: ShowOptions = {}): Promise<void> {\n    if (!specId) {\n      const canPrompt = isInteractive(options);\n      const specIds = await getSpecIds();\n      if (canPrompt && specIds.length > 0) {\n        const { select } = await import('@inquirer/prompts');\n        specId = await select({\n          message: 'Select a spec to show',\n          choices: specIds.map(id => ({ name: id, value: id })),\n        });\n      } else {\n        throw new Error('Missing required argument <spec-id>');\n      }\n    }\n\n    const specPath = join(this.SPECS_DIR, specId, 'spec.md');\n    if (!existsSync(specPath)) {\n      throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);\n    }\n\n    if (options.json) {\n      if (options.requirements && options.requirement) {\n        throw new Error('Options --requirements and --requirement cannot be used together');\n      }\n      const parsed = parseSpecFromFile(specPath, specId);\n      const filtered = filterSpec(parsed, options);\n      const output = {\n        id: specId,\n        title: parsed.name,\n        overview: parsed.overview,\n        requirementCount: filtered.requirements.length,\n        requirements: filtered.requirements,\n        metadata: parsed.metadata ?? { version: '1.0.0', format: 'openspec' as const },\n      };\n      console.log(JSON.stringify(output, null, 2));\n      return;\n    }\n    printSpecTextRaw(specPath);\n  }\n}\n\nexport function registerSpecCommand(rootProgram: typeof program) {\n  const specCommand = rootProgram\n    .command('spec')\n    .description('Manage and view OpenSpec specifications');\n\n  // Deprecation notice for noun-based commands\n  specCommand.hook('preAction', () => {\n    console.error('Warning: The \"openspec spec ...\" commands are deprecated. Prefer verb-first commands (e.g., \"openspec show\", \"openspec validate --specs\").');\n  });\n\n  specCommand\n    .command('show [spec-id]')\n    .description('Display a specific specification')\n    .option('--json', 'Output as JSON')\n    .option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')\n    .option('--no-scenarios', 'JSON only: Exclude scenario content')\n    .option('-r, --requirement <id>', 'JSON only: Show specific requirement by ID (1-based)')\n    .option('--no-interactive', 'Disable interactive prompts')\n    .action(async (specId: string | undefined, options: ShowOptions & { noInteractive?: boolean }) => {\n      try {\n        const cmd = new SpecCommand();\n        await cmd.show(specId, options as any);\n      } catch (error) {\n        console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);\n        process.exitCode = 1;\n      }\n    });\n\n  specCommand\n    .command('list')\n    .description('List all available specifications')\n    .option('--json', 'Output as JSON')\n    .option('--long', 'Show id and title with counts')\n    .action((options: { json?: boolean; long?: boolean }) => {\n      try {\n        if (!existsSync(SPECS_DIR)) {\n          console.log('No items found');\n          return;\n        }\n\n        const specs = readdirSync(SPECS_DIR, { withFileTypes: true })\n          .filter(dirent => dirent.isDirectory())\n          .map(dirent => {\n            const specPath = join(SPECS_DIR, dirent.name, 'spec.md');\n            if (existsSync(specPath)) {\n              try {\n                const spec = parseSpecFromFile(specPath, dirent.name);\n                \n                return {\n                  id: dirent.name,\n                  title: spec.name,\n                  requirementCount: spec.requirements.length\n                };\n              } catch {\n                return {\n                  id: dirent.name,\n                  title: dirent.name,\n                  requirementCount: 0\n                };\n              }\n            }\n            return null;\n          })\n          .filter((spec): spec is { id: string; title: string; requirementCount: number } => spec !== null)\n          .sort((a, b) => a.id.localeCompare(b.id));\n\n        if (options.json) {\n          console.log(JSON.stringify(specs, null, 2));\n        } else {\n          if (specs.length === 0) {\n            console.log('No items found');\n            return;\n          }\n          if (!options.long) {\n            specs.forEach(spec => console.log(spec.id));\n            return;\n          }\n          specs.forEach(spec => {\n            console.log(`${spec.id}: ${spec.title} [requirements ${spec.requirementCount}]`);\n          });\n        }\n      } catch (error) {\n        console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);\n        process.exitCode = 1;\n      }\n    });\n\n  specCommand\n    .command('validate [spec-id]')\n    .description('Validate a specification structure')\n    .option('--strict', 'Enable strict validation mode')\n    .option('--json', 'Output validation report as JSON')\n    .option('--no-interactive', 'Disable interactive prompts')\n    .action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {\n      try {\n        if (!specId) {\n          const canPrompt = isInteractive(options);\n          const specIds = await getSpecIds();\n          if (canPrompt && specIds.length > 0) {\n            const { select } = await import('@inquirer/prompts');\n            specId = await select({\n              message: 'Select a spec to validate',\n              choices: specIds.map(id => ({ name: id, value: id })),\n            });\n          } else {\n            throw new Error('Missing required argument <spec-id>');\n          }\n        }\n\n        const specPath = join(SPECS_DIR, specId, 'spec.md');\n        \n        if (!existsSync(specPath)) {\n          throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);\n        }\n\n        const validator = new Validator(options.strict);\n        const report = await validator.validateSpec(specPath);\n\n        if (options.json) {\n          console.log(JSON.stringify(report, null, 2));\n        } else {\n          if (report.valid) {\n            console.log(`Specification '${specId}' is valid`);\n          } else {\n            console.error(`Specification '${specId}' has issues`);\n            report.issues.forEach(issue => {\n              const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;\n              const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';\n              console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);\n            });\n          }\n        }\n        process.exitCode = report.valid ? 0 : 1;\n      } catch (error) {\n        console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);\n        process.exitCode = 1;\n      }\n    });\n\n  return specCommand;\n}\n"
  },
  {
    "path": "src/commands/validate.ts",
    "content": "import ora from 'ora';\nimport path from 'path';\nimport { Validator } from '../core/validation/validator.js';\nimport { isInteractive, resolveNoInteractive } from '../utils/interactive.js';\nimport { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';\nimport { nearestMatches } from '../utils/match.js';\n\ntype ItemType = 'change' | 'spec';\n\ninterface ExecuteOptions {\n  all?: boolean;\n  changes?: boolean;\n  specs?: boolean;\n  type?: string;\n  strict?: boolean;\n  json?: boolean;\n  noInteractive?: boolean;\n  interactive?: boolean; // Commander sets this to false when --no-interactive is used\n  concurrency?: string;\n}\n\ninterface BulkItemResult {\n  id: string;\n  type: ItemType;\n  valid: boolean;\n  issues: { level: 'ERROR' | 'WARNING' | 'INFO'; path: string; message: string }[];\n  durationMs: number;\n}\n\nexport class ValidateCommand {\n  async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise<void> {\n    const interactive = isInteractive(options);\n\n    // Handle bulk flags first\n    if (options.all || options.changes || options.specs) {\n      await this.runBulkValidation({\n        changes: !!options.all || !!options.changes,\n        specs: !!options.all || !!options.specs,\n      }, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) });\n      return;\n    }\n\n    // No item and no flags\n    if (!itemName) {\n      if (interactive) {\n        await this.runInteractiveSelector({ strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });\n        return;\n      }\n      this.printNonInteractiveHint();\n      process.exitCode = 1;\n      return;\n    }\n\n    // Direct item validation with type detection or override\n    const typeOverride = this.normalizeType(options.type);\n    await this.validateDirectItem(itemName, { typeOverride, strict: !!options.strict, json: !!options.json });\n  }\n\n  private normalizeType(value?: string): ItemType | undefined {\n    if (!value) return undefined;\n    const v = value.toLowerCase();\n    if (v === 'change' || v === 'spec') return v;\n    return undefined;\n  }\n\n  private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {\n    const { select } = await import('@inquirer/prompts');\n    const choice = await select({\n      message: 'What would you like to validate?',\n      choices: [\n        { name: 'All (changes + specs)', value: 'all' },\n        { name: 'All changes', value: 'changes' },\n        { name: 'All specs', value: 'specs' },\n        { name: 'Pick a specific change or spec', value: 'one' },\n      ],\n    });\n\n    if (choice === 'all') return this.runBulkValidation({ changes: true, specs: true }, opts);\n    if (choice === 'changes') return this.runBulkValidation({ changes: true, specs: false }, opts);\n    if (choice === 'specs') return this.runBulkValidation({ changes: false, specs: true }, opts);\n\n    // one\n    const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);\n    const items: { name: string; value: { type: ItemType; id: string } }[] = [];\n    items.push(...changes.map(id => ({ name: `change/${id}`, value: { type: 'change' as const, id } })));\n    items.push(...specs.map(id => ({ name: `spec/${id}`, value: { type: 'spec' as const, id } })));\n    if (items.length === 0) {\n      console.error('No items found to validate.');\n      process.exitCode = 1;\n      return;\n    }\n    const picked = await select<{ type: ItemType; id: string }>({ message: 'Pick an item', choices: items });\n    await this.validateByType(picked.type, picked.id, opts);\n  }\n\n  private printNonInteractiveHint(): void {\n    console.error('Nothing to validate. Try one of:');\n    console.error('  openspec validate --all');\n    console.error('  openspec validate --changes');\n    console.error('  openspec validate --specs');\n    console.error('  openspec validate <item-name>');\n    console.error('Or run in an interactive terminal.');\n  }\n\n  private async validateDirectItem(itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise<void> {\n    const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);\n    const isChange = changes.includes(itemName);\n    const isSpec = specs.includes(itemName);\n\n    const type = opts.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined);\n\n    if (!type) {\n      console.error(`Unknown item '${itemName}'`);\n      const suggestions = nearestMatches(itemName, [...changes, ...specs]);\n      if (suggestions.length) console.error(`Did you mean: ${suggestions.join(', ')}?`);\n      process.exitCode = 1;\n      return;\n    }\n\n    if (!opts.typeOverride && isChange && isSpec) {\n      console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`);\n      console.error('Pass --type change|spec, or use: openspec change validate / openspec spec validate');\n      process.exitCode = 1;\n      return;\n    }\n\n    await this.validateByType(type, itemName, opts);\n  }\n\n  private async validateByType(type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise<void> {\n    const validator = new Validator(opts.strict);\n    if (type === 'change') {\n      const changeDir = path.join(process.cwd(), 'openspec', 'changes', id);\n      const start = Date.now();\n      const report = await validator.validateChangeDeltaSpecs(changeDir);\n      const durationMs = Date.now() - start;\n      this.printReport('change', id, report, durationMs, opts.json);\n      // Non-zero exit if invalid (keeps enriched output test semantics)\n      process.exitCode = report.valid ? 0 : 1;\n      return;\n    }\n    const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md');\n    const start = Date.now();\n    const report = await validator.validateSpec(file);\n    const durationMs = Date.now() - start;\n    this.printReport('spec', id, report, durationMs, opts.json);\n    process.exitCode = report.valid ? 0 : 1;\n  }\n\n  private printReport(type: ItemType, id: string, report: { valid: boolean; issues: any[] }, durationMs: number, json: boolean): void {\n    if (json) {\n      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' };\n      console.log(JSON.stringify(out, null, 2));\n      return;\n    }\n    if (report.valid) {\n      console.log(`${type === 'change' ? 'Change' : 'Specification'} '${id}' is valid`);\n    } else {\n      console.error(`${type === 'change' ? 'Change' : 'Specification'} '${id}' has issues`);\n      for (const issue of report.issues) {\n        const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;\n        const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';\n        console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);\n      }\n      this.printNextSteps(type);\n    }\n  }\n\n  private printNextSteps(type: ItemType): void {\n    const bullets: string[] = [];\n    if (type === 'change') {\n      bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements');\n      bullets.push('- Each requirement MUST include at least one #### Scenario: block');\n      bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only');\n    } else {\n      bullets.push('- Ensure spec includes ## Purpose and ## Requirements sections');\n      bullets.push('- Each requirement MUST include at least one #### Scenario: block');\n      bullets.push('- Re-run with --json to see structured report');\n    }\n    console.error('Next steps:');\n    bullets.forEach(b => console.error(`  ${b}`));\n  }\n\n  private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise<void> {\n    const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;\n    const [changeIds, specIds] = await Promise.all([\n      scope.changes ? getActiveChangeIds() : Promise.resolve<string[]>([]),\n      scope.specs ? getSpecIds() : Promise.resolve<string[]>([]),\n    ]);\n\n    const DEFAULT_CONCURRENCY = 6;\n    const maxSuggestions = 5; // used by nearestMatches\n    const concurrency = normalizeConcurrency(opts.concurrency) ?? normalizeConcurrency(process.env.OPENSPEC_CONCURRENCY) ?? DEFAULT_CONCURRENCY;\n    const validator = new Validator(opts.strict);\n    const queue: Array<() => Promise<BulkItemResult>> = [];\n\n    for (const id of changeIds) {\n      queue.push(async () => {\n        const start = Date.now();\n        const changeDir = path.join(process.cwd(), 'openspec', 'changes', id);\n        const report = await validator.validateChangeDeltaSpecs(changeDir);\n        const durationMs = Date.now() - start;\n        return { id, type: 'change' as const, valid: report.valid, issues: report.issues, durationMs };\n      });\n    }\n    for (const id of specIds) {\n      queue.push(async () => {\n        const start = Date.now();\n        const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md');\n        const report = await validator.validateSpec(file);\n        const durationMs = Date.now() - start;\n        return { id, type: 'spec' as const, valid: report.valid, issues: report.issues, durationMs };\n      });\n    }\n\n    if (queue.length === 0) {\n      spinner?.stop();\n\n      const summary = {\n        totals: { items: 0, passed: 0, failed: 0 },\n        byType: {\n          ...(scope.changes ? { change: { items: 0, passed: 0, failed: 0 } } : {}),\n          ...(scope.specs ? { spec: { items: 0, passed: 0, failed: 0 } } : {}),\n        },\n      } as const;\n\n      if (opts.json) {\n        const out = { items: [] as BulkItemResult[], summary, version: '1.0' };\n        console.log(JSON.stringify(out, null, 2));\n      } else {\n        console.log('No items found to validate.');\n      }\n\n      process.exitCode = 0;\n      return;\n    }\n\n    const results: BulkItemResult[] = [];\n    let index = 0;\n    let running = 0;\n    let passed = 0;\n    let failed = 0;\n\n    await new Promise<void>((resolve) => {\n      const next = () => {\n        while (running < concurrency && index < queue.length) {\n          const currentIndex = index++;\n          const task = queue[currentIndex];\n          running++;\n          if (spinner) spinner.text = `Validating (${currentIndex + 1}/${queue.length})...`;\n          task()\n            .then(res => {\n              results.push(res);\n              if (res.valid) passed++; else failed++;\n            })\n            .catch((error: any) => {\n              const message = error?.message || 'Unknown error';\n              const res: BulkItemResult = { id: getPlannedId(currentIndex, changeIds, specIds) ?? 'unknown', type: getPlannedType(currentIndex, changeIds, specIds) ?? 'change', valid: false, issues: [{ level: 'ERROR', path: 'file', message }], durationMs: 0 };\n              results.push(res);\n              failed++;\n            })\n            .finally(() => {\n              running--;\n              if (index >= queue.length && running === 0) resolve();\n              else next();\n            });\n        }\n      };\n      next();\n    });\n\n    spinner?.stop();\n\n    results.sort((a, b) => a.id.localeCompare(b.id));\n    const summary = {\n      totals: { items: results.length, passed, failed },\n      byType: {\n        ...(scope.changes ? { change: summarizeType(results, 'change') } : {}),\n        ...(scope.specs ? { spec: summarizeType(results, 'spec') } : {}),\n      },\n    } as const;\n\n    if (opts.json) {\n      const out = { items: results, summary, version: '1.0' };\n      console.log(JSON.stringify(out, null, 2));\n    } else {\n      for (const res of results) {\n        if (res.valid) console.log(`✓ ${res.type}/${res.id}`);\n        else console.error(`✗ ${res.type}/${res.id}`);\n      }\n      console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);\n    }\n\n    process.exitCode = failed > 0 ? 1 : 0;\n  }\n}\n\nfunction summarizeType(results: BulkItemResult[], type: ItemType) {\n  const filtered = results.filter(r => r.type === type);\n  const items = filtered.length;\n  const passed = filtered.filter(r => r.valid).length;\n  const failed = items - passed;\n  return { items, passed, failed };\n}\n\nfunction normalizeConcurrency(value?: string): number | undefined {\n  if (!value) return undefined;\n  const n = parseInt(value, 10);\n  if (Number.isNaN(n) || n <= 0) return undefined;\n  return n;\n}\n\nfunction getPlannedId(index: number, changeIds: string[], specIds: string[]): string | undefined {\n  const totalChanges = changeIds.length;\n  if (index < totalChanges) return changeIds[index];\n  const specIndex = index - totalChanges;\n  return specIds[specIndex];\n}\n\nfunction getPlannedType(index: number, changeIds: string[], specIds: string[]): ItemType | undefined {\n  const totalChanges = changeIds.length;\n  if (index < totalChanges) return 'change';\n  const specIndex = index - totalChanges;\n  if (specIndex >= 0 && specIndex < specIds.length) return 'spec';\n  return undefined;\n}\n"
  },
  {
    "path": "src/commands/workflow/index.ts",
    "content": "/**\n * Workflow CLI Commands\n *\n * Commands for the artifact-driven workflow: status, instructions, templates, schemas, new change.\n */\n\nexport { statusCommand } from './status.js';\nexport type { StatusOptions } from './status.js';\n\nexport { instructionsCommand, applyInstructionsCommand } from './instructions.js';\nexport type { InstructionsOptions } from './instructions.js';\n\nexport { templatesCommand } from './templates.js';\nexport type { TemplatesOptions } from './templates.js';\n\nexport { schemasCommand } from './schemas.js';\nexport type { SchemasOptions } from './schemas.js';\n\nexport { newChangeCommand } from './new-change.js';\nexport type { NewChangeOptions } from './new-change.js';\n\nexport { DEFAULT_SCHEMA } from './shared.js';\n"
  },
  {
    "path": "src/commands/workflow/instructions.ts",
    "content": "/**\n * Instructions Command\n *\n * Generates enriched instructions for creating artifacts or applying tasks.\n * Includes both artifact instructions and apply instructions.\n */\n\nimport ora from 'ora';\nimport path from 'path';\nimport * as fs from 'fs';\nimport {\n  loadChangeContext,\n  generateInstructions,\n  resolveSchema,\n  type ArtifactInstructions,\n} from '../../core/artifact-graph/index.js';\nimport {\n  validateChangeExists,\n  validateSchemaExists,\n  type TaskItem,\n  type ApplyInstructions,\n} from './shared.js';\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\nexport interface InstructionsOptions {\n  change?: string;\n  schema?: string;\n  json?: boolean;\n}\n\nexport interface ApplyInstructionsOptions {\n  change?: string;\n  schema?: string;\n  json?: boolean;\n}\n\n// -----------------------------------------------------------------------------\n// Artifact Instructions Command\n// -----------------------------------------------------------------------------\n\nexport async function instructionsCommand(\n  artifactId: string | undefined,\n  options: InstructionsOptions\n): Promise<void> {\n  const spinner = ora('Generating instructions...').start();\n\n  try {\n    const projectRoot = process.cwd();\n    const changeName = await validateChangeExists(options.change, projectRoot);\n\n    // Validate schema if explicitly provided\n    if (options.schema) {\n      validateSchemaExists(options.schema, projectRoot);\n    }\n\n    // loadChangeContext will auto-detect schema from metadata if not provided\n    const context = loadChangeContext(projectRoot, changeName, options.schema);\n\n    if (!artifactId) {\n      spinner.stop();\n      const validIds = context.graph.getAllArtifacts().map((a) => a.id);\n      throw new Error(\n        `Missing required argument <artifact>. Valid artifacts:\\n  ${validIds.join('\\n  ')}`\n      );\n    }\n\n    const artifact = context.graph.getArtifact(artifactId);\n\n    if (!artifact) {\n      spinner.stop();\n      const validIds = context.graph.getAllArtifacts().map((a) => a.id);\n      throw new Error(\n        `Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\\n  ${validIds.join('\\n  ')}`\n      );\n    }\n\n    const instructions = generateInstructions(context, artifactId, projectRoot);\n    const isBlocked = instructions.dependencies.some((d) => !d.done);\n\n    spinner.stop();\n\n    if (options.json) {\n      console.log(JSON.stringify(instructions, null, 2));\n      return;\n    }\n\n    printInstructionsText(instructions, isBlocked);\n  } catch (error) {\n    spinner.stop();\n    throw error;\n  }\n}\n\nexport function printInstructionsText(instructions: ArtifactInstructions, isBlocked: boolean): void {\n  const {\n    artifactId,\n    changeName,\n    schemaName,\n    changeDir,\n    outputPath,\n    description,\n    instruction,\n    context,\n    rules,\n    template,\n    dependencies,\n    unlocks,\n  } = instructions;\n\n  // Opening tag\n  console.log(`<artifact id=\"${artifactId}\" change=\"${changeName}\" schema=\"${schemaName}\">`);\n  console.log();\n\n  // Warning for blocked artifacts\n  if (isBlocked) {\n    const missing = dependencies.filter((d) => !d.done).map((d) => d.id);\n    console.log('<warning>');\n    console.log('This artifact has unmet dependencies. Complete them first or proceed with caution.');\n    console.log(`Missing: ${missing.join(', ')}`);\n    console.log('</warning>');\n    console.log();\n  }\n\n  // Task directive\n  console.log('<task>');\n  console.log(`Create the ${artifactId} artifact for change \"${changeName}\".`);\n  console.log(description);\n  console.log('</task>');\n  console.log();\n\n  // Project context (AI constraint - do not include in output)\n  if (context) {\n    console.log('<project_context>');\n    console.log('<!-- This is background information for you. Do NOT include this in your output. -->');\n    console.log(context);\n    console.log('</project_context>');\n    console.log();\n  }\n\n  // Rules (AI constraint - do not include in output)\n  if (rules && rules.length > 0) {\n    console.log('<rules>');\n    console.log('<!-- These are constraints for you to follow. Do NOT include this in your output. -->');\n    for (const rule of rules) {\n      console.log(`- ${rule}`);\n    }\n    console.log('</rules>');\n    console.log();\n  }\n\n  // Dependencies (files to read for context)\n  if (dependencies.length > 0) {\n    console.log('<dependencies>');\n    console.log('Read these files for context before creating this artifact:');\n    console.log();\n    for (const dep of dependencies) {\n      const status = dep.done ? 'done' : 'missing';\n      const fullPath = path.join(changeDir, dep.path);\n      console.log(`<dependency id=\"${dep.id}\" status=\"${status}\">`);\n      console.log(`  <path>${fullPath}</path>`);\n      console.log(`  <description>${dep.description}</description>`);\n      console.log('</dependency>');\n    }\n    console.log('</dependencies>');\n    console.log();\n  }\n\n  // Output location\n  console.log('<output>');\n  console.log(`Write to: ${path.join(changeDir, outputPath)}`);\n  console.log('</output>');\n  console.log();\n\n  // Instruction (guidance)\n  if (instruction) {\n    console.log('<instruction>');\n    console.log(instruction.trim());\n    console.log('</instruction>');\n    console.log();\n  }\n\n  // Template\n  console.log('<template>');\n  console.log('<!-- Use this as the structure for your output file. Fill in the sections. -->');\n  console.log(template.trim());\n  console.log('</template>');\n  console.log();\n\n  // Success criteria placeholder\n  console.log('<success_criteria>');\n  console.log('<!-- To be defined in schema validation rules -->');\n  console.log('</success_criteria>');\n  console.log();\n\n  // Unlocks\n  if (unlocks.length > 0) {\n    console.log('<unlocks>');\n    console.log(`Completing this artifact enables: ${unlocks.join(', ')}`);\n    console.log('</unlocks>');\n    console.log();\n  }\n\n  // Closing tag\n  console.log('</artifact>');\n}\n\n// -----------------------------------------------------------------------------\n// Apply Instructions Command\n// -----------------------------------------------------------------------------\n\n/**\n * Parses tasks.md content and extracts task items with their completion status.\n */\nfunction parseTasksFile(content: string): TaskItem[] {\n  const tasks: TaskItem[] = [];\n  const lines = content.split('\\n');\n  let taskIndex = 0;\n\n  for (const line of lines) {\n    // Match checkbox patterns: - [ ] or - [x] or - [X]\n    const checkboxMatch = line.match(/^[-*]\\s*\\[([ xX])\\]\\s*(.+)\\s*$/);\n    if (checkboxMatch) {\n      taskIndex++;\n      const done = checkboxMatch[1].toLowerCase() === 'x';\n      const description = checkboxMatch[2].trim();\n      tasks.push({\n        id: `${taskIndex}`,\n        description,\n        done,\n      });\n    }\n  }\n\n  return tasks;\n}\n\n/**\n * Checks if an artifact output exists in the change directory.\n * Supports glob patterns (e.g., \"specs/*.md\") by verifying at least one matching file exists.\n */\nfunction artifactOutputExists(changeDir: string, generates: string): boolean {\n  // Normalize the generates path to use platform-specific separators\n  const normalizedGenerates = generates.split('/').join(path.sep);\n  const fullPath = path.join(changeDir, normalizedGenerates);\n\n  // If it's a glob pattern (contains ** or *), check for matching files\n  if (generates.includes('*')) {\n    // Extract the directory part before the glob pattern\n    const parts = normalizedGenerates.split(path.sep);\n    const dirParts: string[] = [];\n    let patternPart = '';\n    for (const part of parts) {\n      if (part.includes('*')) {\n        patternPart = part;\n        break;\n      }\n      dirParts.push(part);\n    }\n    const dirPath = path.join(changeDir, ...dirParts);\n\n    // Check if directory exists\n    if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {\n      return false;\n    }\n\n    // Extract expected extension from pattern (e.g., \"*.md\" -> \".md\")\n    const extMatch = patternPart.match(/\\*(\\.[a-zA-Z0-9]+)$/);\n    const expectedExt = extMatch ? extMatch[1] : null;\n\n    // Recursively check for matching files\n    const hasMatchingFiles = (dir: string): boolean => {\n      try {\n        const entries = fs.readdirSync(dir, { withFileTypes: true });\n        for (const entry of entries) {\n          if (entry.isDirectory()) {\n            // For ** patterns, recurse into subdirectories\n            if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) {\n              return true;\n            }\n          } else if (entry.isFile()) {\n            // Check if file matches expected extension (or any file if no extension specified)\n            if (!expectedExt || entry.name.endsWith(expectedExt)) {\n              return true;\n            }\n          }\n        }\n      } catch {\n        return false;\n      }\n      return false;\n    };\n\n    return hasMatchingFiles(dirPath);\n  }\n\n  return fs.existsSync(fullPath);\n}\n\n/**\n * Generates apply instructions for implementing tasks from a change.\n * Schema-aware: reads apply phase configuration from schema to determine\n * required artifacts, tracking file, and instruction.\n */\nexport async function generateApplyInstructions(\n  projectRoot: string,\n  changeName: string,\n  schemaName?: string\n): Promise<ApplyInstructions> {\n  // loadChangeContext will auto-detect schema from metadata if not provided\n  const context = loadChangeContext(projectRoot, changeName, schemaName);\n  const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);\n\n  // Get the full schema to access the apply phase configuration\n  const schema = resolveSchema(context.schemaName, projectRoot);\n  const applyConfig = schema.apply;\n\n  // Determine required artifacts and tracking file from schema\n  // Fallback: if no apply block, require all artifacts\n  const requiredArtifactIds = applyConfig?.requires ?? schema.artifacts.map((a) => a.id);\n  const tracksFile = applyConfig?.tracks ?? null;\n  const schemaInstruction = applyConfig?.instruction ?? null;\n\n  // Check which required artifacts are missing\n  const missingArtifacts: string[] = [];\n  for (const artifactId of requiredArtifactIds) {\n    const artifact = schema.artifacts.find((a) => a.id === artifactId);\n    if (artifact && !artifactOutputExists(changeDir, artifact.generates)) {\n      missingArtifacts.push(artifactId);\n    }\n  }\n\n  // Build context files from all existing artifacts in schema\n  const contextFiles: Record<string, string> = {};\n  for (const artifact of schema.artifacts) {\n    if (artifactOutputExists(changeDir, artifact.generates)) {\n      contextFiles[artifact.id] = path.join(changeDir, artifact.generates);\n    }\n  }\n\n  // Parse tasks if tracking file exists\n  let tasks: TaskItem[] = [];\n  let tracksFileExists = false;\n  if (tracksFile) {\n    const tracksPath = path.join(changeDir, tracksFile);\n    tracksFileExists = fs.existsSync(tracksPath);\n    if (tracksFileExists) {\n      const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');\n      tasks = parseTasksFile(tasksContent);\n    }\n  }\n\n  // Calculate progress\n  const total = tasks.length;\n  const complete = tasks.filter((t) => t.done).length;\n  const remaining = total - complete;\n\n  // Determine state and instruction\n  let state: ApplyInstructions['state'];\n  let instruction: string;\n\n  if (missingArtifacts.length > 0) {\n    state = 'blocked';\n    instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\\nUse the openspec-continue-change skill to create the missing artifacts first.`;\n  } else if (tracksFile && !tracksFileExists) {\n    // Tracking file configured but doesn't exist yet\n    const tracksFilename = path.basename(tracksFile);\n    state = 'blocked';\n    instruction = `The ${tracksFilename} file is missing and must be created.\\nUse openspec-continue-change to generate the tracking file.`;\n  } else if (tracksFile && tracksFileExists && total === 0) {\n    // Tracking file exists but contains no tasks\n    const tracksFilename = path.basename(tracksFile);\n    state = 'blocked';\n    instruction = `The ${tracksFilename} file exists but contains no tasks.\\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;\n  } else if (tracksFile && remaining === 0 && total > 0) {\n    state = 'all_done';\n    instruction = 'All tasks are complete! This change is ready to be archived.\\nConsider running tests and reviewing the changes before archiving.';\n  } else if (!tracksFile) {\n    // No tracking file configured in schema - ready to apply\n    state = 'ready';\n    instruction = schemaInstruction?.trim() ?? 'All required artifacts complete. Proceed with implementation.';\n  } else {\n    state = 'ready';\n    instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\\nPause if you hit blockers or need clarification.';\n  }\n\n  return {\n    changeName,\n    changeDir,\n    schemaName: context.schemaName,\n    contextFiles,\n    progress: { total, complete, remaining },\n    tasks,\n    state,\n    missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined,\n    instruction,\n  };\n}\n\nexport async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise<void> {\n  const spinner = ora('Generating apply instructions...').start();\n\n  try {\n    const projectRoot = process.cwd();\n    const changeName = await validateChangeExists(options.change, projectRoot);\n\n    // Validate schema if explicitly provided\n    if (options.schema) {\n      validateSchemaExists(options.schema, projectRoot);\n    }\n\n    // generateApplyInstructions uses loadChangeContext which auto-detects schema\n    const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema);\n\n    spinner.stop();\n\n    if (options.json) {\n      console.log(JSON.stringify(instructions, null, 2));\n      return;\n    }\n\n    printApplyInstructionsText(instructions);\n  } catch (error) {\n    spinner.stop();\n    throw error;\n  }\n}\n\nexport function printApplyInstructionsText(instructions: ApplyInstructions): void {\n  const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions;\n\n  console.log(`## Apply: ${changeName}`);\n  console.log(`Schema: ${schemaName}`);\n  console.log();\n\n  // Warning for blocked state\n  if (state === 'blocked' && missingArtifacts) {\n    console.log('### ⚠️ Blocked');\n    console.log();\n    console.log(`Missing artifacts: ${missingArtifacts.join(', ')}`);\n    console.log('Use the openspec-continue-change skill to create these first.');\n    console.log();\n  }\n\n  // Context files (dynamically from schema)\n  const contextFileEntries = Object.entries(contextFiles);\n  if (contextFileEntries.length > 0) {\n    console.log('### Context Files');\n    for (const [artifactId, filePath] of contextFileEntries) {\n      console.log(`- ${artifactId}: ${filePath}`);\n    }\n    console.log();\n  }\n\n  // Progress (only show if we have tracking)\n  if (progress.total > 0 || tasks.length > 0) {\n    console.log('### Progress');\n    if (state === 'all_done') {\n      console.log(`${progress.complete}/${progress.total} complete ✓`);\n    } else {\n      console.log(`${progress.complete}/${progress.total} complete`);\n    }\n    console.log();\n  }\n\n  // Tasks\n  if (tasks.length > 0) {\n    console.log('### Tasks');\n    for (const task of tasks) {\n      const checkbox = task.done ? '[x]' : '[ ]';\n      console.log(`- ${checkbox} ${task.description}`);\n    }\n    console.log();\n  }\n\n  // Instruction\n  console.log('### Instruction');\n  console.log(instruction);\n}\n"
  },
  {
    "path": "src/commands/workflow/new-change.ts",
    "content": "/**\n * New Change Command\n *\n * Creates a new change directory with optional description and schema.\n */\n\nimport ora from 'ora';\nimport path from 'path';\nimport { createChange, validateChangeName } from '../../utils/change-utils.js';\nimport { validateSchemaExists } from './shared.js';\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\nexport interface NewChangeOptions {\n  description?: string;\n  schema?: string;\n}\n\n// -----------------------------------------------------------------------------\n// Command Implementation\n// -----------------------------------------------------------------------------\n\nexport async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> {\n  if (!name) {\n    throw new Error('Missing required argument <name>');\n  }\n\n  const validation = validateChangeName(name);\n  if (!validation.valid) {\n    throw new Error(validation.error);\n  }\n\n  const projectRoot = process.cwd();\n\n  // Validate schema if provided\n  if (options.schema) {\n    validateSchemaExists(options.schema, projectRoot);\n  }\n\n  const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : '';\n  const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start();\n\n  try {\n    const result = await createChange(projectRoot, name, { schema: options.schema });\n\n    // If description provided, create README.md with description\n    if (options.description) {\n      const { promises: fs } = await import('fs');\n      const changeDir = path.join(projectRoot, 'openspec', 'changes', name);\n      const readmePath = path.join(changeDir, 'README.md');\n      await fs.writeFile(readmePath, `# ${name}\\n\\n${options.description}\\n`, 'utf-8');\n    }\n\n    spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${result.schema})`);\n  } catch (error) {\n    spinner.fail(`Failed to create change '${name}'`);\n    throw error;\n  }\n}\n"
  },
  {
    "path": "src/commands/workflow/schemas.ts",
    "content": "/**\n * Schemas Command\n *\n * Lists available workflow schemas with descriptions.\n */\n\nimport chalk from 'chalk';\nimport { listSchemasWithInfo } from '../../core/artifact-graph/index.js';\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\nexport interface SchemasOptions {\n  json?: boolean;\n}\n\n// -----------------------------------------------------------------------------\n// Command Implementation\n// -----------------------------------------------------------------------------\n\nexport async function schemasCommand(options: SchemasOptions): Promise<void> {\n  const projectRoot = process.cwd();\n  const schemas = listSchemasWithInfo(projectRoot);\n\n  if (options.json) {\n    console.log(JSON.stringify(schemas, null, 2));\n    return;\n  }\n\n  console.log('Available schemas:');\n  console.log();\n\n  for (const schema of schemas) {\n    let sourceLabel = '';\n    if (schema.source === 'project') {\n      sourceLabel = chalk.cyan(' (project)');\n    } else if (schema.source === 'user') {\n      sourceLabel = chalk.dim(' (user override)');\n    }\n    console.log(`  ${chalk.bold(schema.name)}${sourceLabel}`);\n    console.log(`    ${schema.description}`);\n    console.log(`    Artifacts: ${schema.artifacts.join(' → ')}`);\n    console.log();\n  }\n}\n"
  },
  {
    "path": "src/commands/workflow/shared.ts",
    "content": "/**\n * Shared Types and Utilities for Artifact Workflow Commands\n *\n * This module contains types, constants, and validation helpers used across\n * multiple artifact workflow commands.\n */\n\nimport chalk from 'chalk';\nimport path from 'path';\nimport * as fs from 'fs';\nimport { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js';\nimport { validateChangeName } from '../../utils/change-utils.js';\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\nexport interface TaskItem {\n  id: string;\n  description: string;\n  done: boolean;\n}\n\nexport interface ApplyInstructions {\n  changeName: string;\n  changeDir: string;\n  schemaName: string;\n  contextFiles: Record<string, string>;\n  progress: {\n    total: number;\n    complete: number;\n    remaining: number;\n  };\n  tasks: TaskItem[];\n  state: 'blocked' | 'all_done' | 'ready';\n  missingArtifacts?: string[];\n  instruction: string;\n}\n\n// -----------------------------------------------------------------------------\n// Constants\n// -----------------------------------------------------------------------------\n\nexport const DEFAULT_SCHEMA = 'spec-driven';\n\n// -----------------------------------------------------------------------------\n// Utility Functions\n// -----------------------------------------------------------------------------\n\n/**\n * Checks if color output is disabled via NO_COLOR env or --no-color flag.\n */\nexport function isColorDisabled(): boolean {\n  return process.env.NO_COLOR === '1' || process.env.NO_COLOR === 'true';\n}\n\n/**\n * Gets the color function based on status.\n */\nexport function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: string) => string {\n  if (isColorDisabled()) {\n    return (text: string) => text;\n  }\n  switch (status) {\n    case 'done':\n      return chalk.green;\n    case 'ready':\n      return chalk.yellow;\n    case 'blocked':\n      return chalk.red;\n  }\n}\n\n/**\n * Gets the status indicator for an artifact.\n */\nexport function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string {\n  const color = getStatusColor(status);\n  switch (status) {\n    case 'done':\n      return color('[x]');\n    case 'ready':\n      return color('[ ]');\n    case 'blocked':\n      return color('[-]');\n  }\n}\n\n/**\n * Returns the list of available change directory names under openspec/changes/.\n * Excludes the archive directory and hidden directories.\n */\nexport async function getAvailableChanges(projectRoot: string): Promise<string[]> {\n  const changesPath = path.join(projectRoot, 'openspec', 'changes');\n  try {\n    const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });\n    return entries\n      .filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))\n      .map((e) => e.name);\n  } catch (error: unknown) {\n    if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];\n    throw error;\n  }\n}\n\n/**\n * Validates that a change exists and returns available changes if not.\n * Checks directory existence directly to support scaffolded changes (without proposal.md).\n */\nexport async function validateChangeExists(\n  changeName: string | undefined,\n  projectRoot: string\n): Promise<string> {\n  if (!changeName) {\n    const available = await getAvailableChanges(projectRoot);\n    if (available.length === 0) {\n      throw new Error('No changes found. Create one with: openspec new change <name>');\n    }\n    throw new Error(\n      `Missing required option --change. Available changes:\\n  ${available.join('\\n  ')}`\n    );\n  }\n\n  // Validate change name format to prevent path traversal\n  const nameValidation = validateChangeName(changeName);\n  if (!nameValidation.valid) {\n    throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`);\n  }\n\n  // Check directory existence directly\n  const changePath = path.join(projectRoot, 'openspec', 'changes', changeName);\n  const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory();\n\n  if (!exists) {\n    const available = await getAvailableChanges(projectRoot);\n    if (available.length === 0) {\n      throw new Error(\n        `Change '${changeName}' not found. No changes exist. Create one with: openspec new change <name>`\n      );\n    }\n    throw new Error(\n      `Change '${changeName}' not found. Available changes:\\n  ${available.join('\\n  ')}`\n    );\n  }\n\n  return changeName;\n}\n\n/**\n * Validates that a schema exists and returns available schemas if not.\n *\n * @param schemaName - The schema name to validate\n * @param projectRoot - Optional project root for project-local schema resolution\n */\nexport function validateSchemaExists(schemaName: string, projectRoot?: string): string {\n  const schemaDir = getSchemaDir(schemaName, projectRoot);\n  if (!schemaDir) {\n    const availableSchemas = listSchemas(projectRoot);\n    throw new Error(\n      `Schema '${schemaName}' not found. Available schemas:\\n  ${availableSchemas.join('\\n  ')}`\n    );\n  }\n  return schemaName;\n}\n"
  },
  {
    "path": "src/commands/workflow/status.ts",
    "content": "/**\n * Status Command\n *\n * Displays artifact completion status for a change.\n */\n\nimport ora from 'ora';\nimport chalk from 'chalk';\nimport {\n  loadChangeContext,\n  formatChangeStatus,\n  type ChangeStatus,\n} from '../../core/artifact-graph/index.js';\nimport {\n  validateChangeExists,\n  validateSchemaExists,\n  getAvailableChanges,\n  getStatusIndicator,\n  getStatusColor,\n} from './shared.js';\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\nexport interface StatusOptions {\n  change?: string;\n  schema?: string;\n  json?: boolean;\n}\n\n// -----------------------------------------------------------------------------\n// Command Implementation\n// -----------------------------------------------------------------------------\n\nexport async function statusCommand(options: StatusOptions): Promise<void> {\n  const spinner = ora('Loading change status...').start();\n\n  try {\n    const projectRoot = process.cwd();\n\n    // Handle no-changes case gracefully — status is informational,\n    // so \"no changes\" is a valid state, not an error.\n    if (!options.change) {\n      const available = await getAvailableChanges(projectRoot);\n      if (available.length === 0) {\n        spinner.stop();\n        if (options.json) {\n          console.log(JSON.stringify({ changes: [], message: 'No active changes.' }, null, 2));\n          return;\n        }\n        console.log('No active changes. Create one with: openspec new change <name>');\n        return;\n      }\n      // Changes exist but --change not provided\n      spinner.stop();\n      throw new Error(\n        `Missing required option --change. Available changes:\\n  ${available.join('\\n  ')}`\n      );\n    }\n\n    const changeName = await validateChangeExists(options.change, projectRoot);\n\n    // Validate schema if explicitly provided\n    if (options.schema) {\n      validateSchemaExists(options.schema, projectRoot);\n    }\n\n    // loadChangeContext will auto-detect schema from metadata if not provided\n    const context = loadChangeContext(projectRoot, changeName, options.schema);\n    const status = formatChangeStatus(context);\n\n    spinner.stop();\n\n    if (options.json) {\n      console.log(JSON.stringify(status, null, 2));\n      return;\n    }\n\n    printStatusText(status);\n  } catch (error) {\n    spinner.stop();\n    throw error;\n  }\n}\n\nexport function printStatusText(status: ChangeStatus): void {\n  const doneCount = status.artifacts.filter((a) => a.status === 'done').length;\n  const total = status.artifacts.length;\n\n  console.log(`Change: ${status.changeName}`);\n  console.log(`Schema: ${status.schemaName}`);\n  console.log(`Progress: ${doneCount}/${total} artifacts complete`);\n  console.log();\n\n  for (const artifact of status.artifacts) {\n    const indicator = getStatusIndicator(artifact.status);\n    const color = getStatusColor(artifact.status);\n    let line = `${indicator} ${artifact.id}`;\n\n    if (artifact.status === 'blocked' && artifact.missingDeps && artifact.missingDeps.length > 0) {\n      line += color(` (blocked by: ${artifact.missingDeps.join(', ')})`);\n    }\n\n    console.log(line);\n  }\n\n  if (status.isComplete) {\n    console.log();\n    console.log(chalk.green('All artifacts complete!'));\n  }\n}\n"
  },
  {
    "path": "src/commands/workflow/templates.ts",
    "content": "/**\n * Templates Command\n *\n * Shows resolved template paths for all artifacts in a schema.\n */\n\nimport ora from 'ora';\nimport path from 'path';\nimport {\n  resolveSchema,\n  getSchemaDir,\n  ArtifactGraph,\n} from '../../core/artifact-graph/index.js';\nimport { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js';\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\nexport interface TemplatesOptions {\n  schema?: string;\n  json?: boolean;\n}\n\nexport interface TemplateInfo {\n  artifactId: string;\n  templatePath: string;\n  source: 'project' | 'user' | 'package';\n}\n\n// -----------------------------------------------------------------------------\n// Command Implementation\n// -----------------------------------------------------------------------------\n\nexport async function templatesCommand(options: TemplatesOptions): Promise<void> {\n  const spinner = ora('Loading templates...').start();\n\n  try {\n    const projectRoot = process.cwd();\n    const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot);\n    const schema = resolveSchema(schemaName, projectRoot);\n    const graph = ArtifactGraph.fromSchema(schema);\n    const schemaDir = getSchemaDir(schemaName, projectRoot)!;\n\n    // Determine the source (project, user, or package)\n    const {\n      getUserSchemasDir,\n      getProjectSchemasDir,\n    } = await import('../../core/artifact-graph/resolver.js');\n    const projectSchemasDir = getProjectSchemasDir(projectRoot);\n    const userSchemasDir = getUserSchemasDir();\n\n    // Determine source by checking if schemaDir is inside each base directory\n    // Using path.relative is more robust than startsWith for path comparisons\n    const isInsideDir = (child: string, parent: string): boolean => {\n      const relative = path.relative(parent, child);\n      return !relative.startsWith('..') && !path.isAbsolute(relative);\n    };\n\n    let source: 'project' | 'user' | 'package';\n    if (isInsideDir(schemaDir, projectSchemasDir)) {\n      source = 'project';\n    } else if (isInsideDir(schemaDir, userSchemasDir)) {\n      source = 'user';\n    } else {\n      source = 'package';\n    }\n\n    const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({\n      artifactId: artifact.id,\n      templatePath: path.join(schemaDir, 'templates', artifact.template),\n      source,\n    }));\n\n    spinner.stop();\n\n    if (options.json) {\n      const output: Record<string, { path: string; source: string }> = {};\n      for (const t of templates) {\n        output[t.artifactId] = { path: t.templatePath, source: t.source };\n      }\n      console.log(JSON.stringify(output, null, 2));\n      return;\n    }\n\n    console.log(`Schema: ${schemaName}`);\n    console.log(`Source: ${source}`);\n    console.log();\n\n    for (const t of templates) {\n      console.log(`${t.artifactId}:`);\n      console.log(`  ${t.templatePath}`);\n    }\n  } catch (error) {\n    spinner.stop();\n    throw error;\n  }\n}\n"
  },
  {
    "path": "src/core/archive.ts",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\nimport { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';\nimport { Validator } from './validation/validator.js';\nimport chalk from 'chalk';\nimport {\n  findSpecUpdates,\n  buildUpdatedSpec,\n  writeUpdatedSpec,\n  type SpecUpdate,\n} from './specs-apply.js';\n\n/**\n * Recursively copy a directory. Used when fs.rename fails (e.g. EPERM on Windows).\n */\nasync function copyDirRecursive(src: string, dest: string): Promise<void> {\n  await fs.mkdir(dest, { recursive: true });\n  const entries = await fs.readdir(src, { withFileTypes: true });\n  for (const entry of entries) {\n    const srcPath = path.join(src, entry.name);\n    const destPath = path.join(dest, entry.name);\n    if (entry.isDirectory()) {\n      await copyDirRecursive(srcPath, destPath);\n    } else {\n      await fs.copyFile(srcPath, destPath);\n    }\n  }\n}\n\n/**\n * Move a directory from src to dest. On Windows, fs.rename() often fails with\n * EPERM when the directory is non-empty or another process has it open (IDE,\n * file watcher, antivirus). Fall back to copy-then-remove when rename fails\n * with EPERM or EXDEV.\n */\nasync function moveDirectory(src: string, dest: string): Promise<void> {\n  try {\n    await fs.rename(src, dest);\n  } catch (err: any) {\n    const code = err?.code;\n    if (code === 'EPERM' || code === 'EXDEV') {\n      await copyDirRecursive(src, dest);\n      await fs.rm(src, { recursive: true, force: true });\n    } else {\n      throw err;\n    }\n  }\n}\n\nexport class ArchiveCommand {\n  async execute(\n    changeName?: string,\n    options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean } = {}\n  ): Promise<void> {\n    const targetPath = '.';\n    const changesDir = path.join(targetPath, 'openspec', 'changes');\n    const archiveDir = path.join(changesDir, 'archive');\n    const mainSpecsDir = path.join(targetPath, 'openspec', 'specs');\n\n    // Check if changes directory exists\n    try {\n      await fs.access(changesDir);\n    } catch {\n      throw new Error(\"No OpenSpec changes directory found. Run 'openspec init' first.\");\n    }\n\n    // Get change name interactively if not provided\n    if (!changeName) {\n      const selectedChange = await this.selectChange(changesDir);\n      if (!selectedChange) {\n        console.log('No change selected. Aborting.');\n        return;\n      }\n      changeName = selectedChange;\n    }\n\n    const changeDir = path.join(changesDir, changeName);\n\n    // Verify change exists\n    try {\n      const stat = await fs.stat(changeDir);\n      if (!stat.isDirectory()) {\n        throw new Error(`Change '${changeName}' not found.`);\n      }\n    } catch {\n      throw new Error(`Change '${changeName}' not found.`);\n    }\n\n    const skipValidation = options.validate === false || options.noValidate === true;\n\n    // Validate specs and change before archiving\n    if (!skipValidation) {\n      const validator = new Validator();\n      let hasValidationErrors = false;\n\n      // Validate proposal.md (non-blocking unless strict mode desired in future)\n      const changeFile = path.join(changeDir, 'proposal.md');\n      try {\n        await fs.access(changeFile);\n        const changeReport = await validator.validateChange(changeFile);\n        // Proposal validation is informative only (do not block archive)\n        if (!changeReport.valid) {\n          console.log(chalk.yellow(`\\nProposal warnings in proposal.md (non-blocking):`));\n          for (const issue of changeReport.issues) {\n            const symbol = issue.level === 'ERROR' ? '⚠' : (issue.level === 'WARNING' ? '⚠' : 'ℹ');\n            console.log(chalk.yellow(`  ${symbol} ${issue.message}`));\n          }\n        }\n      } catch {\n        // Change file doesn't exist, skip validation\n      }\n\n      // Validate delta-formatted spec files under the change directory if present\n      const changeSpecsDir = path.join(changeDir, 'specs');\n      let hasDeltaSpecs = false;\n      try {\n        const candidates = await fs.readdir(changeSpecsDir, { withFileTypes: true });\n        for (const c of candidates) {\n          if (c.isDirectory()) {\n            try {\n              const candidatePath = path.join(changeSpecsDir, c.name, 'spec.md');\n              await fs.access(candidatePath);\n              const content = await fs.readFile(candidatePath, 'utf-8');\n              if (/^##\\s+(ADDED|MODIFIED|REMOVED|RENAMED)\\s+Requirements/m.test(content)) {\n                hasDeltaSpecs = true;\n                break;\n              }\n            } catch {}\n          }\n        }\n      } catch {}\n      if (hasDeltaSpecs) {\n        const deltaReport = await validator.validateChangeDeltaSpecs(changeDir);\n        if (!deltaReport.valid) {\n          hasValidationErrors = true;\n          console.log(chalk.red(`\\nValidation errors in change delta specs:`));\n          for (const issue of deltaReport.issues) {\n            if (issue.level === 'ERROR') {\n              console.log(chalk.red(`  ✗ ${issue.message}`));\n            } else if (issue.level === 'WARNING') {\n              console.log(chalk.yellow(`  ⚠ ${issue.message}`));\n            }\n          }\n        }\n      }\n\n      if (hasValidationErrors) {\n        console.log(chalk.red('\\nValidation failed. Please fix the errors before archiving.'));\n        console.log(chalk.yellow('To skip validation (not recommended), use --no-validate flag.'));\n        return;\n      }\n    } else {\n      // Log warning when validation is skipped\n      const timestamp = new Date().toISOString();\n      \n      if (!options.yes) {\n        const { confirm } = await import('@inquirer/prompts');\n        const proceed = await confirm({\n          message: chalk.yellow('⚠️  WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),\n          default: false\n        });\n        if (!proceed) {\n          console.log('Archive cancelled.');\n          return;\n        }\n      } else {\n        console.log(chalk.yellow(`\\n⚠️  WARNING: Skipping validation may archive invalid specs.`));\n      }\n      \n      console.log(chalk.yellow(`[${timestamp}] Validation skipped for change: ${changeName}`));\n      console.log(chalk.yellow(`Affected files: ${changeDir}`));\n    }\n\n    // Show progress and check for incomplete tasks\n    const progress = await getTaskProgressForChange(changesDir, changeName);\n    const status = formatTaskStatus(progress);\n    console.log(`Task status: ${status}`);\n\n    const incompleteTasks = Math.max(progress.total - progress.completed, 0);\n    if (incompleteTasks > 0) {\n      if (!options.yes) {\n        const { confirm } = await import('@inquirer/prompts');\n        const proceed = await confirm({\n          message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,\n          default: false\n        });\n        if (!proceed) {\n          console.log('Archive cancelled.');\n          return;\n        }\n      } else {\n        console.log(`Warning: ${incompleteTasks} incomplete task(s) found. Continuing due to --yes flag.`);\n      }\n    }\n\n    // Handle spec updates unless skipSpecs flag is set\n    if (options.skipSpecs) {\n      console.log('Skipping spec updates (--skip-specs flag provided).');\n    } else {\n      // Find specs to update\n      const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);\n      \n      if (specUpdates.length > 0) {\n        console.log('\\nSpecs to update:');\n        for (const update of specUpdates) {\n          const status = update.exists ? 'update' : 'create';\n          const capability = path.basename(path.dirname(update.target));\n          console.log(`  ${capability}: ${status}`);\n        }\n\n        let shouldUpdateSpecs = true;\n        if (!options.yes) {\n          const { confirm } = await import('@inquirer/prompts');\n          shouldUpdateSpecs = await confirm({\n            message: 'Proceed with spec updates?',\n            default: true\n          });\n          if (!shouldUpdateSpecs) {\n            console.log('Skipping spec updates. Proceeding with archive.');\n          }\n        }\n\n        if (shouldUpdateSpecs) {\n          // Prepare all updates first (validation pass, no writes)\n          const prepared: Array<{ update: SpecUpdate; rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> = [];\n          try {\n            for (const update of specUpdates) {\n              const built = await buildUpdatedSpec(update, changeName!);\n              prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });\n            }\n          } catch (err: any) {\n            console.log(String(err.message || err));\n            console.log('Aborted. No files were changed.');\n            return;\n          }\n\n          // All validations passed; pre-validate rebuilt full spec and then write files and display counts\n          let totals = { added: 0, modified: 0, removed: 0, renamed: 0 };\n          for (const p of prepared) {\n            const specName = path.basename(path.dirname(p.update.target));\n            if (!skipValidation) {\n              const report = await new Validator().validateSpecContent(specName, p.rebuilt);\n              if (!report.valid) {\n                console.log(chalk.red(`\\nValidation errors in rebuilt spec for ${specName} (will not write changes):`));\n                for (const issue of report.issues) {\n                  if (issue.level === 'ERROR') console.log(chalk.red(`  ✗ ${issue.message}`));\n                  else if (issue.level === 'WARNING') console.log(chalk.yellow(`  ⚠ ${issue.message}`));\n                }\n                console.log('Aborted. No files were changed.');\n                return;\n              }\n            }\n            await writeUpdatedSpec(p.update, p.rebuilt, p.counts);\n            totals.added += p.counts.added;\n            totals.modified += p.counts.modified;\n            totals.removed += p.counts.removed;\n            totals.renamed += p.counts.renamed;\n          }\n          console.log(\n            `Totals: + ${totals.added}, ~ ${totals.modified}, - ${totals.removed}, → ${totals.renamed}`\n          );\n          console.log('Specs updated successfully.');\n        }\n      }\n    }\n\n    // Create archive directory with date prefix\n    const archiveName = `${this.getArchiveDate()}-${changeName}`;\n    const archivePath = path.join(archiveDir, archiveName);\n\n    // Check if archive already exists\n    try {\n      await fs.access(archivePath);\n      throw new Error(`Archive '${archiveName}' already exists.`);\n    } catch (error: any) {\n      if (error.code !== 'ENOENT') {\n        throw error;\n      }\n    }\n\n    // Create archive directory if needed\n    await fs.mkdir(archiveDir, { recursive: true });\n\n    // Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows)\n    await moveDirectory(changeDir, archivePath);\n\n    console.log(`Change '${changeName}' archived as '${archiveName}'.`);\n  }\n\n  private async selectChange(changesDir: string): Promise<string | null> {\n    const { select } = await import('@inquirer/prompts');\n    // Get all directories in changes (excluding archive)\n    const entries = await fs.readdir(changesDir, { withFileTypes: true });\n    const changeDirs = entries\n      .filter(entry => entry.isDirectory() && entry.name !== 'archive')\n      .map(entry => entry.name)\n      .sort();\n\n    if (changeDirs.length === 0) {\n      console.log('No active changes found.');\n      return null;\n    }\n\n    // Build choices with progress inline to avoid duplicate lists\n    let choices: Array<{ name: string; value: string }> = changeDirs.map(name => ({ name, value: name }));\n    try {\n      const progressList: Array<{ id: string; status: string }> = [];\n      for (const id of changeDirs) {\n        const progress = await getTaskProgressForChange(changesDir, id);\n        const status = formatTaskStatus(progress);\n        progressList.push({ id, status });\n      }\n      const nameWidth = Math.max(...progressList.map(p => p.id.length));\n      choices = progressList.map(p => ({\n        name: `${p.id.padEnd(nameWidth)}     ${p.status}`,\n        value: p.id\n      }));\n    } catch {\n      // If anything fails, fall back to simple names\n      choices = changeDirs.map(name => ({ name, value: name }));\n    }\n\n    try {\n      const answer = await select({\n        message: 'Select a change to archive',\n        choices\n      });\n      return answer;\n    } catch (error) {\n      // User cancelled (Ctrl+C)\n      return null;\n    }\n  }\n\n  private getArchiveDate(): string {\n    // Returns date in YYYY-MM-DD format\n    return new Date().toISOString().split('T')[0];\n  }\n}\n"
  },
  {
    "path": "src/core/artifact-graph/graph.ts",
    "content": "import type { Artifact, SchemaYaml, CompletedSet, BlockedArtifacts } from './types.js';\nimport { loadSchema, parseSchema } from './schema.js';\n\n/**\n * Represents an artifact dependency graph.\n * Provides methods for querying build order, ready artifacts, and completion status.\n */\nexport class ArtifactGraph {\n  private artifacts: Map<string, Artifact>;\n  private schema: SchemaYaml;\n\n  private constructor(schema: SchemaYaml) {\n    this.schema = schema;\n    this.artifacts = new Map(schema.artifacts.map(a => [a.id, a]));\n  }\n\n  /**\n   * Creates an ArtifactGraph from a YAML file path.\n   */\n  static fromYaml(filePath: string): ArtifactGraph {\n    const schema = loadSchema(filePath);\n    return new ArtifactGraph(schema);\n  }\n\n  /**\n   * Creates an ArtifactGraph from YAML content string.\n   */\n  static fromYamlContent(yamlContent: string): ArtifactGraph {\n    const schema = parseSchema(yamlContent);\n    return new ArtifactGraph(schema);\n  }\n\n  /**\n   * Creates an ArtifactGraph from a pre-validated schema object.\n   */\n  static fromSchema(schema: SchemaYaml): ArtifactGraph {\n    return new ArtifactGraph(schema);\n  }\n\n  /**\n   * Gets a single artifact by ID.\n   */\n  getArtifact(id: string): Artifact | undefined {\n    return this.artifacts.get(id);\n  }\n\n  /**\n   * Gets all artifacts in the graph.\n   */\n  getAllArtifacts(): Artifact[] {\n    return Array.from(this.artifacts.values());\n  }\n\n  /**\n   * Gets the schema name.\n   */\n  getName(): string {\n    return this.schema.name;\n  }\n\n  /**\n   * Gets the schema version.\n   */\n  getVersion(): number {\n    return this.schema.version;\n  }\n\n  /**\n   * Computes the topological build order using Kahn's algorithm.\n   * Returns artifact IDs in the order they should be built.\n   */\n  getBuildOrder(): string[] {\n    const inDegree = new Map<string, number>();\n    const dependents = new Map<string, string[]>();\n\n    // Initialize all artifacts\n    for (const artifact of this.artifacts.values()) {\n      inDegree.set(artifact.id, artifact.requires.length);\n      dependents.set(artifact.id, []);\n    }\n\n    // Build reverse adjacency (who depends on whom)\n    for (const artifact of this.artifacts.values()) {\n      for (const req of artifact.requires) {\n        dependents.get(req)!.push(artifact.id);\n      }\n    }\n\n    // Start with roots (in-degree 0), sorted for determinism\n    const queue = [...this.artifacts.keys()]\n      .filter(id => inDegree.get(id) === 0)\n      .sort();\n\n    const result: string[] = [];\n\n    while (queue.length > 0) {\n      const current = queue.shift()!;\n      result.push(current);\n\n      // Collect newly ready artifacts, then sort before adding\n      const newlyReady: string[] = [];\n      for (const dep of dependents.get(current)!) {\n        const newDegree = inDegree.get(dep)! - 1;\n        inDegree.set(dep, newDegree);\n        if (newDegree === 0) {\n          newlyReady.push(dep);\n        }\n      }\n      queue.push(...newlyReady.sort());\n    }\n\n    return result;\n  }\n\n  /**\n   * Gets artifacts that are ready to be created (all dependencies completed).\n   */\n  getNextArtifacts(completed: CompletedSet): string[] {\n    const ready: string[] = [];\n\n    for (const artifact of this.artifacts.values()) {\n      if (completed.has(artifact.id)) {\n        continue; // Already completed\n      }\n\n      const allDepsCompleted = artifact.requires.every(req => completed.has(req));\n      if (allDepsCompleted) {\n        ready.push(artifact.id);\n      }\n    }\n\n    // Sort for deterministic ordering\n    return ready.sort();\n  }\n\n  /**\n   * Checks if all artifacts in the graph are completed.\n   */\n  isComplete(completed: CompletedSet): boolean {\n    for (const artifact of this.artifacts.values()) {\n      if (!completed.has(artifact.id)) {\n        return false;\n      }\n    }\n    return true;\n  }\n\n  /**\n   * Gets blocked artifacts and their unmet dependencies.\n   */\n  getBlocked(completed: CompletedSet): BlockedArtifacts {\n    const blocked: BlockedArtifacts = {};\n\n    for (const artifact of this.artifacts.values()) {\n      if (completed.has(artifact.id)) {\n        continue; // Already completed\n      }\n\n      const unmetDeps = artifact.requires.filter(req => !completed.has(req));\n      if (unmetDeps.length > 0) {\n        blocked[artifact.id] = unmetDeps.sort();\n      }\n    }\n\n    return blocked;\n  }\n}\n"
  },
  {
    "path": "src/core/artifact-graph/index.ts",
    "content": "// Types\nexport {\n  ArtifactSchema,\n  SchemaYamlSchema,\n  type Artifact,\n  type SchemaYaml,\n  type CompletedSet,\n  type BlockedArtifacts,\n} from './types.js';\n\n// Schema loading and validation\nexport { loadSchema, parseSchema, SchemaValidationError } from './schema.js';\n\n// Graph operations\nexport { ArtifactGraph } from './graph.js';\n\n// State detection\nexport { detectCompleted } from './state.js';\n\n// Schema resolution\nexport {\n  resolveSchema,\n  listSchemas,\n  listSchemasWithInfo,\n  getSchemaDir,\n  getPackageSchemasDir,\n  getUserSchemasDir,\n  SchemaLoadError,\n  type SchemaInfo,\n} from './resolver.js';\n\n// Instruction loading\nexport {\n  loadTemplate,\n  loadChangeContext,\n  generateInstructions,\n  formatChangeStatus,\n  TemplateLoadError,\n  type ChangeContext,\n  type ArtifactInstructions,\n  type DependencyInfo,\n  type ArtifactStatus,\n  type ChangeStatus,\n} from './instruction-loader.js';\n"
  },
  {
    "path": "src/core/artifact-graph/instruction-loader.ts",
    "content": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { getSchemaDir, resolveSchema } from './resolver.js';\nimport { ArtifactGraph } from './graph.js';\nimport { detectCompleted } from './state.js';\nimport { resolveSchemaForChange } from '../../utils/change-metadata.js';\nimport { readProjectConfig, validateConfigRules } from '../project-config.js';\nimport type { Artifact, CompletedSet } from './types.js';\n\n// Session-level cache for validation warnings (avoid repeating same warnings)\nconst shownWarnings = new Set<string>();\n\n/**\n * Error thrown when loading a template fails.\n */\nexport class TemplateLoadError extends Error {\n  constructor(\n    message: string,\n    public readonly templatePath: string\n  ) {\n    super(message);\n    this.name = 'TemplateLoadError';\n  }\n}\n\n/**\n * Change context containing graph, completion state, and metadata.\n */\nexport interface ChangeContext {\n  /** The artifact dependency graph */\n  graph: ArtifactGraph;\n  /** Set of completed artifact IDs */\n  completed: CompletedSet;\n  /** Schema name being used */\n  schemaName: string;\n  /** Change name */\n  changeName: string;\n  /** Path to the change directory */\n  changeDir: string;\n  /** Project root directory */\n  projectRoot: string;\n}\n\n/**\n * Enriched instructions for creating an artifact.\n */\nexport interface ArtifactInstructions {\n  /** Change name */\n  changeName: string;\n  /** Artifact ID */\n  artifactId: string;\n  /** Schema name */\n  schemaName: string;\n  /** Full path to change directory */\n  changeDir: string;\n  /** Output path pattern (e.g., \"proposal.md\") */\n  outputPath: string;\n  /** Artifact description */\n  description: string;\n  /** Guidance on how to create this artifact (from schema instruction field) */\n  instruction: string | undefined;\n  /** Project context from config (constraints/background for AI, not to be included in output) */\n  context: string | undefined;\n  /** Artifact-specific rules from config (constraints for AI, not to be included in output) */\n  rules: string[] | undefined;\n  /** Template content (structure to follow - this IS the output format) */\n  template: string;\n  /** Dependencies with completion status and paths */\n  dependencies: DependencyInfo[];\n  /** Artifacts that become available after completing this one */\n  unlocks: string[];\n}\n\n/**\n * Dependency information including path and description.\n */\nexport interface DependencyInfo {\n  /** Artifact ID */\n  id: string;\n  /** Whether the dependency is completed */\n  done: boolean;\n  /** Relative output path of the dependency (e.g., \"proposal.md\") */\n  path: string;\n  /** Description of the dependency artifact */\n  description: string;\n}\n\n/**\n * Status of a single artifact in the workflow.\n */\nexport interface ArtifactStatus {\n  /** Artifact ID */\n  id: string;\n  /** Output path pattern */\n  outputPath: string;\n  /** Status: done, ready, or blocked */\n  status: 'done' | 'ready' | 'blocked';\n  /** Missing dependencies (only for blocked) */\n  missingDeps?: string[];\n}\n\n/**\n * Formatted change status.\n */\nexport interface ChangeStatus {\n  /** Change name */\n  changeName: string;\n  /** Schema name */\n  schemaName: string;\n  /** Whether all artifacts are complete */\n  isComplete: boolean;\n  /** Artifact IDs required before apply phase (from schema's apply.requires) */\n  applyRequires: string[];\n  /** Status of each artifact */\n  artifacts: ArtifactStatus[];\n}\n\n/**\n * Loads a template from a schema's templates directory.\n *\n * @param schemaName - Schema name (e.g., \"spec-driven\")\n * @param templatePath - Relative path within the templates directory (e.g., \"proposal.md\")\n * @param projectRoot - Optional project root for project-local schema resolution\n * @returns The template content\n * @throws TemplateLoadError if the template cannot be loaded\n */\nexport function loadTemplate(\n  schemaName: string,\n  templatePath: string,\n  projectRoot?: string\n): string {\n  const schemaDir = getSchemaDir(schemaName, projectRoot);\n  if (!schemaDir) {\n    throw new TemplateLoadError(\n      `Schema '${schemaName}' not found`,\n      templatePath\n    );\n  }\n\n  const fullPath = path.join(schemaDir, 'templates', templatePath);\n\n  if (!fs.existsSync(fullPath)) {\n    throw new TemplateLoadError(\n      `Template not found: ${fullPath}`,\n      fullPath\n    );\n  }\n\n  try {\n    return fs.readFileSync(fullPath, 'utf-8');\n  } catch (err) {\n    const ioError = err instanceof Error ? err : new Error(String(err));\n    throw new TemplateLoadError(\n      `Failed to read template: ${ioError.message}`,\n      fullPath\n    );\n  }\n}\n\n/**\n * Loads change context combining graph and completion state.\n *\n * Schema resolution order:\n * 1. Explicit schemaName parameter (if provided)\n * 2. Schema from .openspec.yaml metadata (if exists in change directory)\n * 3. Default 'spec-driven'\n *\n * @param projectRoot - Project root directory\n * @param changeName - Change name\n * @param schemaName - Optional schema name override. If not provided, auto-detected from metadata.\n * @returns Change context with graph, completed set, and metadata\n */\nexport function loadChangeContext(\n  projectRoot: string,\n  changeName: string,\n  schemaName?: string\n): ChangeContext {\n  const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);\n\n  // Resolve schema: explicit > metadata > default\n  const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName);\n\n  const schema = resolveSchema(resolvedSchemaName, projectRoot);\n  const graph = ArtifactGraph.fromSchema(schema);\n  const completed = detectCompleted(graph, changeDir);\n\n  return {\n    graph,\n    completed,\n    schemaName: resolvedSchemaName,\n    changeName,\n    changeDir,\n    projectRoot,\n  };\n}\n\n/**\n * Generates enriched instructions for creating an artifact.\n *\n * Instruction injection order:\n * 1. <context> - Project context from config (if present)\n * 2. <rules> - Artifact-specific rules from config (if present)\n * 3. <template> - Schema's template content\n *\n * @param context - Change context\n * @param artifactId - Artifact ID to generate instructions for\n * @param projectRoot - Project root directory (for reading config)\n * @returns Enriched artifact instructions\n * @throws Error if artifact not found\n */\nexport function generateInstructions(\n  context: ChangeContext,\n  artifactId: string,\n  projectRoot?: string\n): ArtifactInstructions {\n  const artifact = context.graph.getArtifact(artifactId);\n  if (!artifact) {\n    throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`);\n  }\n\n  const templateContent = loadTemplate(context.schemaName, artifact.template, context.projectRoot);\n  const dependencies = getDependencyInfo(artifact, context.graph, context.completed);\n  const unlocks = getUnlockedArtifacts(context.graph, artifactId);\n\n  // Use projectRoot from context if not explicitly provided\n  const effectiveProjectRoot = projectRoot ?? context.projectRoot;\n\n  // Try to read project config for context and rules\n  let projectConfig = null;\n  if (effectiveProjectRoot) {\n    try {\n      projectConfig = readProjectConfig(effectiveProjectRoot);\n    } catch {\n      // If config read fails, continue without config\n    }\n  }\n\n  // Validate rules artifact IDs if config has rules (only once per session)\n  if (projectConfig?.rules) {\n    const validArtifactIds = new Set(context.graph.getAllArtifacts().map((a) => a.id));\n    const warnings = validateConfigRules(\n      projectConfig.rules,\n      validArtifactIds,\n      context.schemaName\n    );\n\n    // Show each unique warning only once per session\n    for (const warning of warnings) {\n      if (!shownWarnings.has(warning)) {\n        console.warn(warning);\n        shownWarnings.add(warning);\n      }\n    }\n  }\n\n  // Extract context and rules as separate fields (not prepended to template)\n  const configContext = projectConfig?.context?.trim() || undefined;\n  const rulesForArtifact = projectConfig?.rules?.[artifactId];\n  const configRules = rulesForArtifact && rulesForArtifact.length > 0 ? rulesForArtifact : undefined;\n\n  return {\n    changeName: context.changeName,\n    artifactId: artifact.id,\n    schemaName: context.schemaName,\n    changeDir: context.changeDir,\n    outputPath: artifact.generates,\n    description: artifact.description,\n    instruction: artifact.instruction,\n    context: configContext,\n    rules: configRules,\n    template: templateContent,\n    dependencies,\n    unlocks,\n  };\n}\n\n/**\n * Gets dependency info including paths and descriptions.\n */\nfunction getDependencyInfo(\n  artifact: Artifact,\n  graph: ArtifactGraph,\n  completed: CompletedSet\n): DependencyInfo[] {\n  return artifact.requires.map(id => {\n    const depArtifact = graph.getArtifact(id);\n    return {\n      id,\n      done: completed.has(id),\n      path: depArtifact?.generates ?? id,\n      description: depArtifact?.description ?? '',\n    };\n  });\n}\n\n/**\n * Gets artifacts that become available after completing the given artifact.\n */\nfunction getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[] {\n  const unlocks: string[] = [];\n\n  for (const artifact of graph.getAllArtifacts()) {\n    if (artifact.requires.includes(artifactId)) {\n      unlocks.push(artifact.id);\n    }\n  }\n\n  return unlocks.sort();\n}\n\n/**\n * Formats the status of all artifacts in a change.\n *\n * @param context - Change context\n * @returns Formatted change status\n */\nexport function formatChangeStatus(context: ChangeContext): ChangeStatus {\n  // Load schema to get apply phase configuration\n  const schema = resolveSchema(context.schemaName, context.projectRoot);\n  const applyRequires = schema.apply?.requires ?? schema.artifacts.map(a => a.id);\n\n  const artifacts = context.graph.getAllArtifacts();\n  const ready = new Set(context.graph.getNextArtifacts(context.completed));\n  const blocked = context.graph.getBlocked(context.completed);\n\n  const artifactStatuses: ArtifactStatus[] = artifacts.map(artifact => {\n    if (context.completed.has(artifact.id)) {\n      return {\n        id: artifact.id,\n        outputPath: artifact.generates,\n        status: 'done' as const,\n      };\n    }\n\n    if (ready.has(artifact.id)) {\n      return {\n        id: artifact.id,\n        outputPath: artifact.generates,\n        status: 'ready' as const,\n      };\n    }\n\n    return {\n      id: artifact.id,\n      outputPath: artifact.generates,\n      status: 'blocked' as const,\n      missingDeps: blocked[artifact.id] ?? [],\n    };\n  });\n\n  // Sort by build order for consistent output\n  const buildOrder = context.graph.getBuildOrder();\n  const orderMap = new Map(buildOrder.map((id, idx) => [id, idx]));\n  artifactStatuses.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));\n\n  return {\n    changeName: context.changeName,\n    schemaName: context.schemaName,\n    isComplete: context.graph.isComplete(context.completed),\n    applyRequires,\n    artifacts: artifactStatuses,\n  };\n}\n"
  },
  {
    "path": "src/core/artifact-graph/resolver.ts",
    "content": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { getGlobalDataDir } from '../global-config.js';\nimport { parseSchema, SchemaValidationError } from './schema.js';\nimport type { SchemaYaml } from './types.js';\n\n/**\n * Error thrown when loading a schema fails.\n */\nexport class SchemaLoadError extends Error {\n  constructor(\n    message: string,\n    public readonly schemaPath: string,\n    public readonly cause?: Error\n  ) {\n    super(message);\n    this.name = 'SchemaLoadError';\n  }\n}\n\n/**\n * Gets the package's built-in schemas directory path.\n * Uses import.meta.url to resolve relative to the current module.\n */\nexport function getPackageSchemasDir(): string {\n  const currentFile = fileURLToPath(import.meta.url);\n  // Navigate from dist/core/artifact-graph/ to package root's schemas/\n  return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas');\n}\n\n/**\n * Gets the user's schema override directory path.\n */\nexport function getUserSchemasDir(): string {\n  return path.join(getGlobalDataDir(), 'schemas');\n}\n\n/**\n * Gets the project-local schemas directory path.\n * @param projectRoot - The project root directory\n * @returns The path to the project's schemas directory\n */\nexport function getProjectSchemasDir(projectRoot: string): string {\n  return path.join(projectRoot, 'openspec', 'schemas');\n}\n\n/**\n * Resolves a schema name to its directory path.\n *\n * Resolution order (when projectRoot is provided):\n * 1. Project-local: <projectRoot>/openspec/schemas/<name>/schema.yaml\n * 2. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml\n * 3. Package built-in: <package>/schemas/<name>/schema.yaml\n *\n * When projectRoot is not provided, only user override and package built-in are checked\n * (backward compatible behavior).\n *\n * @param name - Schema name (e.g., \"spec-driven\")\n * @param projectRoot - Optional project root directory for project-local schema resolution\n * @returns The path to the schema directory, or null if not found\n */\nexport function getSchemaDir(\n  name: string,\n  projectRoot?: string\n): string | null {\n  // 1. Check project-local directory (if projectRoot provided)\n  if (projectRoot) {\n    const projectDir = path.join(getProjectSchemasDir(projectRoot), name);\n    const projectSchemaPath = path.join(projectDir, 'schema.yaml');\n    if (fs.existsSync(projectSchemaPath)) {\n      return projectDir;\n    }\n  }\n\n  // 2. Check user override directory\n  const userDir = path.join(getUserSchemasDir(), name);\n  const userSchemaPath = path.join(userDir, 'schema.yaml');\n  if (fs.existsSync(userSchemaPath)) {\n    return userDir;\n  }\n\n  // 3. Check package built-in directory\n  const packageDir = path.join(getPackageSchemasDir(), name);\n  const packageSchemaPath = path.join(packageDir, 'schema.yaml');\n  if (fs.existsSync(packageSchemaPath)) {\n    return packageDir;\n  }\n\n  return null;\n}\n\n/**\n * Resolves a schema name to a SchemaYaml object.\n *\n * Resolution order (when projectRoot is provided):\n * 1. Project-local: <projectRoot>/openspec/schemas/<name>/schema.yaml\n * 2. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml\n * 3. Package built-in: <package>/schemas/<name>/schema.yaml\n *\n * When projectRoot is not provided, only user override and package built-in are checked\n * (backward compatible behavior).\n *\n * @param name - Schema name (e.g., \"spec-driven\")\n * @param projectRoot - Optional project root directory for project-local schema resolution\n * @returns The resolved schema object\n * @throws Error if schema is not found in any location\n */\nexport function resolveSchema(name: string, projectRoot?: string): SchemaYaml {\n  // Normalize name (remove .yaml extension if provided)\n  const normalizedName = name.replace(/\\.ya?ml$/, '');\n\n  const schemaDir = getSchemaDir(normalizedName, projectRoot);\n  if (!schemaDir) {\n    const availableSchemas = listSchemas(projectRoot);\n    throw new Error(\n      `Schema '${normalizedName}' not found. Available schemas: ${availableSchemas.join(', ')}`\n    );\n  }\n\n  const schemaPath = path.join(schemaDir, 'schema.yaml');\n\n  // Load and parse the schema\n  let content: string;\n  try {\n    content = fs.readFileSync(schemaPath, 'utf-8');\n  } catch (err) {\n    const ioError = err instanceof Error ? err : new Error(String(err));\n    throw new SchemaLoadError(\n      `Failed to read schema at '${schemaPath}': ${ioError.message}`,\n      schemaPath,\n      ioError\n    );\n  }\n\n  try {\n    return parseSchema(content);\n  } catch (err) {\n    if (err instanceof SchemaValidationError) {\n      throw new SchemaLoadError(\n        `Invalid schema at '${schemaPath}': ${err.message}`,\n        schemaPath,\n        err\n      );\n    }\n    const parseError = err instanceof Error ? err : new Error(String(err));\n    throw new SchemaLoadError(\n      `Failed to parse schema at '${schemaPath}': ${parseError.message}`,\n      schemaPath,\n      parseError\n    );\n  }\n}\n\n/**\n * Lists all available schema names.\n * Combines project-local, user override, and package built-in schemas.\n *\n * @param projectRoot - Optional project root directory for project-local schema resolution\n */\nexport function listSchemas(projectRoot?: string): string[] {\n  const schemas = new Set<string>();\n\n  // Add package built-in schemas\n  const packageDir = getPackageSchemasDir();\n  if (fs.existsSync(packageDir)) {\n    for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {\n      if (entry.isDirectory()) {\n        const schemaPath = path.join(packageDir, entry.name, 'schema.yaml');\n        if (fs.existsSync(schemaPath)) {\n          schemas.add(entry.name);\n        }\n      }\n    }\n  }\n\n  // Add user override schemas (may override package schemas)\n  const userDir = getUserSchemasDir();\n  if (fs.existsSync(userDir)) {\n    for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) {\n      if (entry.isDirectory()) {\n        const schemaPath = path.join(userDir, entry.name, 'schema.yaml');\n        if (fs.existsSync(schemaPath)) {\n          schemas.add(entry.name);\n        }\n      }\n    }\n  }\n\n  // Add project-local schemas (if projectRoot provided)\n  if (projectRoot) {\n    const projectDir = getProjectSchemasDir(projectRoot);\n    if (fs.existsSync(projectDir)) {\n      for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) {\n        if (entry.isDirectory()) {\n          const schemaPath = path.join(projectDir, entry.name, 'schema.yaml');\n          if (fs.existsSync(schemaPath)) {\n            schemas.add(entry.name);\n          }\n        }\n      }\n    }\n  }\n\n  return Array.from(schemas).sort();\n}\n\n/**\n * Schema info with metadata (name, description, artifacts).\n */\nexport interface SchemaInfo {\n  name: string;\n  description: string;\n  artifacts: string[];\n  source: 'project' | 'user' | 'package';\n}\n\n/**\n * Lists all available schemas with their descriptions and artifact lists.\n * Useful for agent skills to present schema selection to users.\n *\n * @param projectRoot - Optional project root directory for project-local schema resolution\n */\nexport function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] {\n  const schemas: SchemaInfo[] = [];\n  const seenNames = new Set<string>();\n\n  // Add project-local schemas first (highest priority, if projectRoot provided)\n  if (projectRoot) {\n    const projectDir = getProjectSchemasDir(projectRoot);\n    if (fs.existsSync(projectDir)) {\n      for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) {\n        if (entry.isDirectory()) {\n          const schemaPath = path.join(projectDir, entry.name, 'schema.yaml');\n          if (fs.existsSync(schemaPath)) {\n            try {\n              const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8'));\n              schemas.push({\n                name: entry.name,\n                description: schema.description || '',\n                artifacts: schema.artifacts.map((a) => a.id),\n                source: 'project',\n              });\n              seenNames.add(entry.name);\n            } catch {\n              // Skip invalid schemas\n            }\n          }\n        }\n      }\n    }\n  }\n\n  // Add user override schemas (if not overridden by project)\n  const userDir = getUserSchemasDir();\n  if (fs.existsSync(userDir)) {\n    for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) {\n      if (entry.isDirectory() && !seenNames.has(entry.name)) {\n        const schemaPath = path.join(userDir, entry.name, 'schema.yaml');\n        if (fs.existsSync(schemaPath)) {\n          try {\n            const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8'));\n            schemas.push({\n              name: entry.name,\n              description: schema.description || '',\n              artifacts: schema.artifacts.map((a) => a.id),\n              source: 'user',\n            });\n            seenNames.add(entry.name);\n          } catch {\n            // Skip invalid schemas\n          }\n        }\n      }\n    }\n  }\n\n  // Add package built-in schemas (if not overridden by project or user)\n  const packageDir = getPackageSchemasDir();\n  if (fs.existsSync(packageDir)) {\n    for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {\n      if (entry.isDirectory() && !seenNames.has(entry.name)) {\n        const schemaPath = path.join(packageDir, entry.name, 'schema.yaml');\n        if (fs.existsSync(schemaPath)) {\n          try {\n            const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8'));\n            schemas.push({\n              name: entry.name,\n              description: schema.description || '',\n              artifacts: schema.artifacts.map((a) => a.id),\n              source: 'package',\n            });\n          } catch {\n            // Skip invalid schemas\n          }\n        }\n      }\n    }\n  }\n\n  return schemas.sort((a, b) => a.name.localeCompare(b.name));\n}\n"
  },
  {
    "path": "src/core/artifact-graph/schema.ts",
    "content": "import * as fs from 'node:fs';\nimport { parse as parseYaml } from 'yaml';\nimport { SchemaYamlSchema, type SchemaYaml, type Artifact } from './types.js';\n\nexport class SchemaValidationError extends Error {\n  constructor(message: string) {\n    super(message);\n    this.name = 'SchemaValidationError';\n  }\n}\n\n/**\n * Loads and validates an artifact schema from a YAML file.\n */\nexport function loadSchema(filePath: string): SchemaYaml {\n  const content = fs.readFileSync(filePath, 'utf-8');\n  return parseSchema(content);\n}\n\n/**\n * Parses and validates an artifact schema from YAML content.\n */\nexport function parseSchema(yamlContent: string): SchemaYaml {\n  const parsed = parseYaml(yamlContent);\n\n  // Validate with Zod\n  const result = SchemaYamlSchema.safeParse(parsed);\n  if (!result.success) {\n    const errors = result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');\n    throw new SchemaValidationError(`Invalid schema: ${errors}`);\n  }\n\n  const schema = result.data;\n\n  // Check for duplicate artifact IDs\n  validateNoDuplicateIds(schema.artifacts);\n\n  // Check that all requires references are valid\n  validateRequiresReferences(schema.artifacts);\n\n  // Check for cycles\n  validateNoCycles(schema.artifacts);\n\n  return schema;\n}\n\n/**\n * Validates that there are no duplicate artifact IDs.\n */\nfunction validateNoDuplicateIds(artifacts: Artifact[]): void {\n  const seen = new Set<string>();\n  for (const artifact of artifacts) {\n    if (seen.has(artifact.id)) {\n      throw new SchemaValidationError(`Duplicate artifact ID: ${artifact.id}`);\n    }\n    seen.add(artifact.id);\n  }\n}\n\n/**\n * Validates that all `requires` references point to valid artifact IDs.\n */\nfunction validateRequiresReferences(artifacts: Artifact[]): void {\n  const validIds = new Set(artifacts.map(a => a.id));\n\n  for (const artifact of artifacts) {\n    for (const req of artifact.requires) {\n      if (!validIds.has(req)) {\n        throw new SchemaValidationError(\n          `Invalid dependency reference in artifact '${artifact.id}': '${req}' does not exist`\n        );\n      }\n    }\n  }\n}\n\n/**\n * Validates that there are no cyclic dependencies.\n * Uses DFS to detect cycles and reports the full cycle path.\n */\nfunction validateNoCycles(artifacts: Artifact[]): void {\n  const artifactMap = new Map(artifacts.map(a => [a.id, a]));\n  const visited = new Set<string>();\n  const inStack = new Set<string>();\n  const parent = new Map<string, string>();\n\n  function dfs(id: string): string | null {\n    visited.add(id);\n    inStack.add(id);\n\n    const artifact = artifactMap.get(id);\n    if (!artifact) return null;\n\n    for (const dep of artifact.requires) {\n      if (!visited.has(dep)) {\n        parent.set(dep, id);\n        const cycle = dfs(dep);\n        if (cycle) return cycle;\n      } else if (inStack.has(dep)) {\n        // Found a cycle - reconstruct the path\n        const cyclePath = [dep];\n        let current = id;\n        while (current !== dep) {\n          cyclePath.unshift(current);\n          current = parent.get(current)!;\n        }\n        cyclePath.unshift(dep);\n        return cyclePath.join(' → ');\n      }\n    }\n\n    inStack.delete(id);\n    return null;\n  }\n\n  for (const artifact of artifacts) {\n    if (!visited.has(artifact.id)) {\n      const cycle = dfs(artifact.id);\n      if (cycle) {\n        throw new SchemaValidationError(`Cyclic dependency detected: ${cycle}`);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/core/artifact-graph/state.ts",
    "content": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport fg from 'fast-glob';\nimport type { CompletedSet } from './types.js';\nimport type { ArtifactGraph } from './graph.js';\nimport { FileSystemUtils } from '../../utils/file-system.js';\n\n/**\n * Detects which artifacts are completed by checking file existence in the change directory.\n * Returns a Set of completed artifact IDs.\n *\n * @param graph - The artifact graph to check\n * @param changeDir - The change directory to scan for files\n * @returns Set of artifact IDs whose generated files exist\n */\nexport function detectCompleted(graph: ArtifactGraph, changeDir: string): CompletedSet {\n  const completed = new Set<string>();\n\n  // Handle missing change directory gracefully\n  if (!fs.existsSync(changeDir)) {\n    return completed;\n  }\n\n  for (const artifact of graph.getAllArtifacts()) {\n    if (isArtifactComplete(artifact.generates, changeDir)) {\n      completed.add(artifact.id);\n    }\n  }\n\n  return completed;\n}\n\n/**\n * Checks if an artifact is complete by checking if its generated file(s) exist.\n * Supports both simple paths and glob patterns.\n */\nfunction isArtifactComplete(generates: string, changeDir: string): boolean {\n  const fullPattern = path.join(changeDir, generates);\n\n  // Check if it's a glob pattern\n  if (isGlobPattern(generates)) {\n    return hasGlobMatches(fullPattern);\n  }\n\n  // Simple file path - check if file exists\n  return fs.existsSync(fullPattern);\n}\n\n/**\n * Checks if a path contains glob pattern characters.\n */\nfunction isGlobPattern(pattern: string): boolean {\n  return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');\n}\n\n/**\n * Checks if a glob pattern has any matches.\n * Normalizes Windows backslashes to forward slashes for cross-platform glob compatibility.\n */\nfunction hasGlobMatches(pattern: string): boolean {\n  const normalizedPattern = FileSystemUtils.toPosixPath(pattern);\n  const matches = fg.sync(normalizedPattern, { onlyFiles: true });\n  return matches.length > 0;\n}\n"
  },
  {
    "path": "src/core/artifact-graph/types.ts",
    "content": "import { z } from 'zod';\n\n// Artifact definition schema\nexport const ArtifactSchema = z.object({\n  id: z.string().min(1, { error: 'Artifact ID is required' }),\n  generates: z.string().min(1, { error: 'generates field is required' }),\n  description: z.string(),\n  template: z.string().min(1, { error: 'template field is required' }),\n  instruction: z.string().optional(),\n  requires: z.array(z.string()).default([]),\n});\n\n// Apply phase configuration for schema-aware apply instructions\nexport const ApplyPhaseSchema = z.object({\n  // Artifact IDs that must exist before apply is available\n  requires: z.array(z.string()).min(1, { error: 'At least one required artifact' }),\n  // Path to file with checkboxes for progress (relative to change dir), or null if no tracking\n  tracks: z.string().nullable().optional(),\n  // Custom guidance for the apply phase\n  instruction: z.string().optional(),\n});\n\n// Full schema YAML structure\nexport const SchemaYamlSchema = z.object({\n  name: z.string().min(1, { error: 'Schema name is required' }),\n  version: z.number().int().positive({ error: 'Version must be a positive integer' }),\n  description: z.string().optional(),\n  artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),\n  // Optional apply phase configuration (for schema-aware apply instructions)\n  apply: ApplyPhaseSchema.optional(),\n});\n\n// Derived TypeScript types\nexport type Artifact = z.infer<typeof ArtifactSchema>;\nexport type ApplyPhase = z.infer<typeof ApplyPhaseSchema>;\nexport type SchemaYaml = z.infer<typeof SchemaYamlSchema>;\n\n// Per-change metadata schema\n// Note: schema field is validated at parse time against available schemas\n// using a lazy import to avoid circular dependencies\nexport const ChangeMetadataSchema = z.object({\n  // Required: which workflow schema this change uses\n  schema: z.string().min(1, { message: 'schema is required' }),\n\n  // Optional: creation timestamp (ISO date string)\n  created: z\n    .string()\n    .regex(/^\\d{4}-\\d{2}-\\d{2}$/, {\n      message: 'created must be YYYY-MM-DD format',\n    })\n    .optional(),\n});\n\nexport type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;\n\n// Runtime state types (not Zod - internal only)\n\n// Slice 1: Simple completion tracking via filesystem\nexport type CompletedSet = Set<string>;\n\n// Return type for blocked query\nexport interface BlockedArtifacts {\n  [artifactId: string]: string[];\n}\n\n"
  },
  {
    "path": "src/core/available-tools.ts",
    "content": "/**\n * Available Tools Detection\n *\n * Detects which AI tools are available in a project by scanning\n * for their configuration directories.\n */\n\nimport path from 'path';\nimport * as fs from 'fs';\nimport { AI_TOOLS, type AIToolOption } from './config.js';\n\n/**\n * Scans the project path for AI tool configuration directories and returns\n * the tools that are present.\n *\n * Checks for each tool's `skillsDir` (e.g., `.claude/`, `.cursor/`) at the\n * project root. Only tools with a `skillsDir` property are considered.\n */\nexport function getAvailableTools(projectPath: string): AIToolOption[] {\n  return AI_TOOLS.filter((tool) => {\n    if (!tool.skillsDir) return false;\n    const dirPath = path.join(projectPath, tool.skillsDir);\n    try {\n      return fs.statSync(dirPath).isDirectory();\n    } catch {\n      return false;\n    }\n  });\n}\n"
  },
  {
    "path": "src/core/command-generation/adapters/amazon-q.ts",
    "content": "/**\n * Amazon Q Developer Command Adapter\n *\n * Formats commands for Amazon Q Developer following its frontmatter specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Amazon Q adapter for command generation.\n * File path: .amazonq/prompts/opsx-<id>.md\n * Frontmatter: description\n */\nexport const amazonQAdapter: ToolCommandAdapter = {\n  toolId: 'amazon-q',\n\n  getFilePath(commandId: string): string {\n    return path.join('.amazonq', 'prompts', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\ndescription: ${content.description}\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/antigravity.ts",
    "content": "/**\n * Antigravity Command Adapter\n *\n * Formats commands for Antigravity following its frontmatter specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Antigravity adapter for command generation.\n * File path: .agent/workflows/opsx-<id>.md\n * Frontmatter: description\n */\nexport const antigravityAdapter: ToolCommandAdapter = {\n  toolId: 'antigravity',\n\n  getFilePath(commandId: string): string {\n    return path.join('.agent', 'workflows', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\ndescription: ${content.description}\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/auggie.ts",
    "content": "/**\n * Auggie (Augment CLI) Command Adapter\n *\n * Formats commands for Auggie following its frontmatter specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Auggie adapter for command generation.\n * File path: .augment/commands/opsx-<id>.md\n * Frontmatter: description, argument-hint\n */\nexport const auggieAdapter: ToolCommandAdapter = {\n  toolId: 'auggie',\n\n  getFilePath(commandId: string): string {\n    return path.join('.augment', 'commands', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\ndescription: ${content.description}\nargument-hint: command arguments\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/claude.ts",
    "content": "/**\n * Claude Code Command Adapter\n *\n * Formats commands for Claude Code following its frontmatter specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Escapes a string value for safe YAML output.\n * Quotes the string if it contains special YAML characters.\n */\nfunction escapeYamlValue(value: string): string {\n  // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)\n  const needsQuoting = /[:\\n\\r#{}[\\],&*!|>'\"%@`]|^\\s|\\s$/.test(value);\n  if (needsQuoting) {\n    // Use double quotes and escape internal double quotes and backslashes\n    const escaped = value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"').replace(/\\n/g, '\\\\n');\n    return `\"${escaped}\"`;\n  }\n  return value;\n}\n\n/**\n * Formats a tags array as a YAML array with proper escaping.\n */\nfunction formatTagsArray(tags: string[]): string {\n  const escapedTags = tags.map((tag) => escapeYamlValue(tag));\n  return `[${escapedTags.join(', ')}]`;\n}\n\n/**\n * Claude Code adapter for command generation.\n * File path: .claude/commands/opsx/<id>.md\n * Frontmatter: name, description, category, tags\n */\nexport const claudeAdapter: ToolCommandAdapter = {\n  toolId: 'claude',\n\n  getFilePath(commandId: string): string {\n    return path.join('.claude', 'commands', 'opsx', `${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\nname: ${escapeYamlValue(content.name)}\ndescription: ${escapeYamlValue(content.description)}\ncategory: ${escapeYamlValue(content.category)}\ntags: ${formatTagsArray(content.tags)}\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/cline.ts",
    "content": "/**\n * Cline Command Adapter\n *\n * Formats commands for Cline following its workflow specification.\n * Cline uses markdown headers instead of YAML frontmatter.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Cline adapter for command generation.\n * File path: .clinerules/workflows/opsx-<id>.md\n * Format: Markdown header with description\n */\nexport const clineAdapter: ToolCommandAdapter = {\n  toolId: 'cline',\n\n  getFilePath(commandId: string): string {\n    return path.join('.clinerules', 'workflows', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `# ${content.name}\n\n${content.description}\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/codebuddy.ts",
    "content": "/**\n * CodeBuddy Command Adapter\n *\n * Formats commands for CodeBuddy following its frontmatter specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * CodeBuddy adapter for command generation.\n * File path: .codebuddy/commands/opsx/<id>.md\n * Frontmatter: name, description, argument-hint\n */\nexport const codebuddyAdapter: ToolCommandAdapter = {\n  toolId: 'codebuddy',\n\n  getFilePath(commandId: string): string {\n    return path.join('.codebuddy', 'commands', 'opsx', `${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\nname: ${content.name}\ndescription: \"${content.description}\"\nargument-hint: \"[command arguments]\"\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/codex.ts",
    "content": "/**\n * Codex Command Adapter\n *\n * Formats commands for Codex following its frontmatter specification.\n * Codex custom prompts live in the global home directory (~/.codex/prompts/)\n * and are not shared through the repository. The CODEX_HOME env var can\n * override the default ~/.codex location.\n */\n\nimport os from 'os';\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Returns the Codex home directory.\n * Respects the CODEX_HOME env var, defaulting to ~/.codex.\n */\nfunction getCodexHome(): string {\n  const envHome = process.env.CODEX_HOME?.trim();\n  return path.resolve(envHome ? envHome : path.join(os.homedir(), '.codex'));\n}\n\n/**\n * Codex adapter for command generation.\n * File path: <CODEX_HOME>/prompts/opsx-<id>.md (absolute, global)\n * Frontmatter: description, argument-hint\n */\nexport const codexAdapter: ToolCommandAdapter = {\n  toolId: 'codex',\n\n  getFilePath(commandId: string): string {\n    return path.join(getCodexHome(), 'prompts', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\ndescription: ${content.description}\nargument-hint: command arguments\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/continue.ts",
    "content": "/**\n * Continue Command Adapter\n *\n * Formats commands for Continue following its .prompt specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Continue adapter for command generation.\n * File path: .continue/prompts/opsx-<id>.prompt\n * Frontmatter: name, description, invokable\n */\nexport const continueAdapter: ToolCommandAdapter = {\n  toolId: 'continue',\n\n  getFilePath(commandId: string): string {\n    return path.join('.continue', 'prompts', `opsx-${commandId}.prompt`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\nname: opsx-${content.id}\ndescription: ${content.description}\ninvokable: true\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/costrict.ts",
    "content": "/**\n * CoStrict Command Adapter\n *\n * Formats commands for CoStrict following its frontmatter specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * CoStrict adapter for command generation.\n * File path: .cospec/openspec/commands/opsx-<id>.md\n * Frontmatter: description, argument-hint\n */\nexport const costrictAdapter: ToolCommandAdapter = {\n  toolId: 'costrict',\n\n  getFilePath(commandId: string): string {\n    return path.join('.cospec', 'openspec', 'commands', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\ndescription: \"${content.description}\"\nargument-hint: command arguments\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/crush.ts",
    "content": "/**\n * Crush Command Adapter\n *\n * Formats commands for Crush following its frontmatter specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Crush adapter for command generation.\n * File path: .crush/commands/opsx/<id>.md\n * Frontmatter: name, description, category, tags\n */\nexport const crushAdapter: ToolCommandAdapter = {\n  toolId: 'crush',\n\n  getFilePath(commandId: string): string {\n    return path.join('.crush', 'commands', 'opsx', `${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    const tagsStr = content.tags.join(', ');\n    return `---\nname: ${content.name}\ndescription: ${content.description}\ncategory: ${content.category}\ntags: [${tagsStr}]\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/cursor.ts",
    "content": "/**\n * Cursor Command Adapter\n *\n * Formats commands for Cursor following its frontmatter specification.\n * Cursor uses a different frontmatter format and file naming convention.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Escapes a string value for safe YAML output.\n * Quotes the string if it contains special YAML characters.\n */\nfunction escapeYamlValue(value: string): string {\n  // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)\n  const needsQuoting = /[:\\n\\r#{}[\\],&*!|>'\"%@`]|^\\s|\\s$/.test(value);\n  if (needsQuoting) {\n    // Use double quotes and escape internal double quotes and backslashes\n    const escaped = value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"').replace(/\\n/g, '\\\\n');\n    return `\"${escaped}\"`;\n  }\n  return value;\n}\n\n/**\n * Cursor adapter for command generation.\n * File path: .cursor/commands/opsx-<id>.md\n * Frontmatter: name (as /opsx-<id>), id, category, description\n */\nexport const cursorAdapter: ToolCommandAdapter = {\n  toolId: 'cursor',\n\n  getFilePath(commandId: string): string {\n    return path.join('.cursor', 'commands', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\nname: /opsx-${content.id}\nid: opsx-${content.id}\ncategory: ${escapeYamlValue(content.category)}\ndescription: ${escapeYamlValue(content.description)}\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/factory.ts",
    "content": "/**\n * Factory Droid Command Adapter\n *\n * Formats commands for Factory Droid following its frontmatter specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Factory adapter for command generation.\n * File path: .factory/commands/opsx-<id>.md\n * Frontmatter: description, argument-hint\n */\nexport const factoryAdapter: ToolCommandAdapter = {\n  toolId: 'factory',\n\n  getFilePath(commandId: string): string {\n    return path.join('.factory', 'commands', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\ndescription: ${content.description}\nargument-hint: command arguments\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/gemini.ts",
    "content": "/**\n * Gemini CLI Command Adapter\n *\n * Formats commands for Gemini CLI following its TOML specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Gemini adapter for command generation.\n * File path: .gemini/commands/opsx/<id>.toml\n * Format: TOML with description and prompt fields\n */\nexport const geminiAdapter: ToolCommandAdapter = {\n  toolId: 'gemini',\n\n  getFilePath(commandId: string): string {\n    return path.join('.gemini', 'commands', 'opsx', `${commandId}.toml`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `description = \"${content.description}\"\n\nprompt = \"\"\"\n${content.body}\n\"\"\"\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/github-copilot.ts",
    "content": "/**\n * GitHub Copilot Command Adapter\n *\n * Formats commands for GitHub Copilot following its .prompt.md specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * GitHub Copilot adapter for command generation.\n * File path: .github/prompts/opsx-<id>.prompt.md\n * Frontmatter: description\n */\nexport const githubCopilotAdapter: ToolCommandAdapter = {\n  toolId: 'github-copilot',\n\n  getFilePath(commandId: string): string {\n    return path.join('.github', 'prompts', `opsx-${commandId}.prompt.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\ndescription: ${content.description}\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/iflow.ts",
    "content": "/**\n * iFlow Command Adapter\n *\n * Formats commands for iFlow following its frontmatter specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * iFlow adapter for command generation.\n * File path: .iflow/commands/opsx-<id>.md\n * Frontmatter: name, id, category, description\n */\nexport const iflowAdapter: ToolCommandAdapter = {\n  toolId: 'iflow',\n\n  getFilePath(commandId: string): string {\n    return path.join('.iflow', 'commands', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\nname: /opsx-${content.id}\nid: opsx-${content.id}\ncategory: ${content.category}\ndescription: ${content.description}\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/index.ts",
    "content": "/**\n * Command Adapters Index\n *\n * Re-exports all tool command adapters.\n */\n\nexport { amazonQAdapter } from './amazon-q.js';\nexport { antigravityAdapter } from './antigravity.js';\nexport { auggieAdapter } from './auggie.js';\nexport { claudeAdapter } from './claude.js';\nexport { clineAdapter } from './cline.js';\nexport { codexAdapter } from './codex.js';\nexport { codebuddyAdapter } from './codebuddy.js';\nexport { continueAdapter } from './continue.js';\nexport { costrictAdapter } from './costrict.js';\nexport { crushAdapter } from './crush.js';\nexport { cursorAdapter } from './cursor.js';\nexport { factoryAdapter } from './factory.js';\nexport { geminiAdapter } from './gemini.js';\nexport { githubCopilotAdapter } from './github-copilot.js';\nexport { iflowAdapter } from './iflow.js';\nexport { kilocodeAdapter } from './kilocode.js';\nexport { kiroAdapter } from './kiro.js';\nexport { opencodeAdapter } from './opencode.js';\nexport { piAdapter } from './pi.js';\nexport { qoderAdapter } from './qoder.js';\nexport { qwenAdapter } from './qwen.js';\nexport { roocodeAdapter } from './roocode.js';\nexport { windsurfAdapter } from './windsurf.js';\n"
  },
  {
    "path": "src/core/command-generation/adapters/kilocode.ts",
    "content": "/**\n * Kilo Code Command Adapter\n *\n * Formats commands for Kilo Code following its workflow specification.\n * Kilo Code workflows don't use frontmatter.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Kilo Code adapter for command generation.\n * File path: .kilocode/workflows/opsx-<id>.md\n * Format: Plain markdown without frontmatter\n */\nexport const kilocodeAdapter: ToolCommandAdapter = {\n  toolId: 'kilocode',\n\n  getFilePath(commandId: string): string {\n    return path.join('.kilocode', 'workflows', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/kiro.ts",
    "content": "/**\n * Kiro Command Adapter\n *\n * Formats commands for Kiro following its .prompt.md specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Kiro adapter for command generation.\n * File path: .kiro/prompts/opsx-<id>.prompt.md\n * Frontmatter: description\n */\nexport const kiroAdapter: ToolCommandAdapter = {\n  toolId: 'kiro',\n\n  getFilePath(commandId: string): string {\n    return path.join('.kiro', 'prompts', `opsx-${commandId}.prompt.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\ndescription: ${content.description}\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/opencode.ts",
    "content": "/**\n * OpenCode Command Adapter\n *\n * Formats commands for OpenCode following its frontmatter specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\nimport { transformToHyphenCommands } from '../../../utils/command-references.js';\n\n/**\n * OpenCode adapter for command generation.\n * File path: .opencode/commands/opsx-<id>.md\n * Frontmatter: description\n */\nexport const opencodeAdapter: ToolCommandAdapter = {\n  toolId: 'opencode',\n\n  getFilePath(commandId: string): string {\n    return path.join('.opencode', 'commands', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    // Transform command references from colon to hyphen format for OpenCode\n    const transformedBody = transformToHyphenCommands(content.body);\n\n    return `---\ndescription: ${content.description}\n---\n\n${transformedBody}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/pi.ts",
    "content": "/**\n * Pi Command Adapter\n *\n * Formats commands for Pi (pi.dev) following its prompt template specification.\n * Pi prompt templates live in .pi/prompts/*.md with description frontmatter.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Escapes a string value for safe YAML output.\n * Quotes the string if it contains special YAML characters.\n */\nfunction escapeYamlValue(value: string): string {\n  // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)\n  const needsQuoting = /[:\\n\\r#{}[\\],&*!|>'\"%@`]|^\\s|\\s$/.test(value);\n  if (needsQuoting) {\n    // Use double quotes and escape internal double quotes and backslashes\n    const escaped = value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"').replace(/\\n/g, '\\\\n');\n    return `\"${escaped}\"`;\n  }\n  return value;\n}\n\n/**\n * Pi adapter for prompt template generation.\n * File path: .pi/prompts/opsx-<id>.md\n * Frontmatter: description\n */\nexport const piAdapter: ToolCommandAdapter = {\n  toolId: 'pi',\n\n  getFilePath(commandId: string): string {\n    return path.join('.pi', 'prompts', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\ndescription: ${escapeYamlValue(content.description)}\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/qoder.ts",
    "content": "/**\n * Qoder Command Adapter\n *\n * Formats commands for Qoder following its frontmatter specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Qoder adapter for command generation.\n * File path: .qoder/commands/opsx/<id>.md\n * Frontmatter: name, description, category, tags\n */\nexport const qoderAdapter: ToolCommandAdapter = {\n  toolId: 'qoder',\n\n  getFilePath(commandId: string): string {\n    return path.join('.qoder', 'commands', 'opsx', `${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    const tagsStr = content.tags.join(', ');\n    return `---\nname: ${content.name}\ndescription: ${content.description}\ncategory: ${content.category}\ntags: [${tagsStr}]\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/qwen.ts",
    "content": "/**\n * Qwen Code Command Adapter\n *\n * Formats commands for Qwen Code following its TOML specification.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Qwen adapter for command generation.\n * File path: .qwen/commands/opsx-<id>.toml\n * Format: TOML with description and prompt fields\n */\nexport const qwenAdapter: ToolCommandAdapter = {\n  toolId: 'qwen',\n\n  getFilePath(commandId: string): string {\n    return path.join('.qwen', 'commands', `opsx-${commandId}.toml`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `description = \"${content.description}\"\n\nprompt = \"\"\"\n${content.body}\n\"\"\"\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/roocode.ts",
    "content": "/**\n * RooCode Command Adapter\n *\n * Formats commands for RooCode following its workflow specification.\n * RooCode uses markdown headers instead of YAML frontmatter.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * RooCode adapter for command generation.\n * File path: .roo/commands/opsx-<id>.md\n * Format: Markdown header with description\n */\nexport const roocodeAdapter: ToolCommandAdapter = {\n  toolId: 'roocode',\n\n  getFilePath(commandId: string): string {\n    return path.join('.roo', 'commands', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `# ${content.name}\n\n${content.description}\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/adapters/windsurf.ts",
    "content": "/**\n * Windsurf Command Adapter\n *\n * Formats commands for Windsurf following its frontmatter specification.\n * Windsurf uses a similar format to Claude but may have different conventions.\n */\n\nimport path from 'path';\nimport type { CommandContent, ToolCommandAdapter } from '../types.js';\n\n/**\n * Escapes a string value for safe YAML output.\n * Quotes the string if it contains special YAML characters.\n */\nfunction escapeYamlValue(value: string): string {\n  // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)\n  const needsQuoting = /[:\\n\\r#{}[\\],&*!|>'\"%@`]|^\\s|\\s$/.test(value);\n  if (needsQuoting) {\n    // Use double quotes and escape internal double quotes and backslashes\n    const escaped = value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"').replace(/\\n/g, '\\\\n');\n    return `\"${escaped}\"`;\n  }\n  return value;\n}\n\n/**\n * Formats a tags array as a YAML array with proper escaping.\n */\nfunction formatTagsArray(tags: string[]): string {\n  const escapedTags = tags.map((tag) => escapeYamlValue(tag));\n  return `[${escapedTags.join(', ')}]`;\n}\n\n/**\n * Windsurf adapter for command generation.\n * File path: .windsurf/workflows/opsx-<id>.md\n * Frontmatter: name, description, category, tags\n */\nexport const windsurfAdapter: ToolCommandAdapter = {\n  toolId: 'windsurf',\n\n  getFilePath(commandId: string): string {\n    return path.join('.windsurf', 'workflows', `opsx-${commandId}.md`);\n  },\n\n  formatFile(content: CommandContent): string {\n    return `---\nname: ${escapeYamlValue(content.name)}\ndescription: ${escapeYamlValue(content.description)}\ncategory: ${escapeYamlValue(content.category)}\ntags: ${formatTagsArray(content.tags)}\n---\n\n${content.body}\n`;\n  },\n};\n"
  },
  {
    "path": "src/core/command-generation/generator.ts",
    "content": "/**\n * Command Generator\n *\n * Functions for generating command files using tool adapters.\n */\n\nimport type { CommandContent, ToolCommandAdapter, GeneratedCommand } from './types.js';\n\n/**\n * Generate a single command file using the provided adapter.\n * @param content - The tool-agnostic command content\n * @param adapter - The tool-specific adapter\n * @returns Generated command with path and file content\n */\nexport function generateCommand(\n  content: CommandContent,\n  adapter: ToolCommandAdapter\n): GeneratedCommand {\n  return {\n    path: adapter.getFilePath(content.id),\n    fileContent: adapter.formatFile(content),\n  };\n}\n\n/**\n * Generate multiple command files using the provided adapter.\n * @param contents - Array of tool-agnostic command contents\n * @param adapter - The tool-specific adapter\n * @returns Array of generated commands with paths and file contents\n */\nexport function generateCommands(\n  contents: CommandContent[],\n  adapter: ToolCommandAdapter\n): GeneratedCommand[] {\n  return contents.map((content) => generateCommand(content, adapter));\n}\n"
  },
  {
    "path": "src/core/command-generation/index.ts",
    "content": "/**\n * Command Generation Module\n *\n * Generic command generation system with tool-specific adapters.\n *\n * Usage:\n * ```typescript\n * import { generateCommands, CommandAdapterRegistry, type CommandContent } from './command-generation/index.js';\n *\n * const contents: CommandContent[] = [...];\n * const adapter = CommandAdapterRegistry.get('cursor');\n * if (adapter) {\n *   const commands = generateCommands(contents, adapter);\n *   // Write commands to disk\n * }\n * ```\n */\n\n// Types\nexport type {\n  CommandContent,\n  ToolCommandAdapter,\n  GeneratedCommand,\n} from './types.js';\n\n// Registry\nexport { CommandAdapterRegistry } from './registry.js';\n\n// Generator functions\nexport { generateCommand, generateCommands } from './generator.js';\n\n// Adapters (for direct access if needed)\nexport { claudeAdapter, cursorAdapter, windsurfAdapter } from './adapters/index.js';\n"
  },
  {
    "path": "src/core/command-generation/registry.ts",
    "content": "/**\n * Command Adapter Registry\n *\n * Centralized registry for tool command adapters.\n * Similar pattern to existing SlashCommandRegistry in the codebase.\n */\n\nimport type { ToolCommandAdapter } from './types.js';\nimport { amazonQAdapter } from './adapters/amazon-q.js';\nimport { antigravityAdapter } from './adapters/antigravity.js';\nimport { auggieAdapter } from './adapters/auggie.js';\nimport { claudeAdapter } from './adapters/claude.js';\nimport { clineAdapter } from './adapters/cline.js';\nimport { codexAdapter } from './adapters/codex.js';\nimport { codebuddyAdapter } from './adapters/codebuddy.js';\nimport { continueAdapter } from './adapters/continue.js';\nimport { costrictAdapter } from './adapters/costrict.js';\nimport { crushAdapter } from './adapters/crush.js';\nimport { cursorAdapter } from './adapters/cursor.js';\nimport { factoryAdapter } from './adapters/factory.js';\nimport { geminiAdapter } from './adapters/gemini.js';\nimport { githubCopilotAdapter } from './adapters/github-copilot.js';\nimport { iflowAdapter } from './adapters/iflow.js';\nimport { kilocodeAdapter } from './adapters/kilocode.js';\nimport { kiroAdapter } from './adapters/kiro.js';\nimport { opencodeAdapter } from './adapters/opencode.js';\nimport { piAdapter } from './adapters/pi.js';\nimport { qoderAdapter } from './adapters/qoder.js';\nimport { qwenAdapter } from './adapters/qwen.js';\nimport { roocodeAdapter } from './adapters/roocode.js';\nimport { windsurfAdapter } from './adapters/windsurf.js';\n\n/**\n * Registry for looking up tool command adapters.\n */\nexport class CommandAdapterRegistry {\n  private static adapters: Map<string, ToolCommandAdapter> = new Map();\n\n  // Static initializer - register built-in adapters\n  static {\n    CommandAdapterRegistry.register(amazonQAdapter);\n    CommandAdapterRegistry.register(antigravityAdapter);\n    CommandAdapterRegistry.register(auggieAdapter);\n    CommandAdapterRegistry.register(claudeAdapter);\n    CommandAdapterRegistry.register(clineAdapter);\n    CommandAdapterRegistry.register(codexAdapter);\n    CommandAdapterRegistry.register(codebuddyAdapter);\n    CommandAdapterRegistry.register(continueAdapter);\n    CommandAdapterRegistry.register(costrictAdapter);\n    CommandAdapterRegistry.register(crushAdapter);\n    CommandAdapterRegistry.register(cursorAdapter);\n    CommandAdapterRegistry.register(factoryAdapter);\n    CommandAdapterRegistry.register(geminiAdapter);\n    CommandAdapterRegistry.register(githubCopilotAdapter);\n    CommandAdapterRegistry.register(iflowAdapter);\n    CommandAdapterRegistry.register(kilocodeAdapter);\n    CommandAdapterRegistry.register(kiroAdapter);\n    CommandAdapterRegistry.register(opencodeAdapter);\n    CommandAdapterRegistry.register(piAdapter);\n    CommandAdapterRegistry.register(qoderAdapter);\n    CommandAdapterRegistry.register(qwenAdapter);\n    CommandAdapterRegistry.register(roocodeAdapter);\n    CommandAdapterRegistry.register(windsurfAdapter);\n  }\n\n  /**\n   * Register a tool command adapter.\n   * @param adapter - The adapter to register\n   */\n  static register(adapter: ToolCommandAdapter): void {\n    CommandAdapterRegistry.adapters.set(adapter.toolId, adapter);\n  }\n\n  /**\n   * Get an adapter by tool ID.\n   * @param toolId - The tool identifier (e.g., 'claude', 'cursor')\n   * @returns The adapter or undefined if not registered\n   */\n  static get(toolId: string): ToolCommandAdapter | undefined {\n    return CommandAdapterRegistry.adapters.get(toolId);\n  }\n\n  /**\n   * Get all registered adapters.\n   * @returns Array of all registered adapters\n   */\n  static getAll(): ToolCommandAdapter[] {\n    return Array.from(CommandAdapterRegistry.adapters.values());\n  }\n\n  /**\n   * Check if an adapter is registered for a tool.\n   * @param toolId - The tool identifier\n   * @returns True if an adapter exists\n   */\n  static has(toolId: string): boolean {\n    return CommandAdapterRegistry.adapters.has(toolId);\n  }\n}\n"
  },
  {
    "path": "src/core/command-generation/types.ts",
    "content": "/**\n * Command Generation Types\n *\n * Tool-agnostic interfaces for command generation.\n * These types separate \"what to generate\" from \"how to format it\".\n */\n\n/**\n * Tool-agnostic command data.\n * Represents the content of a command without any tool-specific formatting.\n */\nexport interface CommandContent {\n  /** Command identifier (e.g., 'explore', 'apply', 'new') */\n  id: string;\n  /** Human-readable name (e.g., 'OpenSpec Explore') */\n  name: string;\n  /** Brief description of command purpose */\n  description: string;\n  /** Grouping category (e.g., 'Workflow') */\n  category: string;\n  /** Array of tag strings */\n  tags: string[];\n  /** The command instruction content (body text) */\n  body: string;\n}\n\n/**\n * Per-tool formatting strategy.\n * Each AI tool implements this interface to handle its specific file path\n * and frontmatter format requirements.\n */\nexport interface ToolCommandAdapter {\n  /** Tool identifier matching AIToolOption.value (e.g., 'claude', 'cursor') */\n  toolId: string;\n  /**\n   * Returns the file path for a command.\n   * @param commandId - The command identifier (e.g., 'explore')\n   * @returns Path from project root (e.g., '.claude/commands/opsx/explore.md').\n   *          May be absolute for tools with global-scoped prompts (e.g., Codex).\n   */\n  getFilePath(commandId: string): string;\n  /**\n   * Formats the complete file content including frontmatter.\n   * @param content - The tool-agnostic command content\n   * @returns Complete file content ready to write\n   */\n  formatFile(content: CommandContent): string;\n}\n\n/**\n * Result of generating a command file.\n */\nexport interface GeneratedCommand {\n  /** File path from project root, or absolute for global-scoped tools */\n  path: string;\n  /** Complete file content (frontmatter + body) */\n  fileContent: string;\n}\n"
  },
  {
    "path": "src/core/completions/command-registry.ts",
    "content": "import { CommandDefinition, FlagDefinition } from './types.js';\n\n/**\n * Common flags used across multiple commands\n */\nconst COMMON_FLAGS = {\n  json: {\n    name: 'json',\n    description: 'Output as JSON',\n  } as FlagDefinition,\n  jsonValidation: {\n    name: 'json',\n    description: 'Output validation results as JSON',\n  } as FlagDefinition,\n  strict: {\n    name: 'strict',\n    description: 'Enable strict validation mode',\n  } as FlagDefinition,\n  noInteractive: {\n    name: 'no-interactive',\n    description: 'Disable interactive prompts',\n  } as FlagDefinition,\n  type: {\n    name: 'type',\n    description: 'Specify item type when ambiguous',\n    takesValue: true,\n    values: ['change', 'spec'],\n  } as FlagDefinition,\n} as const;\n\n/**\n * Registry of all OpenSpec CLI commands with their flags and metadata.\n * This registry is used to generate shell completion scripts.\n */\nexport const COMMAND_REGISTRY: CommandDefinition[] = [\n  {\n    name: 'init',\n    description: 'Initialize OpenSpec in your project',\n    acceptsPositional: true,\n    positionalType: 'path',\n    flags: [\n      {\n        name: 'tools',\n        description: 'Configure AI tools non-interactively (e.g., \"all\", \"none\", or comma-separated tool IDs)',\n        takesValue: true,\n      },\n    ],\n  },\n  {\n    name: 'update',\n    description: 'Update OpenSpec instruction files',\n    acceptsPositional: true,\n    positionalType: 'path',\n    flags: [],\n  },\n  {\n    name: 'list',\n    description: 'List items (changes by default, or specs with --specs)',\n    flags: [\n      {\n        name: 'specs',\n        description: 'List specs instead of changes',\n      },\n      {\n        name: 'changes',\n        description: 'List changes explicitly (default)',\n      },\n    ],\n  },\n  {\n    name: 'view',\n    description: 'Display an interactive dashboard of specs and changes',\n    flags: [],\n  },\n  {\n    name: 'validate',\n    description: 'Validate changes and specs',\n    acceptsPositional: true,\n    positionalType: 'change-or-spec-id',\n    flags: [\n      {\n        name: 'all',\n        description: 'Validate all changes and specs',\n      },\n      {\n        name: 'changes',\n        description: 'Validate all changes',\n      },\n      {\n        name: 'specs',\n        description: 'Validate all specs',\n      },\n      COMMON_FLAGS.type,\n      COMMON_FLAGS.strict,\n      COMMON_FLAGS.jsonValidation,\n      {\n        name: 'concurrency',\n        description: 'Max concurrent validations (defaults to env OPENSPEC_CONCURRENCY or 6)',\n        takesValue: true,\n      },\n      COMMON_FLAGS.noInteractive,\n    ],\n  },\n  {\n    name: 'show',\n    description: 'Show a change or spec',\n    acceptsPositional: true,\n    positionalType: 'change-or-spec-id',\n    flags: [\n      COMMON_FLAGS.json,\n      COMMON_FLAGS.type,\n      COMMON_FLAGS.noInteractive,\n      {\n        name: 'deltas-only',\n        description: 'Show only deltas (JSON only, change-specific)',\n      },\n      {\n        name: 'requirements-only',\n        description: 'Alias for --deltas-only (deprecated, change-specific)',\n      },\n      {\n        name: 'requirements',\n        description: 'Show only requirements, exclude scenarios (JSON only, spec-specific)',\n      },\n      {\n        name: 'no-scenarios',\n        description: 'Exclude scenario content (JSON only, spec-specific)',\n      },\n      {\n        name: 'requirement',\n        short: 'r',\n        description: 'Show specific requirement by ID (JSON only, spec-specific)',\n        takesValue: true,\n      },\n    ],\n  },\n  {\n    name: 'archive',\n    description: 'Archive a completed change and update main specs',\n    acceptsPositional: true,\n    positionalType: 'change-id',\n    flags: [\n      {\n        name: 'yes',\n        short: 'y',\n        description: 'Skip confirmation prompts',\n      },\n      {\n        name: 'skip-specs',\n        description: 'Skip spec update operations',\n      },\n      {\n        name: 'no-validate',\n        description: 'Skip validation (not recommended)',\n      },\n    ],\n  },\n  {\n    name: 'feedback',\n    description: 'Submit feedback about OpenSpec',\n    acceptsPositional: true,\n    flags: [\n      {\n        name: 'body',\n        description: 'Detailed description for the feedback',\n        takesValue: true,\n      },\n    ],\n  },\n  {\n    name: 'change',\n    description: 'Manage OpenSpec change proposals (deprecated)',\n    flags: [],\n    subcommands: [\n      {\n        name: 'show',\n        description: 'Show a change proposal',\n        acceptsPositional: true,\n        positionalType: 'change-id',\n        flags: [\n          COMMON_FLAGS.json,\n          {\n            name: 'deltas-only',\n            description: 'Show only deltas (JSON only)',\n          },\n          {\n            name: 'requirements-only',\n            description: 'Alias for --deltas-only (deprecated)',\n          },\n          COMMON_FLAGS.noInteractive,\n        ],\n      },\n      {\n        name: 'list',\n        description: 'List all active changes (deprecated)',\n        flags: [\n          COMMON_FLAGS.json,\n          {\n            name: 'long',\n            description: 'Show id and title with counts',\n          },\n        ],\n      },\n      {\n        name: 'validate',\n        description: 'Validate a change proposal',\n        acceptsPositional: true,\n        positionalType: 'change-id',\n        flags: [\n          COMMON_FLAGS.strict,\n          COMMON_FLAGS.jsonValidation,\n          COMMON_FLAGS.noInteractive,\n        ],\n      },\n    ],\n  },\n  {\n    name: 'spec',\n    description: 'Manage OpenSpec specifications',\n    flags: [],\n    subcommands: [\n      {\n        name: 'show',\n        description: 'Show a specification',\n        acceptsPositional: true,\n        positionalType: 'spec-id',\n        flags: [\n          COMMON_FLAGS.json,\n          {\n            name: 'requirements',\n            description: 'Show only requirements, exclude scenarios (JSON only)',\n          },\n          {\n            name: 'no-scenarios',\n            description: 'Exclude scenario content (JSON only)',\n          },\n          {\n            name: 'requirement',\n            short: 'r',\n            description: 'Show specific requirement by ID (JSON only)',\n            takesValue: true,\n          },\n          COMMON_FLAGS.noInteractive,\n        ],\n      },\n      {\n        name: 'list',\n        description: 'List all specifications',\n        flags: [\n          COMMON_FLAGS.json,\n          {\n            name: 'long',\n            description: 'Show id and title with counts',\n          },\n        ],\n      },\n      {\n        name: 'validate',\n        description: 'Validate a specification',\n        acceptsPositional: true,\n        positionalType: 'spec-id',\n        flags: [\n          COMMON_FLAGS.strict,\n          COMMON_FLAGS.jsonValidation,\n          COMMON_FLAGS.noInteractive,\n        ],\n      },\n    ],\n  },\n  {\n    name: 'completion',\n    description: 'Manage shell completions for OpenSpec CLI',\n    flags: [],\n    subcommands: [\n      {\n        name: 'generate',\n        description: 'Generate completion script for a shell (outputs to stdout)',\n        acceptsPositional: true,\n        positionalType: 'shell',\n        flags: [],\n      },\n      {\n        name: 'install',\n        description: 'Install completion script for a shell',\n        acceptsPositional: true,\n        positionalType: 'shell',\n        flags: [\n          {\n            name: 'verbose',\n            description: 'Show detailed installation output',\n          },\n        ],\n      },\n      {\n        name: 'uninstall',\n        description: 'Uninstall completion script for a shell',\n        acceptsPositional: true,\n        positionalType: 'shell',\n        flags: [\n          {\n            name: 'yes',\n            short: 'y',\n            description: 'Skip confirmation prompts',\n          },\n        ],\n      },\n    ],\n  },\n  {\n    name: 'config',\n    description: 'View and modify global OpenSpec configuration',\n    flags: [\n      {\n        name: 'scope',\n        description: 'Config scope (only \"global\" supported currently)',\n        takesValue: true,\n        values: ['global'],\n      },\n    ],\n    subcommands: [\n      {\n        name: 'path',\n        description: 'Show config file location',\n        flags: [],\n      },\n      {\n        name: 'list',\n        description: 'Show all current settings',\n        flags: [\n          COMMON_FLAGS.json,\n        ],\n      },\n      {\n        name: 'get',\n        description: 'Get a specific value (raw, scriptable)',\n        acceptsPositional: true,\n        flags: [],\n      },\n      {\n        name: 'set',\n        description: 'Set a value (auto-coerce types)',\n        acceptsPositional: true,\n        flags: [\n          {\n            name: 'string',\n            description: 'Force value to be stored as string',\n          },\n          {\n            name: 'allow-unknown',\n            description: 'Allow setting unknown keys',\n          },\n        ],\n      },\n      {\n        name: 'unset',\n        description: 'Remove a key (revert to default)',\n        acceptsPositional: true,\n        flags: [],\n      },\n      {\n        name: 'reset',\n        description: 'Reset configuration to defaults',\n        flags: [\n          {\n            name: 'all',\n            description: 'Reset all configuration (required)',\n          },\n          {\n            name: 'yes',\n            short: 'y',\n            description: 'Skip confirmation prompts',\n          },\n        ],\n      },\n      {\n        name: 'edit',\n        description: 'Open config in $EDITOR',\n        flags: [],\n      },\n      {\n        name: 'profile',\n        description: 'Configure workflow profile (interactive picker or preset shortcut)',\n        flags: [],\n      },\n    ],\n  },\n  {\n    name: 'schema',\n    description: 'Manage workflow schemas',\n    flags: [],\n    subcommands: [\n      {\n        name: 'which',\n        description: 'Show where a schema resolves from',\n        acceptsPositional: true,\n        positionalType: 'schema-name',\n        flags: [\n          COMMON_FLAGS.json,\n          {\n            name: 'all',\n            description: 'List all schemas with their resolution sources',\n          },\n        ],\n      },\n      {\n        name: 'validate',\n        description: 'Validate a schema structure and templates',\n        acceptsPositional: true,\n        positionalType: 'schema-name',\n        flags: [\n          COMMON_FLAGS.json,\n          {\n            name: 'verbose',\n            description: 'Show detailed validation steps',\n          },\n        ],\n      },\n      {\n        name: 'fork',\n        description: 'Copy an existing schema to project for customization',\n        acceptsPositional: true,\n        positionalType: 'schema-name',\n        flags: [\n          COMMON_FLAGS.json,\n          {\n            name: 'force',\n            description: 'Overwrite existing destination',\n          },\n        ],\n      },\n      {\n        name: 'init',\n        description: 'Create a new project-local schema',\n        acceptsPositional: true,\n        flags: [\n          COMMON_FLAGS.json,\n          {\n            name: 'description',\n            description: 'Schema description',\n            takesValue: true,\n          },\n          {\n            name: 'artifacts',\n            description: 'Comma-separated artifact IDs',\n            takesValue: true,\n          },\n          {\n            name: 'default',\n            description: 'Set as project default schema',\n          },\n          {\n            name: 'no-default',\n            description: 'Do not prompt to set as default',\n          },\n          {\n            name: 'force',\n            description: 'Overwrite existing schema',\n          },\n        ],\n      },\n    ],\n  },\n];\n"
  },
  {
    "path": "src/core/completions/completion-provider.ts",
    "content": "import { getActiveChangeIds, getSpecIds } from '../../utils/item-discovery.js';\n\n/**\n * Cache entry for completion data\n */\ninterface CacheEntry<T> {\n  data: T;\n  timestamp: number;\n}\n\n/**\n * Provides dynamic completion suggestions for OpenSpec items (changes and specs).\n * Implements a 2-second cache to avoid excessive file system operations during\n * tab completion.\n */\nexport class CompletionProvider {\n  private readonly cacheTTL: number;\n  private changeCache: CacheEntry<string[]> | null = null;\n  private specCache: CacheEntry<string[]> | null = null;\n\n  /**\n   * Creates a new completion provider\n   *\n   * @param cacheTTLMs - Cache time-to-live in milliseconds (default: 2000ms)\n   * @param projectRoot - Project root directory (default: process.cwd())\n   */\n  constructor(\n    private readonly cacheTTLMs: number = 2000,\n    private readonly projectRoot: string = process.cwd()\n  ) {\n    this.cacheTTL = cacheTTLMs;\n  }\n\n  /**\n   * Get all active change IDs for completion\n   *\n   * @returns Array of change IDs\n   */\n  async getChangeIds(): Promise<string[]> {\n    const now = Date.now();\n\n    // Check if cache is valid\n    if (this.changeCache && now - this.changeCache.timestamp < this.cacheTTL) {\n      return this.changeCache.data;\n    }\n\n    // Fetch fresh data\n    const changeIds = await getActiveChangeIds(this.projectRoot);\n\n    // Update cache\n    this.changeCache = {\n      data: changeIds,\n      timestamp: now,\n    };\n\n    return changeIds;\n  }\n\n  /**\n   * Get all spec IDs for completion\n   *\n   * @returns Array of spec IDs\n   */\n  async getSpecIds(): Promise<string[]> {\n    const now = Date.now();\n\n    // Check if cache is valid\n    if (this.specCache && now - this.specCache.timestamp < this.cacheTTL) {\n      return this.specCache.data;\n    }\n\n    // Fetch fresh data\n    const specIds = await getSpecIds(this.projectRoot);\n\n    // Update cache\n    this.specCache = {\n      data: specIds,\n      timestamp: now,\n    };\n\n    return specIds;\n  }\n\n  /**\n   * Get both change and spec IDs for completion\n   *\n   * @returns Object with changeIds and specIds arrays\n   */\n  async getAllIds(): Promise<{ changeIds: string[]; specIds: string[] }> {\n    const [changeIds, specIds] = await Promise.all([\n      this.getChangeIds(),\n      this.getSpecIds(),\n    ]);\n\n    return { changeIds, specIds };\n  }\n\n  /**\n   * Clear all cached data\n   */\n  clearCache(): void {\n    this.changeCache = null;\n    this.specCache = null;\n  }\n\n  /**\n   * Get cache statistics for debugging\n   *\n   * @returns Cache status information\n   */\n  getCacheStats(): {\n    changeCache: { valid: boolean; age?: number };\n    specCache: { valid: boolean; age?: number };\n  } {\n    const now = Date.now();\n\n    return {\n      changeCache: {\n        valid: this.changeCache !== null && now - this.changeCache.timestamp < this.cacheTTL,\n        age: this.changeCache ? now - this.changeCache.timestamp : undefined,\n      },\n      specCache: {\n        valid: this.specCache !== null && now - this.specCache.timestamp < this.cacheTTL,\n        age: this.specCache ? now - this.specCache.timestamp : undefined,\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "src/core/completions/factory.ts",
    "content": "import { CompletionGenerator } from './types.js';\nimport { ZshGenerator } from './generators/zsh-generator.js';\nimport { BashGenerator } from './generators/bash-generator.js';\nimport { FishGenerator } from './generators/fish-generator.js';\nimport { PowerShellGenerator } from './generators/powershell-generator.js';\nimport { ZshInstaller } from './installers/zsh-installer.js';\nimport { BashInstaller } from './installers/bash-installer.js';\nimport { FishInstaller } from './installers/fish-installer.js';\nimport { PowerShellInstaller } from './installers/powershell-installer.js';\nimport { SupportedShell } from '../../utils/shell-detection.js';\n\n/**\n * Common installation result interface\n */\nexport interface InstallationResult {\n  success: boolean;\n  installedPath?: string;\n  backupPath?: string;\n  message: string;\n  instructions?: string[];\n  warnings?: string[];\n  // Shell-specific optional fields\n  isOhMyZsh?: boolean;\n  zshrcConfigured?: boolean;\n  bashrcConfigured?: boolean;\n  profileConfigured?: boolean;\n}\n\n/**\n * Interface for completion installers\n */\nexport interface CompletionInstaller {\n  install(script: string): Promise<InstallationResult>;\n  uninstall(): Promise<{ success: boolean; message: string }>;\n}\n\n/**\n * Factory for creating completion generators and installers\n * This design makes it easy to add support for additional shells\n */\nexport class CompletionFactory {\n  private static readonly SUPPORTED_SHELLS: SupportedShell[] = ['zsh', 'bash', 'fish', 'powershell'];\n\n  /**\n   * Create a completion generator for the specified shell\n   *\n   * @param shell - The target shell\n   * @returns CompletionGenerator instance\n   * @throws Error if shell is not supported\n   */\n  static createGenerator(shell: SupportedShell): CompletionGenerator {\n    switch (shell) {\n      case 'zsh':\n        return new ZshGenerator();\n      case 'bash':\n        return new BashGenerator();\n      case 'fish':\n        return new FishGenerator();\n      case 'powershell':\n        return new PowerShellGenerator();\n      default:\n        throw new Error(`Unsupported shell: ${shell}`);\n    }\n  }\n\n  /**\n   * Create a completion installer for the specified shell\n   *\n   * @param shell - The target shell\n   * @returns CompletionInstaller instance\n   * @throws Error if shell is not supported\n   */\n  static createInstaller(shell: SupportedShell): CompletionInstaller {\n    switch (shell) {\n      case 'zsh':\n        return new ZshInstaller();\n      case 'bash':\n        return new BashInstaller();\n      case 'fish':\n        return new FishInstaller();\n      case 'powershell':\n        return new PowerShellInstaller();\n      default:\n        throw new Error(`Unsupported shell: ${shell}`);\n    }\n  }\n\n  /**\n   * Check if a shell is supported\n   *\n   * @param shell - The shell to check\n   * @returns true if the shell is supported\n   */\n  static isSupported(shell: string): shell is SupportedShell {\n    return this.SUPPORTED_SHELLS.includes(shell as SupportedShell);\n  }\n\n  /**\n   * Get list of all supported shells\n   *\n   * @returns Array of supported shell names\n   */\n  static getSupportedShells(): SupportedShell[] {\n    return [...this.SUPPORTED_SHELLS];\n  }\n}\n"
  },
  {
    "path": "src/core/completions/generators/bash-generator.ts",
    "content": "import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';\nimport { BASH_DYNAMIC_HELPERS } from '../templates/bash-templates.js';\n\n/**\n * Generates Bash completion scripts for the OpenSpec CLI.\n * Follows Bash completion conventions using complete builtin and COMPREPLY array.\n */\nexport class BashGenerator implements CompletionGenerator {\n  readonly shell = 'bash' as const;\n\n  /**\n   * Generate a Bash completion script\n   *\n   * @param commands - Command definitions to generate completions for\n   * @returns Bash completion script as a string\n   */\n  generate(commands: CommandDefinition[]): string {\n    // Build command list for top-level completions\n    const commandList = commands.map(c => this.escapeCommandName(c.name)).join(' ');\n\n    // Build command cases using push() for loop clarity\n    const caseLines: string[] = [];\n    for (const cmd of commands) {\n      caseLines.push(`    ${cmd.name})`);\n      caseLines.push(...this.generateCommandCase(cmd, '      '));\n      caseLines.push('      ;;');\n    }\n    const commandCases = caseLines.join('\\n');\n\n    // Dynamic completion helpers from template\n    const helpers = BASH_DYNAMIC_HELPERS;\n\n    // Assemble final script with template literal\n    return `# Bash completion script for OpenSpec CLI\n# Auto-generated - do not edit manually\n\n_openspec_completion() {\n  local cur prev words cword\n\n  # Use _init_completion if available (from bash-completion package)\n  # The -n : option prevents colons from being treated as word separators\n  # (important for spec/change IDs that may contain colons)\n  # Otherwise, fall back to manual initialization\n  if declare -F _init_completion >/dev/null 2>&1; then\n    _init_completion -n : || return\n  else\n    # Manual fallback when bash-completion is not installed\n    COMPREPLY=()\n    cur=\"\\${COMP_WORDS[COMP_CWORD]}\"\n    prev=\"\\${COMP_WORDS[COMP_CWORD-1]}\"\n    words=(\"\\${COMP_WORDS[@]}\")\n    cword=$COMP_CWORD\n  fi\n\n  local cmd=\"\\${words[1]}\"\n  local subcmd=\"\\${words[2]}\"\n\n  # Top-level commands\n  if [[ $cword -eq 1 ]]; then\n    local commands=\"${commandList}\"\n    COMPREPLY=($(compgen -W \"$commands\" -- \"$cur\"))\n    return 0\n  fi\n\n  # Command-specific completion\n  case \"$cmd\" in\n${commandCases}\n  esac\n\n  return 0\n}\n\n${helpers}\ncomplete -F _openspec_completion openspec\n`;\n  }\n\n  /**\n   * Generate completion case logic for a command\n   */\n  private generateCommandCase(cmd: CommandDefinition, indent: string): string[] {\n    const lines: string[] = [];\n\n    // Handle subcommands\n    if (cmd.subcommands && cmd.subcommands.length > 0) {\n      // First, check if user is typing a flag for the parent command\n      if (cmd.flags.length > 0) {\n        lines.push(`${indent}if [[ \"$cur\" == -* ]]; then`);\n        const flags = cmd.flags.map(f => {\n          const parts: string[] = [];\n          if (f.short) parts.push(`-${f.short}`);\n          parts.push(`--${f.name}`);\n          return parts.join(' ');\n        }).join(' ');\n        lines.push(`${indent}  local flags=\"${flags}\"`);\n        lines.push(`${indent}  COMPREPLY=($(compgen -W \"$flags\" -- \"$cur\"))`);\n        lines.push(`${indent}  return 0`);\n        lines.push(`${indent}fi`);\n        lines.push('');\n      }\n\n      lines.push(`${indent}if [[ $cword -eq 2 ]]; then`);\n      lines.push(`${indent}  local subcommands=\"` + cmd.subcommands.map(s => this.escapeCommandName(s.name)).join(' ') + '\"');\n      lines.push(`${indent}  COMPREPLY=($(compgen -W \"$subcommands\" -- \"$cur\"))`);\n      lines.push(`${indent}  return 0`);\n      lines.push(`${indent}fi`);\n      lines.push('');\n      lines.push(`${indent}case \"$subcmd\" in`);\n\n      for (const subcmd of cmd.subcommands) {\n        lines.push(`${indent}  ${subcmd.name})`);\n        lines.push(...this.generateArgumentCompletion(subcmd, indent + '    '));\n        lines.push(`${indent}    ;;`);\n      }\n\n      lines.push(`${indent}esac`);\n    } else {\n      // No subcommands, just complete arguments\n      lines.push(...this.generateArgumentCompletion(cmd, indent));\n    }\n\n    return lines;\n  }\n\n  /**\n   * Generate argument completion (flags and positional arguments)\n   */\n  private generateArgumentCompletion(cmd: CommandDefinition, indent: string): string[] {\n    const lines: string[] = [];\n\n    // Check for flag completion\n    if (cmd.flags.length > 0) {\n      lines.push(`${indent}if [[ \"$cur\" == -* ]]; then`);\n      const flags = cmd.flags.map(f => {\n        const parts: string[] = [];\n        if (f.short) parts.push(`-${f.short}`);\n        parts.push(`--${f.name}`);\n        return parts.join(' ');\n      }).join(' ');\n      lines.push(`${indent}  local flags=\"${flags}\"`);\n      lines.push(`${indent}  COMPREPLY=($(compgen -W \"$flags\" -- \"$cur\"))`);\n      lines.push(`${indent}  return 0`);\n      lines.push(`${indent}fi`);\n      lines.push('');\n    }\n\n    // Handle positional completions\n    if (cmd.acceptsPositional) {\n      lines.push(...this.generatePositionalCompletion(cmd.positionalType, indent));\n    }\n\n    return lines;\n  }\n\n  /**\n   * Generate positional argument completion based on type\n   */\n  private generatePositionalCompletion(positionalType: string | undefined, indent: string): string[] {\n    const lines: string[] = [];\n\n    switch (positionalType) {\n      case 'change-id':\n        lines.push(`${indent}_openspec_complete_changes`);\n        break;\n      case 'spec-id':\n        lines.push(`${indent}_openspec_complete_specs`);\n        break;\n      case 'change-or-spec-id':\n        lines.push(`${indent}_openspec_complete_items`);\n        break;\n      case 'shell':\n        lines.push(`${indent}local shells=\"zsh bash fish powershell\"`);\n        lines.push(`${indent}COMPREPLY=($(compgen -W \"$shells\" -- \"$cur\"))`);\n        break;\n      case 'path':\n        lines.push(`${indent}COMPREPLY=($(compgen -f -- \"$cur\"))`);\n        break;\n    }\n\n    return lines;\n  }\n\n\n  /**\n   * Escape command/subcommand names for safe use in Bash scripts\n   */\n  private escapeCommandName(name: string): string {\n    // Escape shell metacharacters to prevent command injection\n    return name.replace(/[\"\\$`\\\\]/g, '\\\\$&');\n  }\n}\n"
  },
  {
    "path": "src/core/completions/generators/fish-generator.ts",
    "content": "import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';\nimport { FISH_STATIC_HELPERS, FISH_DYNAMIC_HELPERS } from '../templates/fish-templates.js';\n\n/**\n * Generates Fish completion scripts for the OpenSpec CLI.\n * Follows Fish completion conventions using the complete command.\n */\nexport class FishGenerator implements CompletionGenerator {\n  readonly shell = 'fish' as const;\n\n  /**\n   * Generate a Fish completion script\n   *\n   * @param commands - Command definitions to generate completions for\n   * @returns Fish completion script as a string\n   */\n  generate(commands: CommandDefinition[]): string {\n    // Build top-level commands using push() for loop clarity\n    const topLevelLines: string[] = [];\n    for (const cmd of commands) {\n      topLevelLines.push(`# ${cmd.name} command`);\n      topLevelLines.push(\n        `complete -c openspec -n '__fish_openspec_no_subcommand' -a '${cmd.name}' -d '${this.escapeDescription(cmd.description)}'`\n      );\n    }\n    const topLevelCommands = topLevelLines.join('\\n');\n\n    // Build command-specific completions using push() for loop clarity\n    const commandCompletionLines: string[] = [];\n    for (const cmd of commands) {\n      commandCompletionLines.push(...this.generateCommandCompletions(cmd));\n      commandCompletionLines.push('');\n    }\n    const commandCompletions = commandCompletionLines.join('\\n');\n\n    // Static helper functions from template\n    const helperFunctions = FISH_STATIC_HELPERS;\n\n    // Dynamic completion helpers from template\n    const dynamicHelpers = FISH_DYNAMIC_HELPERS;\n\n    // Assemble final script with template literal\n    return `# Fish completion script for OpenSpec CLI\n# Auto-generated - do not edit manually\n\n${helperFunctions}\n${dynamicHelpers}\n${topLevelCommands}\n\n${commandCompletions}`;\n  }\n\n  /**\n   * Generate completions for a specific command\n   */\n  private generateCommandCompletions(cmd: CommandDefinition): string[] {\n    const lines: string[] = [];\n\n    // If command has subcommands\n    if (cmd.subcommands && cmd.subcommands.length > 0) {\n      // Add subcommand completions\n      for (const subcmd of cmd.subcommands) {\n        lines.push(\n          `complete -c openspec -n '__fish_openspec_using_subcommand ${cmd.name}; and not __fish_openspec_using_subcommand ${subcmd.name}' -a '${subcmd.name}' -d '${this.escapeDescription(subcmd.description)}'`\n        );\n      }\n      lines.push('');\n\n      // Add flags for parent command\n      for (const flag of cmd.flags) {\n        lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}`));\n      }\n\n      // Add completions for each subcommand\n      for (const subcmd of cmd.subcommands) {\n        lines.push(`# ${cmd.name} ${subcmd.name} flags`);\n        for (const flag of subcmd.flags) {\n          lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));\n        }\n\n        // Add positional completions for subcommand\n        if (subcmd.acceptsPositional) {\n          lines.push(...this.generatePositionalCompletion(subcmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));\n        }\n      }\n    } else {\n      // Command without subcommands\n      lines.push(`# ${cmd.name} flags`);\n      for (const flag of cmd.flags) {\n        lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}`));\n      }\n\n      // Add positional completions\n      if (cmd.acceptsPositional) {\n        lines.push(...this.generatePositionalCompletion(cmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}`));\n      }\n    }\n\n    return lines;\n  }\n\n  /**\n   * Generate flag completion\n   */\n  private generateFlagCompletion(flag: FlagDefinition, condition: string): string[] {\n    const lines: string[] = [];\n    const longFlag = `--${flag.name}`;\n    const shortFlag = flag.short ? `-${flag.short}` : undefined;\n\n    if (flag.takesValue && flag.values) {\n      // Flag with enum values\n      for (const value of flag.values) {\n        if (shortFlag) {\n          lines.push(\n            `complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`\n          );\n        } else {\n          lines.push(\n            `complete -c openspec -n '${condition}' -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`\n          );\n        }\n      }\n    } else if (flag.takesValue) {\n      // Flag that takes a value but no specific values defined\n      if (shortFlag) {\n        lines.push(\n          `complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`\n        );\n      } else {\n        lines.push(\n          `complete -c openspec -n '${condition}' -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`\n        );\n      }\n    } else {\n      // Boolean flag\n      if (shortFlag) {\n        lines.push(\n          `complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`\n        );\n      } else {\n        lines.push(\n          `complete -c openspec -n '${condition}' -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`\n        );\n      }\n    }\n\n    return lines;\n  }\n\n  /**\n   * Generate positional argument completion\n   */\n  private generatePositionalCompletion(positionalType: string | undefined, condition: string): string[] {\n    const lines: string[] = [];\n\n    switch (positionalType) {\n      case 'change-id':\n        lines.push(`complete -c openspec -n '${condition}' -a '(__fish_openspec_changes)' -f`);\n        break;\n      case 'spec-id':\n        lines.push(`complete -c openspec -n '${condition}' -a '(__fish_openspec_specs)' -f`);\n        break;\n      case 'change-or-spec-id':\n        lines.push(`complete -c openspec -n '${condition}' -a '(__fish_openspec_items)' -f`);\n        break;\n      case 'shell':\n        lines.push(`complete -c openspec -n '${condition}' -a 'zsh bash fish powershell' -f`);\n        break;\n      case 'path':\n        // Fish automatically completes files, no need to specify\n        break;\n    }\n\n    return lines;\n  }\n\n\n  /**\n   * Escape description text for Fish\n   */\n  private escapeDescription(description: string): string {\n    return description\n      .replace(/\\\\/g, '\\\\\\\\')  // Backslashes first\n      .replace(/'/g, \"\\\\'\")    // Single quotes\n      .replace(/\\$/g, '\\\\$')   // Dollar signs (prevents $())\n      .replace(/`/g, '\\\\`');   // Backticks\n  }\n}\n"
  },
  {
    "path": "src/core/completions/generators/powershell-generator.ts",
    "content": "import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';\nimport { POWERSHELL_DYNAMIC_HELPERS } from '../templates/powershell-templates.js';\n\n/**\n * Generates PowerShell completion scripts for the OpenSpec CLI.\n * Uses Register-ArgumentCompleter for command completion.\n */\nexport class PowerShellGenerator implements CompletionGenerator {\n  readonly shell = 'powershell' as const;\n\n  private stripTrailingCommaFromLastLine(lines: string[]): void {\n    if (lines.length === 0) return;\n    lines[lines.length - 1] = lines[lines.length - 1].replace(/,\\s*$/, '');\n  }\n\n  /**\n   * Generate a PowerShell completion script\n   *\n   * @param commands - Command definitions to generate completions for\n   * @returns PowerShell completion script as a string\n   */\n  generate(commands: CommandDefinition[]): string {\n    // Build top-level commands using push() for loop clarity\n    const commandLines: string[] = [];\n    for (const cmd of commands) {\n      commandLines.push(`            @{Name=\"${cmd.name}\"; Description=\"${this.escapeDescription(cmd.description)}\"},`);\n    }\n    this.stripTrailingCommaFromLastLine(commandLines);\n    const topLevelCommands = commandLines.join('\\n');\n\n    // Build command cases using push() for loop clarity\n    const commandCaseLines: string[] = [];\n    for (const cmd of commands) {\n      commandCaseLines.push(`        \"${cmd.name}\" {`);\n      commandCaseLines.push(...this.generateCommandCase(cmd, '            '));\n      commandCaseLines.push('        }');\n    }\n    const commandCases = commandCaseLines.join('\\n');\n\n    // Dynamic completion helpers from template\n    const helpers = POWERSHELL_DYNAMIC_HELPERS;\n\n    // Assemble final script with template literal\n    return `# PowerShell completion script for OpenSpec CLI\n# Auto-generated - do not edit manually\n\n${helpers}\n$openspecCompleter = {\n    param($wordToComplete, $commandAst, $cursorPosition)\n\n    $tokens = $commandAst.ToString() -split \"\\\\s+\"\n    $commandCount = ($tokens | Measure-Object).Count\n\n    # Top-level commands\n    if ($commandCount -eq 1 -or ($commandCount -eq 2 -and $wordToComplete)) {\n        $commands = @(\n${topLevelCommands}\n        )\n        $commands | Where-Object { $_.Name -like \"$wordToComplete*\" } | ForEach-Object {\n            [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, \"ParameterValue\", $_.Description)\n        }\n        return\n    }\n\n    $command = $tokens[1]\n\n    switch ($command) {\n${commandCases}\n    }\n}\n\nRegister-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter\n`;\n  }\n\n  /**\n   * Generate completion case for a command\n   */\n  private generateCommandCase(cmd: CommandDefinition, indent: string): string[] {\n    const lines: string[] = [];\n\n    if (cmd.subcommands && cmd.subcommands.length > 0) {\n      // First, check if user is typing a flag for the parent command\n      if (cmd.flags.length > 0) {\n        lines.push(`${indent}if ($wordToComplete -like \"-*\") {`);\n        lines.push(`${indent}    $flags = @(`);\n        for (const flag of cmd.flags) {\n          const longFlag = `--${flag.name}`;\n          const shortFlag = flag.short ? `-${flag.short}` : undefined;\n          if (shortFlag) {\n            lines.push(`${indent}        @{Name=\"${longFlag}\"; Description=\"${this.escapeDescription(flag.description)}\"},`);\n            lines.push(`${indent}        @{Name=\"${shortFlag}\"; Description=\"${this.escapeDescription(flag.description)}\"},`);\n          } else {\n            lines.push(`${indent}        @{Name=\"${longFlag}\"; Description=\"${this.escapeDescription(flag.description)}\"},`);\n          }\n        }\n        this.stripTrailingCommaFromLastLine(lines);\n        lines.push(`${indent}    )`);\n        lines.push(`${indent}    $flags | Where-Object { $_.Name -like \"$wordToComplete*\" } | ForEach-Object {`);\n        lines.push(`${indent}        [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, \"ParameterName\", $_.Description)`);\n        lines.push(`${indent}    }`);\n        lines.push(`${indent}    return`);\n        lines.push(`${indent}}`);\n        lines.push('');\n      }\n\n      // Handle subcommands\n      lines.push(`${indent}if ($commandCount -eq 2 -or ($commandCount -eq 3 -and $wordToComplete)) {`);\n      lines.push(`${indent}    $subcommands = @(`);\n      for (const subcmd of cmd.subcommands) {\n        lines.push(`${indent}        @{Name=\"${subcmd.name}\"; Description=\"${this.escapeDescription(subcmd.description)}\"},`);\n      }\n      this.stripTrailingCommaFromLastLine(lines);\n      lines.push(`${indent}    )`);\n      lines.push(`${indent}    $subcommands | Where-Object { $_.Name -like \"$wordToComplete*\" } | ForEach-Object {`);\n      lines.push(`${indent}        [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, \"ParameterValue\", $_.Description)`);\n      lines.push(`${indent}    }`);\n      lines.push(`${indent}    return`);\n      lines.push(`${indent}}`);\n      lines.push('');\n      lines.push(`${indent}$subcommand = if ($commandCount -gt 2) { $tokens[2] } else { \"\" }`);\n      lines.push(`${indent}switch ($subcommand) {`);\n\n      for (const subcmd of cmd.subcommands) {\n        lines.push(`${indent}    \"${subcmd.name}\" {`);\n        lines.push(...this.generateArgumentCompletion(subcmd, indent + '        '));\n        lines.push(`${indent}    }`);\n      }\n\n      lines.push(`${indent}}`);\n    } else {\n      // No subcommands\n      lines.push(...this.generateArgumentCompletion(cmd, indent));\n    }\n\n    return lines;\n  }\n\n  /**\n   * Generate argument completion (flags and positional)\n   */\n  private generateArgumentCompletion(cmd: CommandDefinition, indent: string): string[] {\n    const lines: string[] = [];\n\n    // Flag completion\n    if (cmd.flags.length > 0) {\n      lines.push(`${indent}if ($wordToComplete -like \"-*\") {`);\n      lines.push(`${indent}    $flags = @(`);\n      for (const flag of cmd.flags) {\n        const longFlag = `--${flag.name}`;\n        const shortFlag = flag.short ? `-${flag.short}` : undefined;\n        if (shortFlag) {\n          lines.push(`${indent}        @{Name=\"${longFlag}\"; Description=\"${this.escapeDescription(flag.description)}\"},`);\n          lines.push(`${indent}        @{Name=\"${shortFlag}\"; Description=\"${this.escapeDescription(flag.description)}\"},`);\n        } else {\n          lines.push(`${indent}        @{Name=\"${longFlag}\"; Description=\"${this.escapeDescription(flag.description)}\"},`);\n        }\n      }\n      this.stripTrailingCommaFromLastLine(lines);\n      lines.push(`${indent}    )`);\n      lines.push(`${indent}    $flags | Where-Object { $_.Name -like \"$wordToComplete*\" } | ForEach-Object {`);\n      lines.push(`${indent}        [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, \"ParameterName\", $_.Description)`);\n      lines.push(`${indent}    }`);\n      lines.push(`${indent}    return`);\n      lines.push(`${indent}}`);\n      lines.push('');\n    }\n\n    // Positional completion\n    if (cmd.acceptsPositional) {\n      lines.push(...this.generatePositionalCompletion(cmd.positionalType, indent));\n    }\n\n    return lines;\n  }\n\n  /**\n   * Generate positional argument completion\n   */\n  private generatePositionalCompletion(positionalType: string | undefined, indent: string): string[] {\n    const lines: string[] = [];\n\n    switch (positionalType) {\n      case 'change-id':\n        lines.push(`${indent}Get-OpenSpecChanges | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {`);\n        lines.push(`${indent}    [System.Management.Automation.CompletionResult]::new($_, $_, \"ParameterValue\", \"Change: $_\")`);\n        lines.push(`${indent}}`);\n        break;\n      case 'spec-id':\n        lines.push(`${indent}Get-OpenSpecSpecs | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {`);\n        lines.push(`${indent}    [System.Management.Automation.CompletionResult]::new($_, $_, \"ParameterValue\", \"Spec: $_\")`);\n        lines.push(`${indent}}`);\n        break;\n      case 'change-or-spec-id':\n        lines.push(`${indent}$items = @(Get-OpenSpecChanges) + @(Get-OpenSpecSpecs)`);\n        lines.push(`${indent}$items | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {`);\n        lines.push(`${indent}    [System.Management.Automation.CompletionResult]::new($_, $_, \"ParameterValue\", $_)`);\n        lines.push(`${indent}}`);\n        break;\n      case 'shell':\n        lines.push(`${indent}$shells = @(\"zsh\", \"bash\", \"fish\", \"powershell\")`);\n        lines.push(`${indent}$shells | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {`);\n        lines.push(`${indent}    [System.Management.Automation.CompletionResult]::new($_, $_, \"ParameterValue\", \"Shell: $_\")`);\n        lines.push(`${indent}}`);\n        break;\n      case 'path':\n        // PowerShell handles file path completion automatically\n        break;\n    }\n\n    return lines;\n  }\n\n  /**\n   * Escape description text for PowerShell\n   */\n  private escapeDescription(description: string): string {\n    return description\n      .replace(/`/g, '``')     // Backticks (escape sequences)\n      .replace(/\\$/g, '`$')    // Dollar signs (prevents $())\n      .replace(/\"/g, '\"\"');    // Double quotes\n  }\n}\n"
  },
  {
    "path": "src/core/completions/generators/zsh-generator.ts",
    "content": "import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';\nimport { ZSH_DYNAMIC_HELPERS } from '../templates/zsh-templates.js';\n\n/**\n * Generates Zsh completion scripts for the OpenSpec CLI.\n * Follows Zsh completion system conventions using the _openspec function.\n */\nexport class ZshGenerator implements CompletionGenerator {\n  readonly shell = 'zsh' as const;\n\n  /**\n   * Generate a Zsh completion script\n   *\n   * @param commands - Command definitions to generate completions for\n   * @returns Zsh completion script as a string\n   */\n  generate(commands: CommandDefinition[]): string {\n    // Build command list using push() for loop clarity\n    const commandLines: string[] = [];\n    for (const cmd of commands) {\n      const escapedDesc = this.escapeDescription(cmd.description);\n      commandLines.push(`    '${cmd.name}:${escapedDesc}'`);\n    }\n    const commandList = commandLines.join('\\n');\n\n    // Build command cases using push() for loop clarity\n    const commandCaseLines: string[] = [];\n    for (const cmd of commands) {\n      commandCaseLines.push(`        ${cmd.name})`);\n      commandCaseLines.push(`          _openspec_${this.sanitizeFunctionName(cmd.name)}`);\n      commandCaseLines.push('          ;;');\n    }\n    const commandCases = commandCaseLines.join('\\n');\n\n    // Build command functions using push() for loop clarity\n    const commandFunctionLines: string[] = [];\n    for (const cmd of commands) {\n      commandFunctionLines.push(...this.generateCommandFunction(cmd));\n      commandFunctionLines.push('');\n    }\n    const commandFunctions = commandFunctionLines.join('\\n');\n\n    // Dynamic completion helpers from template\n    const helpers = ZSH_DYNAMIC_HELPERS;\n\n    // Assemble final script with template literal\n    return `#compdef openspec\n\n# Zsh completion script for OpenSpec CLI\n# Auto-generated - do not edit manually\n\n_openspec() {\n  local context state line\n  typeset -A opt_args\n\n  local -a commands\n  commands=(\n${commandList}\n  )\n\n  _arguments -C \\\\\n    \"1: :->command\" \\\\\n    \"*::arg:->args\"\n\n  case $state in\n    command)\n      _describe \"openspec command\" commands\n      ;;\n    args)\n      case $words[1] in\n${commandCases}\n      esac\n      ;;\n  esac\n}\n\n${commandFunctions}\n${helpers}\ncompdef _openspec openspec\n`;\n  }\n\n  /**\n   * Generate completion function for a specific command\n   */\n  private generateCommandFunction(cmd: CommandDefinition): string[] {\n    const funcName = `_openspec_${this.sanitizeFunctionName(cmd.name)}`;\n    const lines: string[] = [];\n\n    lines.push(`${funcName}() {`);\n\n    // If command has subcommands, handle them\n    if (cmd.subcommands && cmd.subcommands.length > 0) {\n      lines.push('  local context state line');\n      lines.push('  typeset -A opt_args');\n      lines.push('');\n      lines.push('  local -a subcommands');\n      lines.push('  subcommands=(');\n\n      for (const subcmd of cmd.subcommands) {\n        const escapedDesc = this.escapeDescription(subcmd.description);\n        lines.push(`    '${subcmd.name}:${escapedDesc}'`);\n      }\n\n      lines.push('  )');\n      lines.push('');\n      lines.push('  _arguments -C \\\\');\n\n      // Add command flags\n      for (const flag of cmd.flags) {\n        lines.push('    ' + this.generateFlagSpec(flag) + ' \\\\');\n      }\n\n      lines.push('    \"1: :->subcommand\" \\\\');\n      lines.push('    \"*::arg:->args\"');\n      lines.push('');\n      lines.push('  case $state in');\n      lines.push('    subcommand)');\n      lines.push('      _describe \"subcommand\" subcommands');\n      lines.push('      ;;');\n      lines.push('    args)');\n      lines.push('      case $words[1] in');\n\n      for (const subcmd of cmd.subcommands) {\n        lines.push(`        ${subcmd.name})`);\n        lines.push(`          _openspec_${this.sanitizeFunctionName(cmd.name)}_${this.sanitizeFunctionName(subcmd.name)}`);\n        lines.push('          ;;');\n      }\n\n      lines.push('      esac');\n      lines.push('      ;;');\n      lines.push('  esac');\n    } else {\n      // Command without subcommands\n      lines.push('  _arguments \\\\');\n\n      // Add flags\n      for (const flag of cmd.flags) {\n        lines.push('    ' + this.generateFlagSpec(flag) + ' \\\\');\n      }\n\n      // Add positional argument completion\n      if (cmd.acceptsPositional) {\n        const positionalSpec = this.generatePositionalSpec(cmd.positionalType);\n        lines.push('    ' + positionalSpec);\n      } else {\n        // Remove trailing backslash from last flag\n        if (lines[lines.length - 1].endsWith(' \\\\')) {\n          lines[lines.length - 1] = lines[lines.length - 1].slice(0, -2);\n        }\n      }\n    }\n\n    lines.push('}');\n\n    // Generate subcommand functions if they exist\n    if (cmd.subcommands) {\n      for (const subcmd of cmd.subcommands) {\n        lines.push('');\n        lines.push(...this.generateSubcommandFunction(cmd.name, subcmd));\n      }\n    }\n\n    return lines;\n  }\n\n  /**\n   * Generate completion function for a subcommand\n   */\n  private generateSubcommandFunction(parentName: string, subcmd: CommandDefinition): string[] {\n    const funcName = `_openspec_${this.sanitizeFunctionName(parentName)}_${this.sanitizeFunctionName(subcmd.name)}`;\n    const lines: string[] = [];\n\n    lines.push(`${funcName}() {`);\n    lines.push('  _arguments \\\\');\n\n    // Add flags\n    for (const flag of subcmd.flags) {\n      lines.push('    ' + this.generateFlagSpec(flag) + ' \\\\');\n    }\n\n    // Add positional argument completion\n    if (subcmd.acceptsPositional) {\n      const positionalSpec = this.generatePositionalSpec(subcmd.positionalType);\n      lines.push('    ' + positionalSpec);\n    } else {\n      // Remove trailing backslash from last flag\n      if (lines[lines.length - 1].endsWith(' \\\\')) {\n        lines[lines.length - 1] = lines[lines.length - 1].slice(0, -2);\n      }\n    }\n\n    lines.push('}');\n\n    return lines;\n  }\n\n  /**\n   * Generate flag specification for _arguments\n   */\n  private generateFlagSpec(flag: FlagDefinition): string {\n    const parts: string[] = [];\n\n    // Handle mutually exclusive short and long forms\n    if (flag.short) {\n      parts.push(`'(-${flag.short} --${flag.name})'{-${flag.short},--${flag.name}}'`);\n    } else {\n      parts.push(`'--${flag.name}`);\n    }\n\n    // Add description\n    const escapedDesc = this.escapeDescription(flag.description);\n    parts.push(`[${escapedDesc}]`);\n\n    // Add value completion if flag takes a value\n    if (flag.takesValue) {\n      if (flag.values && flag.values.length > 0) {\n        // Provide specific value completions\n        const valueList = flag.values.map(v => this.escapeValue(v)).join(' ');\n        parts.push(`:value:(${valueList})`);\n      } else {\n        // Generic value placeholder\n        parts.push(':value:');\n      }\n    }\n\n    // Close the quote (needed for both short and long forms)\n    parts.push(\"'\");\n\n    return parts.join('');\n  }\n\n  /**\n   * Generate positional argument specification\n   */\n  private generatePositionalSpec(positionalType?: string): string {\n    switch (positionalType) {\n      case 'change-id':\n        return \"'*: :_openspec_complete_changes'\";\n      case 'spec-id':\n        return \"'*: :_openspec_complete_specs'\";\n      case 'change-or-spec-id':\n        return \"'*: :_openspec_complete_items'\";\n      case 'path':\n        return \"'*:path:_files'\";\n      case 'shell':\n        return \"'*:shell:(zsh bash fish powershell)'\";\n      default:\n        return \"'*: :_default'\";\n    }\n  }\n\n  /**\n   * Escape special characters in descriptions\n   */\n  private escapeDescription(desc: string): string {\n    return desc\n      .replace(/\\\\/g, '\\\\\\\\')\n      .replace(/'/g, \"\\\\'\")\n      .replace(/\\[/g, '\\\\[')\n      .replace(/]/g, '\\\\]')\n      .replace(/:/g, '\\\\:');\n  }\n\n  /**\n   * Escape special characters in values\n   */\n  private escapeValue(value: string): string {\n    return value\n      .replace(/\\\\/g, '\\\\\\\\')\n      .replace(/'/g, \"\\\\'\")\n      .replace(/ /g, '\\\\ ');\n  }\n\n  /**\n   * Sanitize command names for use in function names\n   */\n  private sanitizeFunctionName(name: string): string {\n    return name.replace(/-/g, '_');\n  }\n}\n"
  },
  {
    "path": "src/core/completions/installers/bash-installer.ts",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { FileSystemUtils } from '../../../utils/file-system.js';\nimport { InstallationResult } from '../factory.js';\n\n/**\n * Installer for Bash completion scripts.\n * Supports bash-completion package and standalone installations.\n */\nexport class BashInstaller {\n  private readonly homeDir: string;\n\n  /**\n   * Markers for .bashrc configuration management\n   */\n  private readonly BASHRC_MARKERS = {\n    start: '# OPENSPEC:START',\n    end: '# OPENSPEC:END',\n  };\n\n  constructor(homeDir: string = os.homedir()) {\n    this.homeDir = homeDir;\n  }\n\n  /**\n   * Check if bash-completion is installed\n   *\n   * @returns true if bash-completion directories exist\n   */\n  async isBashCompletionInstalled(): Promise<boolean> {\n    const paths = [\n      '/usr/share/bash-completion',              // Linux system-wide\n      '/usr/local/share/bash-completion',        // Homebrew Intel (main)\n      '/opt/homebrew/etc/bash_completion.d',     // Homebrew Apple Silicon\n      '/usr/local/etc/bash_completion.d',        // Homebrew Intel (alt path)\n      '/etc/bash_completion.d',                   // Legacy fallback\n    ];\n\n    for (const p of paths) {\n      try {\n        const stat = await fs.stat(p);\n        if (stat.isDirectory()) {\n          return true;\n        }\n      } catch {\n        // Continue checking other paths\n      }\n    }\n\n    return false;\n  }\n\n  /**\n   * Get the appropriate installation path for the completion script\n   *\n   * @returns Installation path\n   */\n  async getInstallationPath(): Promise<string> {\n    // Try user-local bash-completion directory first\n    const localCompletionDir = path.join(this.homeDir, '.local', 'share', 'bash-completion', 'completions');\n\n    // For user installation, use local directory\n    return path.join(localCompletionDir, 'openspec');\n  }\n\n  /**\n   * Backup an existing completion file if it exists\n   *\n   * @param targetPath - Path to the file to backup\n   * @returns Path to the backup file, or undefined if no backup was needed\n   */\n  async backupExistingFile(targetPath: string): Promise<string | undefined> {\n    try {\n      await fs.access(targetPath);\n      // File exists, create a backup\n      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n      const backupPath = `${targetPath}.backup-${timestamp}`;\n      await fs.copyFile(targetPath, backupPath);\n      return backupPath;\n    } catch {\n      // File doesn't exist, no backup needed\n      return undefined;\n    }\n  }\n\n  /**\n   * Get the path to .bashrc file\n   *\n   * @returns Path to .bashrc\n   */\n  private getBashrcPath(): string {\n    return path.join(this.homeDir, '.bashrc');\n  }\n\n  /**\n   * Generate .bashrc configuration content\n   *\n   * @param completionsDir - Directory containing completion scripts\n   * @returns Configuration content\n   */\n  private generateBashrcConfig(completionsDir: string): string {\n    return [\n      '# OpenSpec shell completions configuration',\n      `if [ -d \"${completionsDir}\" ]; then`,\n      `  for f in \"${completionsDir}\"/*; do`,\n      '    [ -f \"$f\" ] && . \"$f\"',\n      '  done',\n      'fi',\n    ].join('\\n');\n  }\n\n  /**\n   * Configure .bashrc to enable completions\n   *\n   * @param completionsDir - Directory containing completion scripts\n   * @returns true if configured successfully, false otherwise\n   */\n  async configureBashrc(completionsDir: string): Promise<boolean> {\n    // Check if auto-configuration is disabled\n    if (process.env.OPENSPEC_NO_AUTO_CONFIG === '1') {\n      return false;\n    }\n\n    try {\n      const bashrcPath = this.getBashrcPath();\n      const config = this.generateBashrcConfig(completionsDir);\n\n      // Check write permissions\n      const canWrite = await FileSystemUtils.canWriteFile(bashrcPath);\n      if (!canWrite) {\n        return false;\n      }\n\n      // Use marker-based update\n      await FileSystemUtils.updateFileWithMarkers(\n        bashrcPath,\n        config,\n        this.BASHRC_MARKERS.start,\n        this.BASHRC_MARKERS.end\n      );\n\n      return true;\n    } catch (error: any) {\n      // Fail gracefully - don't break installation\n      console.debug(`Unable to configure .bashrc for completions: ${error.message}`);\n      return false;\n    }\n  }\n\n  /**\n   * Remove .bashrc configuration\n   * Used during uninstallation\n   *\n   * @returns true if removed successfully, false otherwise\n   */\n  async removeBashrcConfig(): Promise<boolean> {\n    try {\n      const bashrcPath = this.getBashrcPath();\n\n      // Check if file exists\n      try {\n        await fs.access(bashrcPath);\n      } catch {\n        // File doesn't exist, nothing to remove\n        return true;\n      }\n\n      // Read file content\n      const content = await fs.readFile(bashrcPath, 'utf-8');\n\n      // Check if markers exist\n      if (!content.includes(this.BASHRC_MARKERS.start) || !content.includes(this.BASHRC_MARKERS.end)) {\n        // Markers don't exist, nothing to remove\n        return true;\n      }\n\n      // Remove content between markers (including markers)\n      const lines = content.split('\\n');\n      const startIndex = lines.findIndex((line) => line.trim() === this.BASHRC_MARKERS.start);\n      const endIndex = lines.findIndex((line) => line.trim() === this.BASHRC_MARKERS.end);\n\n      if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {\n        // Invalid marker placement\n        return false;\n      }\n\n      // Remove lines between markers (inclusive)\n      lines.splice(startIndex, endIndex - startIndex + 1);\n\n      // Remove trailing empty lines\n      while (lines.length > 0 && lines[lines.length - 1].trim() === '') {\n        lines.pop();\n      }\n\n      // Write back\n      await fs.writeFile(bashrcPath, lines.join('\\n'), 'utf-8');\n\n      return true;\n    } catch (error: any) {\n      // Fail gracefully\n      console.debug(`Unable to remove .bashrc configuration: ${error.message}`);\n      return false;\n    }\n  }\n\n  /**\n   * Install the completion script\n   *\n   * @param completionScript - The completion script content to install\n   * @returns Installation result with status and instructions\n   */\n  async install(completionScript: string): Promise<InstallationResult> {\n    try {\n      const targetPath = await this.getInstallationPath();\n\n      // Check for bash-completion package\n      const hasBashCompletion = await this.isBashCompletionInstalled();\n\n      // Check if already installed with same content\n      let isUpdate = false;\n      try {\n        const existingContent = await fs.readFile(targetPath, 'utf-8');\n        if (existingContent === completionScript) {\n          // Already installed and up to date\n          return {\n            success: true,\n            installedPath: targetPath,\n            message: 'Completion script is already installed (up to date)',\n            instructions: [\n              'The completion script is already installed and up to date.',\n              'If completions are not working, try: exec bash',\n            ],\n          };\n        }\n        // File exists but content is different - this is an update\n        isUpdate = true;\n      } catch (error: any) {\n        // File doesn't exist or can't be read, proceed with installation\n        console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);\n      }\n\n      // Ensure the directory exists\n      const targetDir = path.dirname(targetPath);\n      await fs.mkdir(targetDir, { recursive: true });\n\n      // Backup existing file if updating\n      const backupPath = isUpdate ? await this.backupExistingFile(targetPath) : undefined;\n\n      // Write the completion script\n      await fs.writeFile(targetPath, completionScript, 'utf-8');\n\n      // Auto-configure .bashrc\n      const bashrcConfigured = await this.configureBashrc(targetDir);\n\n      // Generate instructions if .bashrc wasn't auto-configured\n      const instructions = bashrcConfigured ? undefined : this.generateInstructions(targetPath);\n\n      // Collect warnings\n      const warnings: string[] = [];\n      if (!hasBashCompletion) {\n        warnings.push(\n          '⚠️  Warning: bash-completion package not detected',\n          '',\n          'The completion script requires bash-completion to function.',\n          'Install it with:',\n          '  brew install bash-completion@2',\n          '',\n          'Then add to your ~/.bash_profile:',\n          '  [[ -r \"/opt/homebrew/etc/profile.d/bash_completion.sh\" ]] && . \"/opt/homebrew/etc/profile.d/bash_completion.sh\"'\n        );\n      }\n\n      // Determine appropriate message\n      let message: string;\n      if (isUpdate) {\n        message = backupPath\n          ? 'Completion script updated successfully (previous version backed up)'\n          : 'Completion script updated successfully';\n      } else {\n        message = bashrcConfigured\n          ? 'Completion script installed and .bashrc configured successfully'\n          : 'Completion script installed successfully for Bash';\n      }\n\n      return {\n        success: true,\n        installedPath: targetPath,\n        backupPath,\n        bashrcConfigured,\n        message,\n        instructions,\n        warnings: warnings.length > 0 ? warnings : undefined,\n      };\n    } catch (error) {\n      return {\n        success: false,\n        message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,\n      };\n    }\n  }\n\n  /**\n   * Generate user instructions for enabling completions\n   *\n   * @param installedPath - Path where the script was installed\n   * @returns Array of instruction strings\n   */\n  private generateInstructions(installedPath: string): string[] {\n    const completionsDir = path.dirname(installedPath);\n\n    return [\n      'Completion script installed successfully.',\n      '',\n      'To enable completions, add the following to your ~/.bashrc file:',\n      '',\n      `  # Source OpenSpec completions`,\n      `  if [ -d \"${completionsDir}\" ]; then`,\n      `    for f in \"${completionsDir}\"/*; do`,\n      '      [ -f \"$f\" ] && . \"$f\"',\n      '    done',\n      '  fi',\n      '',\n      'Then restart your shell or run: exec bash',\n    ];\n  }\n\n  /**\n   * Uninstall the completion script\n   *\n   * @param options - Optional uninstall options\n   * @param options.yes - Skip confirmation prompt (handled by command layer)\n   * @returns Uninstallation result\n   */\n  async uninstall(options?: { yes?: boolean }): Promise<{ success: boolean; message: string }> {\n    try {\n      const targetPath = await this.getInstallationPath();\n\n      // Check if installed\n      try {\n        await fs.access(targetPath);\n      } catch {\n        return {\n          success: false,\n          message: 'Completion script is not installed',\n        };\n      }\n\n      // Remove the completion script\n      await fs.unlink(targetPath);\n\n      // Remove .bashrc configuration\n      await this.removeBashrcConfig();\n\n      return {\n        success: true,\n        message: 'Completion script uninstalled successfully',\n      };\n    } catch (error) {\n      return {\n        success: false,\n        message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "src/core/completions/installers/fish-installer.ts",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { InstallationResult } from '../factory.js';\n\n/**\n * Installer for Fish completion scripts.\n * Fish automatically loads completions from ~/.config/fish/completions/\n */\nexport class FishInstaller {\n  private readonly homeDir: string;\n\n  constructor(homeDir: string = os.homedir()) {\n    this.homeDir = homeDir;\n  }\n\n  /**\n   * Get the installation path for Fish completions\n   *\n   * @returns Installation path\n   */\n  getInstallationPath(): string {\n    return path.join(this.homeDir, '.config', 'fish', 'completions', 'openspec.fish');\n  }\n\n  /**\n   * Backup an existing completion file if it exists\n   *\n   * @param targetPath - Path to the file to backup\n   * @returns Path to the backup file, or undefined if no backup was needed\n   */\n  async backupExistingFile(targetPath: string): Promise<string | undefined> {\n    try {\n      await fs.access(targetPath);\n      // File exists, create a backup\n      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n      const backupPath = `${targetPath}.backup-${timestamp}`;\n      await fs.copyFile(targetPath, backupPath);\n      return backupPath;\n    } catch {\n      // File doesn't exist, no backup needed\n      return undefined;\n    }\n  }\n\n  /**\n   * Install the completion script\n   *\n   * @param completionScript - The completion script content to install\n   * @returns Installation result with status and instructions\n   */\n  async install(completionScript: string): Promise<InstallationResult> {\n    try {\n      const targetPath = this.getInstallationPath();\n\n      // Check if already installed with same content\n      let isUpdate = false;\n      try {\n        const existingContent = await fs.readFile(targetPath, 'utf-8');\n        if (existingContent === completionScript) {\n          // Already installed and up to date\n          return {\n            success: true,\n            installedPath: targetPath,\n            message: 'Completion script is already installed (up to date)',\n            instructions: [\n              'The completion script is already installed and up to date.',\n              'Fish automatically loads completions - they should be available immediately.',\n            ],\n          };\n        }\n        // File exists but content is different - this is an update\n        isUpdate = true;\n      } catch (error: any) {\n        // File doesn't exist or can't be read, proceed with installation\n        console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);\n      }\n\n      // Ensure the directory exists\n      const targetDir = path.dirname(targetPath);\n      await fs.mkdir(targetDir, { recursive: true });\n\n      // Backup existing file if updating\n      const backupPath = isUpdate ? await this.backupExistingFile(targetPath) : undefined;\n\n      // Write the completion script\n      await fs.writeFile(targetPath, completionScript, 'utf-8');\n\n      // Determine appropriate message\n      let message: string;\n      if (isUpdate) {\n        message = backupPath\n          ? 'Completion script updated successfully (previous version backed up)'\n          : 'Completion script updated successfully';\n      } else {\n        message = 'Completion script installed successfully for Fish';\n      }\n\n      return {\n        success: true,\n        installedPath: targetPath,\n        backupPath,\n        message,\n        instructions: [\n          'Fish automatically loads completions from ~/.config/fish/completions/',\n          'Completions are available immediately - no shell restart needed.',\n        ],\n      };\n    } catch (error) {\n      return {\n        success: false,\n        message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,\n      };\n    }\n  }\n\n  /**\n   * Uninstall the completion script\n   *\n   * @param options - Optional uninstall options\n   * @param options.yes - Skip confirmation prompt (handled by command layer)\n   * @returns Uninstallation result\n   */\n  async uninstall(options?: { yes?: boolean }): Promise<{ success: boolean; message: string }> {\n    try {\n      const targetPath = this.getInstallationPath();\n\n      // Check if installed\n      try {\n        await fs.access(targetPath);\n      } catch {\n        return {\n          success: false,\n          message: 'Completion script is not installed',\n        };\n      }\n\n      // Remove the completion script\n      await fs.unlink(targetPath);\n\n      return {\n        success: true,\n        message: 'Completion script uninstalled successfully',\n      };\n    } catch (error) {\n      return {\n        success: false,\n        message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "src/core/completions/installers/powershell-installer.ts",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { FileSystemUtils } from '../../../utils/file-system.js';\nimport { InstallationResult } from '../factory.js';\n\n/**\n * Installer for PowerShell completion scripts.\n * Works with both Windows PowerShell 5.1 and PowerShell Core 7+\n */\nexport class PowerShellInstaller {\n  private readonly homeDir: string;\n\n  /**\n   * Markers for PowerShell profile configuration management\n   */\n  private readonly PROFILE_MARKERS = {\n    start: '# OPENSPEC:START',\n    end: '# OPENSPEC:END',\n  };\n\n  constructor(homeDir: string = os.homedir()) {\n    this.homeDir = homeDir;\n  }\n\n  /**\n   * Get PowerShell profile path\n   * Prefers $PROFILE environment variable, falls back to platform defaults\n   *\n   * @returns Profile path\n   */\n  getProfilePath(): string {\n    // Check $PROFILE environment variable (set when running in PowerShell)\n    if (process.env.PROFILE) {\n      return process.env.PROFILE;\n    }\n\n    // Fall back to platform-specific defaults\n    if (process.platform === 'win32') {\n      // Windows: Documents/PowerShell/Microsoft.PowerShell_profile.ps1\n      return path.join(this.homeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');\n    } else {\n      // macOS/Linux: .config/powershell/Microsoft.PowerShell_profile.ps1\n      return path.join(this.homeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1');\n    }\n  }\n\n  /**\n   * Get all PowerShell profile paths to configure.\n   * On Windows, returns both PowerShell Core and Windows PowerShell 5.1 paths.\n   * On Unix, returns PowerShell Core path only.\n   */\n  private getAllProfilePaths(): string[] {\n    // If PROFILE env var is set, use only that path\n    if (process.env.PROFILE) {\n      return [process.env.PROFILE];\n    }\n\n    if (process.platform === 'win32') {\n      return [\n        // PowerShell Core 6+ (cross-platform)\n        path.join(this.homeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),\n        // Windows PowerShell 5.1 (Windows-only)\n        path.join(this.homeDir, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1'),\n      ];\n    } else {\n      // Unix systems: PowerShell Core only\n      return [path.join(this.homeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1')];\n    }\n  }\n\n  /**\n   * Get the installation path for the completion script\n   *\n   * @returns Installation path\n   */\n  getInstallationPath(): string {\n    const profilePath = this.getProfilePath();\n    const profileDir = path.dirname(profilePath);\n    return path.join(profileDir, 'OpenSpecCompletion.ps1');\n  }\n\n  /**\n   * Backup an existing completion file if it exists\n   *\n   * @param targetPath - Path to the file to backup\n   * @returns Path to the backup file, or undefined if no backup was needed\n   */\n  async backupExistingFile(targetPath: string): Promise<string | undefined> {\n    try {\n      await fs.access(targetPath);\n      // File exists, create a backup\n      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n      const backupPath = `${targetPath}.backup-${timestamp}`;\n      await fs.copyFile(targetPath, backupPath);\n      return backupPath;\n    } catch {\n      // File doesn't exist, no backup needed\n      return undefined;\n    }\n  }\n\n  /**\n   * Generate PowerShell profile configuration content\n   *\n   * @param scriptPath - Path to the completion script\n   * @returns Configuration content\n   */\n  private generateProfileConfig(scriptPath: string): string {\n    return [\n      '# OpenSpec shell completions configuration',\n      `if (Test-Path \"${scriptPath}\") {`,\n      `    . \"${scriptPath}\"`,\n      '}',\n    ].join('\\n');\n  }\n\n  /**\n   * Configure PowerShell profile to source the completion script\n   *\n   * @param scriptPath - Path to the completion script\n   * @returns true if configured successfully, false otherwise\n   */\n  async configureProfile(scriptPath: string): Promise<boolean> {\n    const profilePaths = this.getAllProfilePaths();\n    let anyConfigured = false;\n\n    for (const profilePath of profilePaths) {\n      try {\n        // Create profile file if it doesn't exist\n        const profileDir = path.dirname(profilePath);\n        await fs.mkdir(profileDir, { recursive: true });\n\n        let profileContent = '';\n        try {\n          profileContent = await fs.readFile(profilePath, 'utf-8');\n        } catch {\n          // Profile doesn't exist yet, that's fine\n        }\n\n        // Check if already configured\n        const scriptLine = `. \"${scriptPath}\"`;\n        if (profileContent.includes(scriptLine)) {\n          continue; // Already configured, skip\n        }\n\n        // Add OpenSpec completion configuration with markers\n        const openspecBlock = [\n          '',\n          '# OPENSPEC:START - OpenSpec completion (managed block, do not edit manually)',\n          scriptLine,\n          '# OPENSPEC:END',\n          '',\n        ].join('\\n');\n\n        const newContent = profileContent + openspecBlock;\n        await fs.writeFile(profilePath, newContent, 'utf-8');\n        anyConfigured = true;\n      } catch (error) {\n        // Continue to next profile if this one fails\n        console.warn(`Warning: Could not configure ${profilePath}: ${error}`);\n      }\n    }\n\n    return anyConfigured;\n  }\n\n  /**\n   * Remove PowerShell profile configuration\n   * Used during uninstallation\n   *\n   * @returns true if removed successfully, false otherwise\n   */\n  async removeProfileConfig(): Promise<boolean> {\n    const profilePaths = this.getAllProfilePaths();\n    let anyRemoved = false;\n\n    for (const profilePath of profilePaths) {\n      try {\n        // Read profile content\n        let profileContent: string;\n        try {\n          profileContent = await fs.readFile(profilePath, 'utf-8');\n        } catch {\n          continue; // Profile doesn't exist, nothing to remove\n        }\n\n        // Remove OPENSPEC:START -> OPENSPEC:END block\n        const startMarker = '# OPENSPEC:START';\n        const endMarker = '# OPENSPEC:END';\n        const startIndex = profileContent.indexOf(startMarker);\n\n        if (startIndex === -1) {\n          continue; // No OpenSpec block found\n        }\n\n        const endIndex = profileContent.indexOf(endMarker, startIndex);\n        if (endIndex === -1) {\n          console.warn(`Warning: Found start marker but no end marker in ${profilePath}`);\n          continue;\n        }\n\n        // Remove the block (including markers and surrounding newlines)\n        const beforeBlock = profileContent.substring(0, startIndex);\n        const afterBlock = profileContent.substring(endIndex + endMarker.length);\n\n        // Clean up extra newlines\n        const newContent = (beforeBlock.trimEnd() + '\\n' + afterBlock.trimStart()).trim() + '\\n';\n\n        await fs.writeFile(profilePath, newContent, 'utf-8');\n        anyRemoved = true;\n      } catch (error) {\n        console.warn(`Warning: Could not clean ${profilePath}: ${error}`);\n      }\n    }\n\n    return anyRemoved;\n  }\n\n  /**\n   * Install the completion script\n   *\n   * @param completionScript - The completion script content to install\n   * @returns Installation result with status and instructions\n   */\n  async install(completionScript: string): Promise<InstallationResult> {\n    try {\n      const targetPath = this.getInstallationPath();\n\n      // Check if already installed with same content\n      let isUpdate = false;\n      try {\n        const existingContent = await fs.readFile(targetPath, 'utf-8');\n        if (existingContent === completionScript) {\n          // Already installed and up to date\n          return {\n            success: true,\n            installedPath: targetPath,\n            message: 'Completion script is already installed (up to date)',\n            instructions: [\n              'The completion script is already installed and up to date.',\n              'If completions are not working, try restarting PowerShell or run: . $PROFILE',\n            ],\n          };\n        }\n        // File exists but content is different - this is an update\n        isUpdate = true;\n      } catch (error: any) {\n        // File doesn't exist or can't be read, proceed with installation\n        console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);\n      }\n\n      // Ensure the directory exists\n      const targetDir = path.dirname(targetPath);\n      await fs.mkdir(targetDir, { recursive: true });\n\n      // Backup existing file if updating\n      const backupPath = isUpdate ? await this.backupExistingFile(targetPath) : undefined;\n\n      // Write the completion script\n      await fs.writeFile(targetPath, completionScript, 'utf-8');\n\n      // Auto-configure PowerShell profile\n      const profileConfigured = await this.configureProfile(targetPath);\n\n      // Generate instructions if profile wasn't auto-configured\n      const instructions = profileConfigured ? undefined : this.generateInstructions(targetPath);\n\n      // Determine appropriate message\n      let message: string;\n      if (isUpdate) {\n        message = backupPath\n          ? 'Completion script updated successfully (previous version backed up)'\n          : 'Completion script updated successfully';\n      } else {\n        message = profileConfigured\n          ? 'Completion script installed and PowerShell profile configured successfully'\n          : 'Completion script installed successfully for PowerShell';\n      }\n\n      return {\n        success: true,\n        installedPath: targetPath,\n        backupPath,\n        profileConfigured,\n        message,\n        instructions,\n      };\n    } catch (error) {\n      return {\n        success: false,\n        message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,\n      };\n    }\n  }\n\n  /**\n   * Generate user instructions for enabling completions\n   *\n   * @param installedPath - Path where the script was installed\n   * @returns Array of instruction strings\n   */\n  private generateInstructions(installedPath: string): string[] {\n    const profilePath = this.getProfilePath();\n\n    return [\n      'Completion script installed successfully.',\n      '',\n      `To enable completions, add the following to your PowerShell profile (${profilePath}):`,\n      '',\n      '  # Source OpenSpec completions',\n      `  if (Test-Path \"${installedPath}\") {`,\n      `      . \"${installedPath}\"`,\n      '  }',\n      '',\n      'Then restart PowerShell or run: . $PROFILE',\n    ];\n  }\n\n  /**\n   * Uninstall the completion script\n   *\n   * @param options - Optional uninstall options\n   * @param options.yes - Skip confirmation prompt (handled by command layer)\n   * @returns Uninstallation result\n   */\n  async uninstall(options?: { yes?: boolean }): Promise<{ success: boolean; message: string }> {\n    try {\n      const targetPath = this.getInstallationPath();\n\n      // Check if installed\n      try {\n        await fs.access(targetPath);\n      } catch {\n        return {\n          success: false,\n          message: 'Completion script is not installed',\n        };\n      }\n\n      // Remove the completion script\n      await fs.unlink(targetPath);\n\n      // Remove profile configuration\n      await this.removeProfileConfig();\n\n      return {\n        success: true,\n        message: 'Completion script uninstalled successfully',\n      };\n    } catch (error) {\n      return {\n        success: false,\n        message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "src/core/completions/installers/zsh-installer.ts",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { FileSystemUtils } from '../../../utils/file-system.js';\nimport { InstallationResult } from '../factory.js';\n\n/**\n * Installer for Zsh completion scripts.\n * Supports both Oh My Zsh and standard Zsh configurations.\n */\nexport class ZshInstaller {\n  private readonly homeDir: string;\n\n  /**\n   * Markers for .zshrc configuration management\n   */\n  private readonly ZSHRC_MARKERS = {\n    start: '# OPENSPEC:START',\n    end: '# OPENSPEC:END',\n  };\n\n  constructor(homeDir: string = os.homedir()) {\n    this.homeDir = homeDir;\n  }\n\n  /**\n   * Check if Oh My Zsh is installed\n   *\n   * @returns true if Oh My Zsh is detected via $ZSH env var or directory exists\n   */\n  async isOhMyZshInstalled(): Promise<boolean> {\n    // First check for $ZSH environment variable (standard OMZ setup)\n    if (process.env.ZSH) {\n      return true;\n    }\n\n    // Fall back to checking for ~/.oh-my-zsh directory\n    const ohMyZshPath = path.join(this.homeDir, '.oh-my-zsh');\n\n    try {\n      const stat = await fs.stat(ohMyZshPath);\n      return stat.isDirectory();\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Get the appropriate installation path for the completion script\n   *\n   * @returns Object with installation path and whether it's Oh My Zsh\n   */\n  async getInstallationPath(): Promise<{ path: string; isOhMyZsh: boolean }> {\n    const isOhMyZsh = await this.isOhMyZshInstalled();\n\n    if (isOhMyZsh) {\n      // Oh My Zsh custom completions directory\n      return {\n        path: path.join(this.homeDir, '.oh-my-zsh', 'custom', 'completions', '_openspec'),\n        isOhMyZsh: true,\n      };\n    } else {\n      // Standard Zsh completions directory\n      return {\n        path: path.join(this.homeDir, '.zsh', 'completions', '_openspec'),\n        isOhMyZsh: false,\n      };\n    }\n  }\n\n  /**\n   * Backup an existing completion file if it exists\n   *\n   * @param targetPath - Path to the file to backup\n   * @returns Path to the backup file, or undefined if no backup was needed\n   */\n  async backupExistingFile(targetPath: string): Promise<string | undefined> {\n    try {\n      await fs.access(targetPath);\n      // File exists, create a backup\n      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n      const backupPath = `${targetPath}.backup-${timestamp}`;\n      await fs.copyFile(targetPath, backupPath);\n      return backupPath;\n    } catch {\n      // File doesn't exist, no backup needed\n      return undefined;\n    }\n  }\n\n  /**\n   * Get the path to .zshrc file\n   *\n   * @returns Path to .zshrc\n   */\n  private getZshrcPath(): string {\n    return path.join(this.homeDir, '.zshrc');\n  }\n\n  /**\n   * Generate .zshrc configuration content\n   *\n   * @param completionsDir - Directory containing completion scripts\n   * @returns Configuration content\n   */\n  private generateZshrcConfig(completionsDir: string): string {\n    return [\n      '# OpenSpec shell completions configuration',\n      `fpath=(\"${completionsDir}\" $fpath)`,\n      'autoload -Uz compinit',\n      'compinit',\n    ].join('\\n');\n  }\n\n  /**\n   * Configure .zshrc to enable completions\n   * Only applies to standard Zsh (not Oh My Zsh)\n   *\n   * @param completionsDir - Directory containing completion scripts\n   * @returns true if configured successfully, false otherwise\n   */\n  async configureZshrc(completionsDir: string): Promise<boolean> {\n    // Check if auto-configuration is disabled\n    if (process.env.OPENSPEC_NO_AUTO_CONFIG === '1') {\n      return false;\n    }\n\n    try {\n      const zshrcPath = this.getZshrcPath();\n      const config = this.generateZshrcConfig(completionsDir);\n\n      // Check write permissions\n      const canWrite = await FileSystemUtils.canWriteFile(zshrcPath);\n      if (!canWrite) {\n        return false;\n      }\n\n      // Use marker-based update\n      await FileSystemUtils.updateFileWithMarkers(\n        zshrcPath,\n        config,\n        this.ZSHRC_MARKERS.start,\n        this.ZSHRC_MARKERS.end\n      );\n\n      return true;\n    } catch (error: any) {\n      // Fail gracefully - don't break installation\n      console.debug(`Unable to configure .zshrc for completions: ${error.message}`);\n      return false;\n    }\n  }\n\n  /**\n   * Check if .zshrc has OpenSpec configuration markers\n   *\n   * @returns true if .zshrc exists and has markers\n   */\n  private async hasZshrcConfig(): Promise<boolean> {\n    try {\n      const zshrcPath = this.getZshrcPath();\n      const content = await fs.readFile(zshrcPath, 'utf-8');\n      return content.includes(this.ZSHRC_MARKERS.start) && content.includes(this.ZSHRC_MARKERS.end);\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Check if fpath configuration is needed for a given directory\n   * Used to verify if Oh My Zsh (or other) completions directory is already in fpath\n   *\n   * @param completionsDir - Directory to check for in fpath\n   * @returns true if configuration is needed, false if directory is already referenced\n   */\n  private async needsFpathConfig(completionsDir: string): Promise<boolean> {\n    try {\n      const zshrcPath = this.getZshrcPath();\n      const content = await fs.readFile(zshrcPath, 'utf-8');\n\n      // Check if fpath already includes this directory\n      return !content.includes(completionsDir);\n    } catch (error) {\n      // If we can't read .zshrc, assume config is needed\n      console.debug(`Unable to read .zshrc to check fpath config: ${error instanceof Error ? error.message : String(error)}`);\n      return true;\n    }\n  }\n\n  /**\n   * Remove .zshrc configuration\n   * Used during uninstallation\n   *\n   * @returns true if removed successfully, false otherwise\n   */\n  async removeZshrcConfig(): Promise<boolean> {\n    try {\n      const zshrcPath = this.getZshrcPath();\n\n      // Check if file exists\n      try {\n        await fs.access(zshrcPath);\n      } catch {\n        // File doesn't exist, nothing to remove\n        return true;\n      }\n\n      // Read file content\n      const content = await fs.readFile(zshrcPath, 'utf-8');\n\n      // Check if markers exist\n      if (!content.includes(this.ZSHRC_MARKERS.start) || !content.includes(this.ZSHRC_MARKERS.end)) {\n        // Markers don't exist, nothing to remove\n        return true;\n      }\n\n      // Remove content between markers (including markers)\n      const lines = content.split('\\n');\n      const startIndex = lines.findIndex((line) => line.trim() === this.ZSHRC_MARKERS.start);\n      const endIndex = lines.findIndex((line) => line.trim() === this.ZSHRC_MARKERS.end);\n\n      if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {\n        // Invalid marker placement\n        return false;\n      }\n\n      // Remove lines between markers (inclusive)\n      lines.splice(startIndex, endIndex - startIndex + 1);\n\n      // Remove trailing empty lines at the start if the markers were at the top\n      while (lines.length > 0 && lines[0].trim() === '') {\n        lines.shift();\n      }\n\n      // Write back\n      await fs.writeFile(zshrcPath, lines.join('\\n'), 'utf-8');\n\n      return true;\n    } catch (error: any) {\n      // Fail gracefully\n      console.debug(`Unable to remove .zshrc configuration: ${error.message}`);\n      return false;\n    }\n  }\n\n  /**\n   * Install the completion script\n   *\n   * @param completionScript - The completion script content to install\n   * @returns Installation result with status and instructions\n   */\n  async install(completionScript: string): Promise<InstallationResult> {\n    try {\n      const { path: targetPath, isOhMyZsh } = await this.getInstallationPath();\n\n      // Check if already installed with same content\n      let isUpdate = false;\n      try {\n        const existingContent = await fs.readFile(targetPath, 'utf-8');\n        if (existingContent === completionScript) {\n          // Already installed and up to date\n          return {\n            success: true,\n            installedPath: targetPath,\n            isOhMyZsh,\n            message: 'Completion script is already installed (up to date)',\n            instructions: [\n              'The completion script is already installed and up to date.',\n              'If completions are not working, try: exec zsh',\n            ],\n          };\n        }\n        // File exists but content is different - this is an update\n        isUpdate = true;\n      } catch (error: any) {\n        // File doesn't exist or can't be read, proceed with installation\n        console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);\n      }\n\n      // Ensure the directory exists\n      const targetDir = path.dirname(targetPath);\n      await fs.mkdir(targetDir, { recursive: true });\n\n      // Backup existing file if updating\n      const backupPath = isUpdate ? await this.backupExistingFile(targetPath) : undefined;\n\n      // Write the completion script\n      await fs.writeFile(targetPath, completionScript, 'utf-8');\n\n      // Auto-configure .zshrc\n      let zshrcConfigured = false;\n      if (isOhMyZsh) {\n        // For Oh My Zsh, verify that custom/completions is in fpath\n        // If not, add it to .zshrc\n        const needsConfig = await this.needsFpathConfig(targetDir);\n        if (needsConfig) {\n          zshrcConfigured = await this.configureZshrc(targetDir);\n        }\n      } else {\n        // Standard Zsh always needs .zshrc configuration\n        zshrcConfigured = await this.configureZshrc(targetDir);\n      }\n\n      // Generate instructions (only if .zshrc wasn't auto-configured)\n      let instructions = zshrcConfigured ? undefined : this.generateInstructions(isOhMyZsh, targetPath);\n\n      // Add fpath guidance for Oh My Zsh installations\n      if (isOhMyZsh) {\n        const fpathGuidance = this.generateOhMyZshFpathGuidance(targetDir);\n        if (fpathGuidance) {\n          instructions = instructions ? [...instructions, '', ...fpathGuidance] : fpathGuidance;\n        }\n      }\n\n      // Determine appropriate message based on update status\n      let message: string;\n      if (isUpdate) {\n        message = backupPath\n          ? 'Completion script updated successfully (previous version backed up)'\n          : 'Completion script updated successfully';\n      } else {\n        message = isOhMyZsh\n          ? 'Completion script installed successfully for Oh My Zsh'\n          : zshrcConfigured\n            ? 'Completion script installed and .zshrc configured successfully'\n            : 'Completion script installed successfully for Zsh';\n      }\n\n      return {\n        success: true,\n        installedPath: targetPath,\n        backupPath,\n        isOhMyZsh,\n        zshrcConfigured,\n        message,\n        instructions,\n      };\n    } catch (error) {\n      return {\n        success: false,\n        isOhMyZsh: false,\n        message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,\n      };\n    }\n  }\n\n  /**\n   * Generate Oh My Zsh fpath verification guidance\n   *\n   * @param completionsDir - Custom completions directory path\n   * @returns Array of guidance strings, or undefined if not needed\n   */\n  private generateOhMyZshFpathGuidance(completionsDir: string): string[] | undefined {\n    return [\n      'Note: Oh My Zsh typically auto-loads completions from custom/completions.',\n      `Verify that ${completionsDir} is in your fpath by running:`,\n      '  echo $fpath | grep \"custom/completions\"',\n      '',\n      'If not found, completions may not work. Restart your shell to ensure changes take effect.',\n    ];\n  }\n\n  /**\n   * Generate user instructions for enabling completions\n   *\n   * @param isOhMyZsh - Whether Oh My Zsh is being used\n   * @param installedPath - Path where the script was installed\n   * @returns Array of instruction strings\n   */\n  private generateInstructions(isOhMyZsh: boolean, installedPath: string): string[] {\n    if (isOhMyZsh) {\n      return [\n        'Completion script installed to Oh My Zsh completions directory.',\n        'Restart your shell or run: exec zsh',\n        'Completions should activate automatically.',\n      ];\n    } else {\n      const completionsDir = path.dirname(installedPath);\n      const zshrcPath = path.join(this.homeDir, '.zshrc');\n\n      return [\n        'Completion script installed to ~/.zsh/completions/',\n        '',\n        'To enable completions, add the following to your ~/.zshrc file:',\n        '',\n        `  # Add completions directory to fpath`,\n        `  fpath=(${completionsDir} $fpath)`,\n        '',\n        '  # Initialize completion system',\n        '  autoload -Uz compinit',\n        '  compinit',\n        '',\n        'Then restart your shell or run: exec zsh',\n        '',\n        `Check if these lines already exist in ${zshrcPath} before adding.`,\n      ];\n    }\n  }\n\n  /**\n   * Uninstall the completion script\n   *\n   * @returns true if uninstalled successfully, false otherwise\n   */\n  async uninstall(): Promise<{ success: boolean; message: string }> {\n    try {\n      const { path: targetPath, isOhMyZsh } = await this.getInstallationPath();\n\n      // Try to remove completion script\n      let scriptRemoved = false;\n      try {\n        await fs.access(targetPath);\n        await fs.unlink(targetPath);\n        scriptRemoved = true;\n      } catch {\n        // Script not installed\n      }\n\n      // Try to remove .zshrc configuration (only for standard Zsh)\n      let zshrcWasPresent = false;\n      let zshrcCleaned = false;\n      if (!isOhMyZsh) {\n        zshrcWasPresent = await this.hasZshrcConfig();\n        if (zshrcWasPresent) {\n          zshrcCleaned = await this.removeZshrcConfig();\n        }\n      }\n\n      if (!scriptRemoved && !zshrcWasPresent) {\n        return {\n          success: false,\n          message: 'Completion script is not installed',\n        };\n      }\n\n      const messages: string[] = [];\n      if (scriptRemoved) {\n        messages.push(`Completion script removed from ${targetPath}`);\n      }\n      if (zshrcCleaned && !isOhMyZsh) {\n        messages.push('Removed OpenSpec configuration from ~/.zshrc');\n      }\n\n      return {\n        success: true,\n        message: messages.join('. '),\n      };\n    } catch (error) {\n      return {\n        success: false,\n        message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,\n      };\n    }\n  }\n\n  /**\n   * Check if completion script is currently installed\n   *\n   * @returns true if the completion script exists\n   */\n  async isInstalled(): Promise<boolean> {\n    try {\n      const { path: targetPath } = await this.getInstallationPath();\n      await fs.access(targetPath);\n      return true;\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Get information about the current installation\n   *\n   * @returns Installation status information\n   */\n  async getInstallationInfo(): Promise<{\n    installed: boolean;\n    path?: string;\n    isOhMyZsh?: boolean;\n  }> {\n    const installed = await this.isInstalled();\n\n    if (!installed) {\n      return { installed: false };\n    }\n\n    const { path: targetPath, isOhMyZsh } = await this.getInstallationPath();\n\n    return {\n      installed: true,\n      path: targetPath,\n      isOhMyZsh,\n    };\n  }\n}\n"
  },
  {
    "path": "src/core/completions/templates/bash-templates.ts",
    "content": "/**\n * Static template strings for Bash completion scripts.\n * These are Bash-specific helper functions that never change.\n */\n\nexport const BASH_DYNAMIC_HELPERS = `# Dynamic completion helpers\n\n_openspec_complete_changes() {\n  local changes\n  changes=$(openspec __complete changes 2>/dev/null | cut -f1)\n  COMPREPLY=($(compgen -W \"$changes\" -- \"$cur\"))\n}\n\n_openspec_complete_specs() {\n  local specs\n  specs=$(openspec __complete specs 2>/dev/null | cut -f1)\n  COMPREPLY=($(compgen -W \"$specs\" -- \"$cur\"))\n}\n\n_openspec_complete_items() {\n  local items\n  items=$(openspec __complete changes 2>/dev/null | cut -f1; openspec __complete specs 2>/dev/null | cut -f1)\n  COMPREPLY=($(compgen -W \"$items\" -- \"$cur\"))\n}`;\n"
  },
  {
    "path": "src/core/completions/templates/fish-templates.ts",
    "content": "/**\n * Static template strings for Fish completion scripts.\n * These are Fish-specific helper functions that never change.\n */\n\nexport const FISH_STATIC_HELPERS = `# Helper function to check if a subcommand is present\nfunction __fish_openspec_using_subcommand\n    set -l cmd (commandline -opc)\n    set -e cmd[1]\n    for i in $argv\n        if contains -- $i $cmd\n            return 0\n        end\n    end\n    return 1\nend\n\nfunction __fish_openspec_no_subcommand\n    set -l cmd (commandline -opc)\n    test (count $cmd) -eq 1\nend`;\n\nexport const FISH_DYNAMIC_HELPERS = `# Dynamic completion helpers\n\nfunction __fish_openspec_changes\n    openspec __complete changes 2>/dev/null | while read -l id desc\n        printf '%s\\\\t%s\\\\n' \"$id\" \"$desc\"\n    end\nend\n\nfunction __fish_openspec_specs\n    openspec __complete specs 2>/dev/null | while read -l id desc\n        printf '%s\\\\t%s\\\\n' \"$id\" \"$desc\"\n    end\nend\n\nfunction __fish_openspec_items\n    __fish_openspec_changes\n    __fish_openspec_specs\nend`;\n"
  },
  {
    "path": "src/core/completions/templates/powershell-templates.ts",
    "content": "/**\n * Static template strings for PowerShell completion scripts.\n * These are PowerShell-specific helper functions that never change.\n */\n\nexport const POWERSHELL_DYNAMIC_HELPERS = `# Dynamic completion helpers\n\nfunction Get-OpenSpecChanges {\n    $output = openspec __complete changes 2>$null\n    if ($output) {\n        $output | ForEach-Object {\n            ($_ -split \"\\\\t\")[0]\n        }\n    }\n}\n\nfunction Get-OpenSpecSpecs {\n    $output = openspec __complete specs 2>$null\n    if ($output) {\n        $output | ForEach-Object {\n            ($_ -split \"\\\\t\")[0]\n        }\n    }\n}\n`;\n"
  },
  {
    "path": "src/core/completions/templates/zsh-templates.ts",
    "content": "/**\n * Static template strings for Zsh completion scripts.\n * These are Zsh-specific helper functions that never change.\n */\n\nexport const ZSH_DYNAMIC_HELPERS = `# Dynamic completion helpers\n\n# Use openspec __complete to get available changes\n_openspec_complete_changes() {\n  local -a changes\n  while IFS=$'\\\\t' read -r id desc; do\n    changes+=(\"$id:$desc\")\n  done < <(openspec __complete changes 2>/dev/null)\n  _describe \"change\" changes\n}\n\n# Use openspec __complete to get available specs\n_openspec_complete_specs() {\n  local -a specs\n  while IFS=$'\\\\t' read -r id desc; do\n    specs+=(\"$id:$desc\")\n  done < <(openspec __complete specs 2>/dev/null)\n  _describe \"spec\" specs\n}\n\n# Get both changes and specs\n_openspec_complete_items() {\n  local -a items\n  while IFS=$'\\\\t' read -r id desc; do\n    items+=(\"$id:$desc\")\n  done < <(openspec __complete changes 2>/dev/null)\n  while IFS=$'\\\\t' read -r id desc; do\n    items+=(\"$id:$desc\")\n  done < <(openspec __complete specs 2>/dev/null)\n  _describe \"item\" items\n}`;\n"
  },
  {
    "path": "src/core/completions/types.ts",
    "content": "import { SupportedShell } from '../../utils/shell-detection.js';\n\n/**\n * Definition of a command-line flag/option\n */\nexport interface FlagDefinition {\n  /**\n   * Flag name without dashes (e.g., \"json\", \"strict\", \"no-interactive\")\n   */\n  name: string;\n\n  /**\n   * Short flag name without dash (e.g., \"y\" for \"-y\")\n   */\n  short?: string;\n\n  /**\n   * Human-readable description of what the flag does\n   */\n  description: string;\n\n  /**\n   * Whether the flag takes an argument value\n   */\n  takesValue?: boolean;\n\n  /**\n   * Possible values for the flag (for completion suggestions)\n   */\n  values?: string[];\n}\n\n/**\n * Definition of a CLI command\n */\nexport interface CommandDefinition {\n  /**\n   * Command name (e.g., \"init\", \"validate\", \"show\")\n   */\n  name: string;\n\n  /**\n   * Human-readable description of the command\n   */\n  description: string;\n\n  /**\n   * Flags/options supported by this command\n   */\n  flags: FlagDefinition[];\n\n  /**\n   * Subcommands (e.g., \"change show\", \"spec validate\")\n   */\n  subcommands?: CommandDefinition[];\n\n  /**\n   * Whether this command accepts a positional argument (e.g., item name, path)\n   */\n  acceptsPositional?: boolean;\n\n  /**\n   * Type of positional argument for dynamic completion\n   * - 'change-id': Complete with active change IDs\n   * - 'spec-id': Complete with spec IDs\n   * - 'change-or-spec-id': Complete with both changes and specs\n   * - 'path': Complete with file paths\n   * - 'shell': Complete with supported shell names\n   * - 'schema-name': Complete with available schema names\n   * - undefined: No specific completion\n   */\n  positionalType?: 'change-id' | 'spec-id' | 'change-or-spec-id' | 'path' | 'shell' | 'schema-name';\n}\n\n/**\n * Interface for shell-specific completion script generators\n */\nexport interface CompletionGenerator {\n  /**\n   * The shell type this generator targets\n   */\n  readonly shell: SupportedShell;\n\n  /**\n   * Generate the completion script content\n   *\n   * @param commands - Command definitions to generate completions for\n   * @returns The shell-specific completion script as a string\n   */\n  generate(commands: CommandDefinition[]): string;\n}\n"
  },
  {
    "path": "src/core/config-prompts.ts",
    "content": "import type { ProjectConfig } from './project-config.js';\n\n/**\n * Serialize config to YAML string with helpful comments.\n *\n * @param config - Partial config object (schema required, context/rules optional)\n * @returns YAML string ready to write to file\n */\nexport function serializeConfig(config: Partial<ProjectConfig>): string {\n  const lines: string[] = [];\n\n  // Schema (required)\n  lines.push(`schema: ${config.schema}`);\n  lines.push('');\n\n  // Context section with comments\n  lines.push('# Project context (optional)');\n  lines.push('# This is shown to AI when creating artifacts.');\n  lines.push('# Add your tech stack, conventions, style guides, domain knowledge, etc.');\n  lines.push('# Example:');\n  lines.push('#   context: |');\n  lines.push('#     Tech stack: TypeScript, React, Node.js');\n  lines.push('#     We use conventional commits');\n  lines.push('#     Domain: e-commerce platform');\n  lines.push('');\n\n  // Rules section with comments\n  lines.push('# Per-artifact rules (optional)');\n  lines.push('# Add custom rules for specific artifacts.');\n  lines.push('# Example:');\n  lines.push('#   rules:');\n  lines.push('#     proposal:');\n  lines.push('#       - Keep proposals under 500 words');\n  lines.push('#       - Always include a \"Non-goals\" section');\n  lines.push('#     tasks:');\n  lines.push('#       - Break tasks into chunks of max 2 hours');\n\n  return lines.join('\\n') + '\\n';\n}\n"
  },
  {
    "path": "src/core/config-schema.ts",
    "content": "import { z } from 'zod';\n\n/**\n * Zod schema for global OpenSpec configuration.\n * Uses passthrough() to preserve unknown fields for forward compatibility.\n */\nexport const GlobalConfigSchema = z\n  .object({\n    featureFlags: z\n      .record(z.string(), z.boolean())\n      .optional()\n      .default({}),\n    profile: z\n      .enum(['core', 'custom'])\n      .optional()\n      .default('core'),\n    delivery: z\n      .enum(['both', 'skills', 'commands'])\n      .optional()\n      .default('both'),\n    workflows: z\n      .array(z.string())\n      .optional(),\n  })\n  .passthrough();\n\nexport type GlobalConfigType = z.infer<typeof GlobalConfigSchema>;\n\n/**\n * Default configuration values.\n */\nexport const DEFAULT_CONFIG: GlobalConfigType = {\n  featureFlags: {},\n  profile: 'core',\n  delivery: 'both',\n};\n\nconst KNOWN_TOP_LEVEL_KEYS = new Set([...Object.keys(DEFAULT_CONFIG), 'workflows']);\n\n/**\n * Validate a config key path for CLI set operations.\n * Unknown top-level keys are rejected unless explicitly allowed by the caller.\n */\nexport function validateConfigKeyPath(path: string): { valid: boolean; reason?: string } {\n  const rawKeys = path.split('.');\n\n  if (rawKeys.length === 0 || rawKeys.some((key) => key.trim() === '')) {\n    return { valid: false, reason: 'Key path must not be empty' };\n  }\n\n  const rootKey = rawKeys[0];\n  if (!KNOWN_TOP_LEVEL_KEYS.has(rootKey)) {\n    return { valid: false, reason: `Unknown top-level key \"${rootKey}\"` };\n  }\n\n  if (rootKey === 'featureFlags') {\n    if (rawKeys.length > 2) {\n      return { valid: false, reason: 'featureFlags values are booleans and do not support nested keys' };\n    }\n    return { valid: true };\n  }\n\n  if (rawKeys.length > 1) {\n    return { valid: false, reason: `\"${rootKey}\" does not support nested keys` };\n  }\n\n  return { valid: true };\n}\n\n/**\n * Get a nested value from an object using dot notation.\n *\n * @param obj - The object to access\n * @param path - Dot-separated path (e.g., \"featureFlags.someFlag\")\n * @returns The value at the path, or undefined if not found\n */\nexport function getNestedValue(obj: Record<string, unknown>, path: string): unknown {\n  const keys = path.split('.');\n  let current: unknown = obj;\n\n  for (const key of keys) {\n    if (current === null || current === undefined) {\n      return undefined;\n    }\n    if (typeof current !== 'object') {\n      return undefined;\n    }\n    current = (current as Record<string, unknown>)[key];\n  }\n\n  return current;\n}\n\n/**\n * Set a nested value in an object using dot notation.\n * Creates intermediate objects as needed.\n *\n * @param obj - The object to modify (mutated in place)\n * @param path - Dot-separated path (e.g., \"featureFlags.someFlag\")\n * @param value - The value to set\n */\nexport function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {\n  const keys = path.split('.');\n  let current: Record<string, unknown> = obj;\n\n  for (let i = 0; i < keys.length - 1; i++) {\n    const key = keys[i];\n    if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {\n      current[key] = {};\n    }\n    current = current[key] as Record<string, unknown>;\n  }\n\n  const lastKey = keys[keys.length - 1];\n  current[lastKey] = value;\n}\n\n/**\n * Delete a nested value from an object using dot notation.\n *\n * @param obj - The object to modify (mutated in place)\n * @param path - Dot-separated path (e.g., \"featureFlags.someFlag\")\n * @returns true if the key existed and was deleted, false otherwise\n */\nexport function deleteNestedValue(obj: Record<string, unknown>, path: string): boolean {\n  const keys = path.split('.');\n  let current: Record<string, unknown> = obj;\n\n  for (let i = 0; i < keys.length - 1; i++) {\n    const key = keys[i];\n    if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {\n      return false;\n    }\n    current = current[key] as Record<string, unknown>;\n  }\n\n  const lastKey = keys[keys.length - 1];\n  if (lastKey in current) {\n    delete current[lastKey];\n    return true;\n  }\n  return false;\n}\n\n/**\n * Coerce a string value to its appropriate type.\n * - \"true\" / \"false\" -> boolean\n * - Numeric strings -> number\n * - Everything else -> string\n *\n * @param value - The string value to coerce\n * @param forceString - If true, always return the value as a string\n * @returns The coerced value\n */\nexport function coerceValue(value: string, forceString: boolean = false): string | number | boolean {\n  if (forceString) {\n    return value;\n  }\n\n  // Boolean coercion\n  if (value === 'true') {\n    return true;\n  }\n  if (value === 'false') {\n    return false;\n  }\n\n  // Number coercion - must be a valid finite number\n  const num = Number(value);\n  if (!isNaN(num) && isFinite(num) && value.trim() !== '') {\n    return num;\n  }\n\n  return value;\n}\n\n/**\n * Format a value for YAML-like display.\n *\n * @param value - The value to format\n * @param indent - Current indentation level\n * @returns Formatted string\n */\nexport function formatValueYaml(value: unknown, indent: number = 0): string {\n  const indentStr = '  '.repeat(indent);\n\n  if (value === null || value === undefined) {\n    return 'null';\n  }\n\n  if (typeof value === 'boolean' || typeof value === 'number') {\n    return String(value);\n  }\n\n  if (typeof value === 'string') {\n    return value;\n  }\n\n  if (Array.isArray(value)) {\n    if (value.length === 0) {\n      return '[]';\n    }\n    return value.map((item) => `${indentStr}- ${formatValueYaml(item, indent + 1)}`).join('\\n');\n  }\n\n  if (typeof value === 'object') {\n    const entries = Object.entries(value as Record<string, unknown>);\n    if (entries.length === 0) {\n      return '{}';\n    }\n    return entries\n      .map(([key, val]) => {\n        const formattedVal = formatValueYaml(val, indent + 1);\n        if (typeof val === 'object' && val !== null && Object.keys(val).length > 0) {\n          return `${indentStr}${key}:\\n${formattedVal}`;\n        }\n        return `${indentStr}${key}: ${formattedVal}`;\n      })\n      .join('\\n');\n  }\n\n  return String(value);\n}\n\n/**\n * Validate a configuration object against the schema.\n *\n * @param config - The configuration to validate\n * @returns Validation result with success status and optional error message\n */\nexport function validateConfig(config: unknown): { success: boolean; error?: string } {\n  try {\n    GlobalConfigSchema.parse(config);\n    return { success: true };\n  } catch (error) {\n    if (error instanceof z.ZodError) {\n      const zodError = error as z.ZodError;\n      const messages = zodError.issues.map((e) => `${e.path.join('.')}: ${e.message}`);\n      return { success: false, error: messages.join('; ') };\n    }\n    return { success: false, error: 'Unknown validation error' };\n  }\n}\n"
  },
  {
    "path": "src/core/config.ts",
    "content": "export const OPENSPEC_DIR_NAME = 'openspec';\n\nexport const OPENSPEC_MARKERS = {\n  start: '<!-- OPENSPEC:START -->',\n  end: '<!-- OPENSPEC:END -->'\n};\n\nexport interface OpenSpecConfig {\n  aiTools: string[];\n}\n\nexport interface AIToolOption {\n  name: string;\n  value: string;\n  available: boolean;\n  successLabel?: string;\n  skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec\n}\n\nexport const AI_TOOLS: AIToolOption[] = [\n  { name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq' },\n  { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent' },\n  { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },\n  { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },\n  { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' },\n  { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' },\n  { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' },\n  { name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' },\n  { name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec' },\n  { name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' },\n  { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor' },\n  { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },\n  { name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },\n  { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github' },\n  { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },\n  { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },\n  { name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },\n  { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },\n  { name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },\n  { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },\n  { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },\n  { name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' },\n  { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' },\n  { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf' },\n  { name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }\n];\n"
  },
  {
    "path": "src/core/converters/json-converter.ts",
    "content": "import { readFileSync } from 'fs';\nimport path from 'path';\nimport { MarkdownParser } from '../parsers/markdown-parser.js';\nimport { ChangeParser } from '../parsers/change-parser.js';\nimport { Spec, Change } from '../schemas/index.js';\nimport { FileSystemUtils } from '../../utils/file-system.js';\n\nexport class JsonConverter {\n  convertSpecToJson(filePath: string): string {\n    const content = readFileSync(filePath, 'utf-8');\n    const parser = new MarkdownParser(content);\n    const specName = this.extractNameFromPath(filePath);\n    \n    const spec = parser.parseSpec(specName);\n    \n    const jsonSpec = {\n      ...spec,\n      metadata: {\n        ...spec.metadata,\n        sourcePath: filePath,\n      },\n    };\n    \n    return JSON.stringify(jsonSpec, null, 2);\n  }\n\n  async convertChangeToJson(filePath: string): Promise<string> {\n    const content = readFileSync(filePath, 'utf-8');\n    const changeName = this.extractNameFromPath(filePath);\n    const changeDir = path.dirname(filePath);\n    const parser = new ChangeParser(content, changeDir);\n    \n    const change = await parser.parseChangeWithDeltas(changeName);\n    \n    const jsonChange = {\n      ...change,\n      metadata: {\n        ...change.metadata,\n        sourcePath: filePath,\n      },\n    };\n    \n    return JSON.stringify(jsonChange, null, 2);\n  }\n\n  private extractNameFromPath(filePath: string): string {\n    const normalizedPath = FileSystemUtils.toPosixPath(filePath);\n    const parts = normalizedPath.split('/');\n    \n    for (let i = parts.length - 1; i >= 0; i--) {\n      if (parts[i] === 'specs' || parts[i] === 'changes') {\n        if (i < parts.length - 1) {\n          return parts[i + 1];\n        }\n      }\n    }\n    \n    const fileName = parts[parts.length - 1] ?? '';\n    const dotIndex = fileName.lastIndexOf('.');\n    return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;\n  }\n}\n"
  },
  {
    "path": "src/core/global-config.ts",
    "content": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\n\n// Constants\nexport const GLOBAL_CONFIG_DIR_NAME = 'openspec';\nexport const GLOBAL_CONFIG_FILE_NAME = 'config.json';\nexport const GLOBAL_DATA_DIR_NAME = 'openspec';\n\n// TypeScript types\nexport type Profile = 'core' | 'custom';\nexport type Delivery = 'both' | 'skills' | 'commands';\n\n// TypeScript interfaces\nexport interface GlobalConfig {\n  featureFlags?: Record<string, boolean>;\n  profile?: Profile;\n  delivery?: Delivery;\n  workflows?: string[];\n}\n\nconst DEFAULT_CONFIG: GlobalConfig = {\n  featureFlags: {},\n  profile: 'core',\n  delivery: 'both',\n};\n\n/**\n * Gets the global configuration directory path following XDG Base Directory Specification.\n *\n * - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set\n * - Unix/macOS fallback: ~/.config/openspec/\n * - Windows fallback: %APPDATA%/openspec/\n */\nexport function getGlobalConfigDir(): string {\n  // XDG_CONFIG_HOME takes precedence on all platforms when explicitly set\n  const xdgConfigHome = process.env.XDG_CONFIG_HOME;\n  if (xdgConfigHome) {\n    return path.join(xdgConfigHome, GLOBAL_CONFIG_DIR_NAME);\n  }\n\n  const platform = os.platform();\n\n  if (platform === 'win32') {\n    // Windows: use %APPDATA%\n    const appData = process.env.APPDATA;\n    if (appData) {\n      return path.join(appData, GLOBAL_CONFIG_DIR_NAME);\n    }\n    // Fallback for Windows if APPDATA is not set\n    return path.join(os.homedir(), 'AppData', 'Roaming', GLOBAL_CONFIG_DIR_NAME);\n  }\n\n  // Unix/macOS fallback: ~/.config\n  return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);\n}\n\n/**\n * Gets the global data directory path following XDG Base Directory Specification.\n * Used for user data like schema overrides.\n *\n * - All platforms: $XDG_DATA_HOME/openspec/ if XDG_DATA_HOME is set\n * - Unix/macOS fallback: ~/.local/share/openspec/\n * - Windows fallback: %LOCALAPPDATA%/openspec/\n */\nexport function getGlobalDataDir(): string {\n  // XDG_DATA_HOME takes precedence on all platforms when explicitly set\n  const xdgDataHome = process.env.XDG_DATA_HOME;\n  if (xdgDataHome) {\n    return path.join(xdgDataHome, GLOBAL_DATA_DIR_NAME);\n  }\n\n  const platform = os.platform();\n\n  if (platform === 'win32') {\n    // Windows: use %LOCALAPPDATA%\n    const localAppData = process.env.LOCALAPPDATA;\n    if (localAppData) {\n      return path.join(localAppData, GLOBAL_DATA_DIR_NAME);\n    }\n    // Fallback for Windows if LOCALAPPDATA is not set\n    return path.join(os.homedir(), 'AppData', 'Local', GLOBAL_DATA_DIR_NAME);\n  }\n\n  // Unix/macOS fallback: ~/.local/share\n  return path.join(os.homedir(), '.local', 'share', GLOBAL_DATA_DIR_NAME);\n}\n\n/**\n * Gets the path to the global config file.\n */\nexport function getGlobalConfigPath(): string {\n  return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE_NAME);\n}\n\n/**\n * Loads the global configuration from disk.\n * Returns default configuration if file doesn't exist or is invalid.\n * Merges loaded config with defaults to ensure new fields are available.\n */\nexport function getGlobalConfig(): GlobalConfig {\n  const configPath = getGlobalConfigPath();\n\n  try {\n    if (!fs.existsSync(configPath)) {\n      return { ...DEFAULT_CONFIG };\n    }\n\n    const content = fs.readFileSync(configPath, 'utf-8');\n    const parsed = JSON.parse(content);\n\n    // Merge with defaults (loaded values take precedence)\n    const merged: GlobalConfig = {\n      ...DEFAULT_CONFIG,\n      ...parsed,\n      // Deep merge featureFlags\n      featureFlags: {\n        ...DEFAULT_CONFIG.featureFlags,\n        ...(parsed.featureFlags || {})\n      }\n    };\n\n    // Schema evolution: apply defaults for new fields if not present in loaded config\n    if (parsed.profile === undefined) {\n      merged.profile = DEFAULT_CONFIG.profile;\n    }\n    if (parsed.delivery === undefined) {\n      merged.delivery = DEFAULT_CONFIG.delivery;\n    }\n\n    return merged;\n  } catch (error) {\n    // Log warning for parse errors, but not for missing files\n    if (error instanceof SyntaxError) {\n      console.error(`Warning: Invalid JSON in ${configPath}, using defaults`);\n    }\n    return { ...DEFAULT_CONFIG };\n  }\n}\n\n/**\n * Saves the global configuration to disk.\n * Creates the config directory if it doesn't exist.\n */\nexport function saveGlobalConfig(config: GlobalConfig): void {\n  const configDir = getGlobalConfigDir();\n  const configPath = getGlobalConfigPath();\n\n  // Create directory if it doesn't exist\n  if (!fs.existsSync(configDir)) {\n    fs.mkdirSync(configDir, { recursive: true });\n  }\n\n  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n', 'utf-8');\n}\n"
  },
  {
    "path": "src/core/index.ts",
    "content": "// Core OpenSpec logic will be implemented here\nexport {\n  GLOBAL_CONFIG_DIR_NAME,\n  GLOBAL_CONFIG_FILE_NAME,\n  GLOBAL_DATA_DIR_NAME,\n  type GlobalConfig,\n  getGlobalConfigDir,\n  getGlobalConfigPath,\n  getGlobalConfig,\n  saveGlobalConfig,\n  getGlobalDataDir\n} from './global-config.js';"
  },
  {
    "path": "src/core/init.ts",
    "content": "/**\n * Init Command\n *\n * Sets up OpenSpec with Agent Skills and /opsx:* slash commands.\n * This is the unified setup command that replaces both the old init and experimental commands.\n */\n\nimport path from 'path';\nimport chalk from 'chalk';\nimport ora from 'ora';\nimport * as fs from 'fs';\nimport { createRequire } from 'module';\nimport { FileSystemUtils } from '../utils/file-system.js';\nimport { transformToHyphenCommands } from '../utils/command-references.js';\nimport {\n  AI_TOOLS,\n  OPENSPEC_DIR_NAME,\n  AIToolOption,\n} from './config.js';\nimport { PALETTE } from './styles/palette.js';\nimport { isInteractive } from '../utils/interactive.js';\nimport { serializeConfig } from './config-prompts.js';\nimport {\n  generateCommands,\n  CommandAdapterRegistry,\n} from './command-generation/index.js';\nimport {\n  detectLegacyArtifacts,\n  cleanupLegacyArtifacts,\n  formatCleanupSummary,\n  formatDetectionSummary,\n  type LegacyDetectionResult,\n} from './legacy-cleanup.js';\nimport {\n  SKILL_NAMES,\n  getToolsWithSkillsDir,\n  getToolSkillStatus,\n  getToolStates,\n  getSkillTemplates,\n  getCommandContents,\n  generateSkillContent,\n  type ToolSkillStatus,\n} from './shared/index.js';\nimport { getGlobalConfig, type Delivery, type Profile } from './global-config.js';\nimport { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js';\nimport { getAvailableTools } from './available-tools.js';\nimport { migrateIfNeeded } from './migration.js';\n\nconst require = createRequire(import.meta.url);\nconst { version: OPENSPEC_VERSION } = require('../../package.json');\n\n// -----------------------------------------------------------------------------\n// Constants\n// -----------------------------------------------------------------------------\n\nconst DEFAULT_SCHEMA = 'spec-driven';\n\nconst PROGRESS_SPINNER = {\n  interval: 80,\n  frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],\n};\n\nconst WORKFLOW_TO_SKILL_DIR: Record<string, string> = {\n  'explore': 'openspec-explore',\n  'new': 'openspec-new-change',\n  'continue': 'openspec-continue-change',\n  'apply': 'openspec-apply-change',\n  'ff': 'openspec-ff-change',\n  'sync': 'openspec-sync-specs',\n  'archive': 'openspec-archive-change',\n  'bulk-archive': 'openspec-bulk-archive-change',\n  'verify': 'openspec-verify-change',\n  'onboard': 'openspec-onboard',\n  'propose': 'openspec-propose',\n};\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\ntype InitCommandOptions = {\n  tools?: string;\n  force?: boolean;\n  interactive?: boolean;\n  profile?: string;\n};\n\n// -----------------------------------------------------------------------------\n// Init Command Class\n// -----------------------------------------------------------------------------\n\nexport class InitCommand {\n  private readonly toolsArg?: string;\n  private readonly force: boolean;\n  private readonly interactiveOption?: boolean;\n  private readonly profileOverride?: string;\n\n  constructor(options: InitCommandOptions = {}) {\n    this.toolsArg = options.tools;\n    this.force = options.force ?? false;\n    this.interactiveOption = options.interactive;\n    this.profileOverride = options.profile;\n  }\n\n  async execute(targetPath: string): Promise<void> {\n    const projectPath = path.resolve(targetPath);\n    const openspecDir = OPENSPEC_DIR_NAME;\n    const openspecPath = path.join(projectPath, openspecDir);\n\n    // Validation happens silently in the background\n    const extendMode = await this.validate(projectPath, openspecPath);\n\n    // Check for legacy artifacts and handle cleanup\n    await this.handleLegacyCleanup(projectPath, extendMode);\n\n    // Detect available tools in the project (task 7.1)\n    const detectedTools = getAvailableTools(projectPath);\n\n    // Migration check: migrate existing projects to profile system (task 7.3)\n    if (extendMode) {\n      migrateIfNeeded(projectPath, detectedTools);\n    }\n\n    // Show animated welcome screen (interactive mode only)\n    const canPrompt = this.canPromptInteractively();\n    if (canPrompt) {\n      const { showWelcomeScreen } = await import('../ui/welcome-screen.js');\n      await showWelcomeScreen();\n    }\n\n    // Validate profile override early so invalid values fail before tool setup.\n    // The resolved value is consumed later when generation reads effective config.\n    this.resolveProfileOverride();\n\n    // Get tool states before processing\n    const toolStates = getToolStates(projectPath);\n\n    // Get tool selection (pass detected tools for pre-selection)\n    const selectedToolIds = await this.getSelectedTools(toolStates, extendMode, detectedTools, projectPath);\n\n    // Validate selected tools\n    const validatedTools = this.validateTools(selectedToolIds, toolStates);\n\n    // Create directory structure and config\n    await this.createDirectoryStructure(openspecPath, extendMode);\n\n    // Generate skills and commands for each tool\n    const results = await this.generateSkillsAndCommands(projectPath, validatedTools);\n\n    // Create config.yaml if needed\n    const configStatus = await this.createConfig(openspecPath, extendMode);\n\n    // Display success message\n    this.displaySuccessMessage(projectPath, validatedTools, results, configStatus);\n  }\n\n  // ═══════════════════════════════════════════════════════════\n  // VALIDATION & SETUP\n  // ═══════════════════════════════════════════════════════════\n\n  private async validate(\n    projectPath: string,\n    openspecPath: string\n  ): Promise<boolean> {\n    const extendMode = await FileSystemUtils.directoryExists(openspecPath);\n\n    // Check write permissions\n    if (!(await FileSystemUtils.ensureWritePermissions(projectPath))) {\n      throw new Error(`Insufficient permissions to write to ${projectPath}`);\n    }\n    return extendMode;\n  }\n\n  private canPromptInteractively(): boolean {\n    if (this.interactiveOption === false) return false;\n    if (this.toolsArg !== undefined) return false;\n    return isInteractive({ interactive: this.interactiveOption });\n  }\n\n  private resolveProfileOverride(): Profile | undefined {\n    if (this.profileOverride === undefined) {\n      return undefined;\n    }\n\n    if (this.profileOverride === 'core' || this.profileOverride === 'custom') {\n      return this.profileOverride;\n    }\n\n    throw new Error(`Invalid profile \"${this.profileOverride}\". Available profiles: core, custom`);\n  }\n\n  // ═══════════════════════════════════════════════════════════\n  // LEGACY CLEANUP\n  // ═══════════════════════════════════════════════════════════\n\n  private async handleLegacyCleanup(projectPath: string, extendMode: boolean): Promise<void> {\n    // Detect legacy artifacts\n    const detection = await detectLegacyArtifacts(projectPath);\n\n    if (!detection.hasLegacyArtifacts) {\n      return; // No legacy artifacts found\n    }\n\n    // Show what was detected\n    console.log();\n    console.log(formatDetectionSummary(detection));\n    console.log();\n\n    const canPrompt = this.canPromptInteractively();\n\n    if (this.force || !canPrompt) {\n      // --force flag or non-interactive mode: proceed with cleanup automatically.\n      // Legacy slash commands are 100% OpenSpec-managed, and config file cleanup\n      // only removes markers (never deletes files), so auto-cleanup is safe.\n      await this.performLegacyCleanup(projectPath, detection);\n      return;\n    }\n\n    // Interactive mode: prompt for confirmation\n    const { confirm } = await import('@inquirer/prompts');\n    const shouldCleanup = await confirm({\n      message: 'Upgrade and clean up legacy files?',\n      default: true,\n    });\n\n    if (!shouldCleanup) {\n      console.log(chalk.dim('Initialization cancelled.'));\n      console.log(chalk.dim('Run with --force to skip this prompt, or manually remove legacy files.'));\n      process.exit(0);\n    }\n\n    await this.performLegacyCleanup(projectPath, detection);\n  }\n\n  private async performLegacyCleanup(projectPath: string, detection: LegacyDetectionResult): Promise<void> {\n    const spinner = ora('Cleaning up legacy files...').start();\n\n    const result = await cleanupLegacyArtifacts(projectPath, detection);\n\n    spinner.succeed('Legacy files cleaned up');\n\n    const summary = formatCleanupSummary(result);\n    if (summary) {\n      console.log();\n      console.log(summary);\n    }\n\n    console.log();\n  }\n\n  // ═══════════════════════════════════════════════════════════\n  // TOOL SELECTION\n  // ═══════════════════════════════════════════════════════════\n\n  private async getSelectedTools(\n    toolStates: Map<string, ToolSkillStatus>,\n    extendMode: boolean,\n    detectedTools: AIToolOption[],\n    projectPath: string\n  ): Promise<string[]> {\n    // Check for --tools flag first\n    const nonInteractiveSelection = this.resolveToolsArg();\n    if (nonInteractiveSelection !== null) {\n      return nonInteractiveSelection;\n    }\n\n    const validTools = getToolsWithSkillsDir();\n    const detectedToolIds = new Set(detectedTools.map((t) => t.value));\n    const configuredToolIds = new Set(\n      [...toolStates.entries()]\n        .filter(([, status]) => status.configured)\n        .map(([toolId]) => toolId)\n    );\n    const shouldPreselectDetected = !extendMode && configuredToolIds.size === 0;\n    const canPrompt = this.canPromptInteractively();\n\n    // Non-interactive mode: use detected tools as fallback (task 7.8)\n    if (!canPrompt) {\n      if (detectedToolIds.size > 0) {\n        return [...detectedToolIds];\n      }\n      throw new Error(\n        `No tools detected and no --tools flag provided. Valid tools:\\n  ${validTools.join('\\n  ')}\\n\\nUse --tools all, --tools none, or --tools claude,cursor,...`\n      );\n    }\n\n    if (validTools.length === 0) {\n      throw new Error(\n        `No tools available for skill generation.`\n      );\n    }\n\n    // Interactive mode: show searchable multi-select\n    const { searchableMultiSelect } = await import('../prompts/searchable-multi-select.js');\n\n    // Build choices: pre-select configured tools; keep detected tools visible but unselected.\n    const sortedChoices = validTools\n      .map((toolId) => {\n        const tool = AI_TOOLS.find((t) => t.value === toolId);\n        const status = toolStates.get(toolId);\n        const configured = status?.configured ?? false;\n        const detected = detectedToolIds.has(toolId);\n\n        return {\n          name: tool?.name || toolId,\n          value: toolId,\n          configured,\n          detected: detected && !configured,\n          preSelected: configured || (shouldPreselectDetected && detected && !configured),\n        };\n      })\n      .sort((a, b) => {\n        // Configured tools first, then detected (not configured), then everything else.\n        if (a.configured && !b.configured) return -1;\n        if (!a.configured && b.configured) return 1;\n        if (a.detected && !b.detected) return -1;\n        if (!a.detected && b.detected) return 1;\n        return 0;\n      });\n\n    const configuredNames = validTools\n      .filter((toolId) => configuredToolIds.has(toolId))\n      .map((toolId) => AI_TOOLS.find((t) => t.value === toolId)?.name || toolId);\n\n    if (configuredNames.length > 0) {\n      console.log(`OpenSpec configured: ${configuredNames.join(', ')} (pre-selected)`);\n    }\n\n    const detectedOnlyNames = detectedTools\n      .filter((tool) => !configuredToolIds.has(tool.value))\n      .map((tool) => tool.name);\n\n    if (detectedOnlyNames.length > 0) {\n      const detectionLabel = shouldPreselectDetected\n        ? 'pre-selected for first-time setup'\n        : 'not pre-selected';\n      console.log(`Detected tool directories: ${detectedOnlyNames.join(', ')} (${detectionLabel})`);\n    }\n\n    const selectedTools = await searchableMultiSelect({\n      message: `Select tools to set up (${validTools.length} available)`,\n      pageSize: 15,\n      choices: sortedChoices,\n      validate: (selected: string[]) => selected.length > 0 || 'Select at least one tool',\n    });\n\n    if (selectedTools.length === 0) {\n      throw new Error('At least one tool must be selected');\n    }\n\n    return selectedTools;\n  }\n\n  private resolveToolsArg(): string[] | null {\n    if (typeof this.toolsArg === 'undefined') {\n      return null;\n    }\n\n    const raw = this.toolsArg.trim();\n    if (raw.length === 0) {\n      throw new Error(\n        'The --tools option requires a value. Use \"all\", \"none\", or a comma-separated list of tool IDs.'\n      );\n    }\n\n    const availableTools = getToolsWithSkillsDir();\n    const availableSet = new Set(availableTools);\n    const availableList = ['all', 'none', ...availableTools].join(', ');\n\n    const lowerRaw = raw.toLowerCase();\n    if (lowerRaw === 'all') {\n      return availableTools;\n    }\n\n    if (lowerRaw === 'none') {\n      return [];\n    }\n\n    const tokens = raw\n      .split(',')\n      .map((token) => token.trim())\n      .filter((token) => token.length > 0);\n\n    if (tokens.length === 0) {\n      throw new Error(\n        'The --tools option requires at least one tool ID when not using \"all\" or \"none\".'\n      );\n    }\n\n    const normalizedTokens = tokens.map((token) => token.toLowerCase());\n\n    if (normalizedTokens.some((token) => token === 'all' || token === 'none')) {\n      throw new Error('Cannot combine reserved values \"all\" or \"none\" with specific tool IDs.');\n    }\n\n    const invalidTokens = tokens.filter(\n      (_token, index) => !availableSet.has(normalizedTokens[index])\n    );\n\n    if (invalidTokens.length > 0) {\n      throw new Error(\n        `Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}`\n      );\n    }\n\n    // Deduplicate while preserving order\n    const deduped: string[] = [];\n    for (const token of normalizedTokens) {\n      if (!deduped.includes(token)) {\n        deduped.push(token);\n      }\n    }\n\n    return deduped;\n  }\n\n  private validateTools(\n    toolIds: string[],\n    toolStates: Map<string, ToolSkillStatus>\n  ): Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> {\n    const validatedTools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> = [];\n\n    for (const toolId of toolIds) {\n      const tool = AI_TOOLS.find((t) => t.value === toolId);\n      if (!tool) {\n        const validToolIds = getToolsWithSkillsDir();\n        throw new Error(\n          `Unknown tool '${toolId}'. Valid tools:\\n  ${validToolIds.join('\\n  ')}`\n        );\n      }\n\n      if (!tool.skillsDir) {\n        const validToolsWithSkills = getToolsWithSkillsDir();\n        throw new Error(\n          `Tool '${toolId}' does not support skill generation.\\nTools with skill generation support:\\n  ${validToolsWithSkills.join('\\n  ')}`\n        );\n      }\n\n      const preState = toolStates.get(tool.value);\n      validatedTools.push({\n        value: tool.value,\n        name: tool.name,\n        skillsDir: tool.skillsDir,\n        wasConfigured: preState?.configured ?? false,\n      });\n    }\n\n    return validatedTools;\n  }\n\n  // ═══════════════════════════════════════════════════════════\n  // DIRECTORY STRUCTURE\n  // ═══════════════════════════════════════════════════════════\n\n  private async createDirectoryStructure(openspecPath: string, extendMode: boolean): Promise<void> {\n    if (extendMode) {\n      // In extend mode, just ensure directories exist without spinner\n      const directories = [\n        openspecPath,\n        path.join(openspecPath, 'specs'),\n        path.join(openspecPath, 'changes'),\n        path.join(openspecPath, 'changes', 'archive'),\n      ];\n\n      for (const dir of directories) {\n        await FileSystemUtils.createDirectory(dir);\n      }\n      return;\n    }\n\n    const spinner = this.startSpinner('Creating OpenSpec structure...');\n\n    const directories = [\n      openspecPath,\n      path.join(openspecPath, 'specs'),\n      path.join(openspecPath, 'changes'),\n      path.join(openspecPath, 'changes', 'archive'),\n    ];\n\n    for (const dir of directories) {\n      await FileSystemUtils.createDirectory(dir);\n    }\n\n    spinner.stopAndPersist({\n      symbol: PALETTE.white('▌'),\n      text: PALETTE.white('OpenSpec structure created'),\n    });\n  }\n\n  // ═══════════════════════════════════════════════════════════\n  // SKILL & COMMAND GENERATION\n  // ═══════════════════════════════════════════════════════════\n\n  private async generateSkillsAndCommands(\n    projectPath: string,\n    tools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }>\n  ): Promise<{\n    createdTools: typeof tools;\n    refreshedTools: typeof tools;\n    failedTools: Array<{ name: string; error: Error }>;\n    commandsSkipped: string[];\n    removedCommandCount: number;\n    removedSkillCount: number;\n  }> {\n    const createdTools: typeof tools = [];\n    const refreshedTools: typeof tools = [];\n    const failedTools: Array<{ name: string; error: Error }> = [];\n    const commandsSkipped: string[] = [];\n    let removedCommandCount = 0;\n    let removedSkillCount = 0;\n\n    // Read global config for profile and delivery settings (use --profile override if set)\n    const globalConfig = getGlobalConfig();\n    const profile: Profile = this.resolveProfileOverride() ?? globalConfig.profile ?? 'core';\n    const delivery: Delivery = globalConfig.delivery ?? 'both';\n    const workflows = getProfileWorkflows(profile, globalConfig.workflows);\n\n    // Get skill and command templates filtered by profile workflows\n    const shouldGenerateSkills = delivery !== 'commands';\n    const shouldGenerateCommands = delivery !== 'skills';\n    const skillTemplates = shouldGenerateSkills ? getSkillTemplates(workflows) : [];\n    const commandContents = shouldGenerateCommands ? getCommandContents(workflows) : [];\n\n    // Process each tool\n    for (const tool of tools) {\n      const spinner = ora(`Setting up ${tool.name}...`).start();\n\n      try {\n        // Generate skill files if delivery includes skills\n        if (shouldGenerateSkills) {\n          // Use tool-specific skillsDir\n          const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');\n\n          // Create skill directories and SKILL.md files\n          for (const { template, dirName } of skillTemplates) {\n            const skillDir = path.join(skillsDir, dirName);\n            const skillFile = path.join(skillDir, 'SKILL.md');\n\n            // Generate SKILL.md content with YAML frontmatter including generatedBy\n            // Use hyphen-based command references for OpenCode\n            const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;\n            const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);\n\n            // Write the skill file\n            await FileSystemUtils.writeFile(skillFile, skillContent);\n          }\n        }\n        if (!shouldGenerateSkills) {\n          const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');\n          removedSkillCount += await this.removeSkillDirs(skillsDir);\n        }\n\n        // Generate commands if delivery includes commands\n        if (shouldGenerateCommands) {\n          const adapter = CommandAdapterRegistry.get(tool.value);\n          if (adapter) {\n            const generatedCommands = generateCommands(commandContents, adapter);\n\n            for (const cmd of generatedCommands) {\n              const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);\n              await FileSystemUtils.writeFile(commandFile, cmd.fileContent);\n            }\n          } else {\n            commandsSkipped.push(tool.value);\n          }\n        }\n        if (!shouldGenerateCommands) {\n          removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);\n        }\n\n        spinner.succeed(`Setup complete for ${tool.name}`);\n\n        if (tool.wasConfigured) {\n          refreshedTools.push(tool);\n        } else {\n          createdTools.push(tool);\n        }\n      } catch (error) {\n        spinner.fail(`Failed for ${tool.name}`);\n        failedTools.push({ name: tool.name, error: error as Error });\n      }\n    }\n\n    return {\n      createdTools,\n      refreshedTools,\n      failedTools,\n      commandsSkipped,\n      removedCommandCount,\n      removedSkillCount,\n    };\n  }\n\n  // ═══════════════════════════════════════════════════════════\n  // CONFIG FILE\n  // ═══════════════════════════════════════════════════════════\n\n  private async createConfig(openspecPath: string, extendMode: boolean): Promise<'created' | 'exists' | 'skipped'> {\n    const configPath = path.join(openspecPath, 'config.yaml');\n    const configYmlPath = path.join(openspecPath, 'config.yml');\n    const configYamlExists = fs.existsSync(configPath);\n    const configYmlExists = fs.existsSync(configYmlPath);\n\n    if (configYamlExists || configYmlExists) {\n      return 'exists';\n    }\n\n    // In non-interactive mode without --force, skip config creation\n    if (!this.canPromptInteractively() && !this.force) {\n      return 'skipped';\n    }\n\n    try {\n      const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA });\n      await FileSystemUtils.writeFile(configPath, yamlContent);\n      return 'created';\n    } catch {\n      return 'skipped';\n    }\n  }\n\n  // ═══════════════════════════════════════════════════════════\n  // UI & OUTPUT\n  // ═══════════════════════════════════════════════════════════\n\n  private displaySuccessMessage(\n    projectPath: string,\n    tools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }>,\n    results: {\n      createdTools: typeof tools;\n      refreshedTools: typeof tools;\n      failedTools: Array<{ name: string; error: Error }>;\n      commandsSkipped: string[];\n      removedCommandCount: number;\n      removedSkillCount: number;\n    },\n    configStatus: 'created' | 'exists' | 'skipped'\n  ): void {\n    console.log();\n    console.log(chalk.bold('OpenSpec Setup Complete'));\n    console.log();\n\n    // Show created vs refreshed tools\n    if (results.createdTools.length > 0) {\n      console.log(`Created: ${results.createdTools.map((t) => t.name).join(', ')}`);\n    }\n    if (results.refreshedTools.length > 0) {\n      console.log(`Refreshed: ${results.refreshedTools.map((t) => t.name).join(', ')}`);\n    }\n\n    // Show counts (respecting profile filter)\n    const successfulTools = [...results.createdTools, ...results.refreshedTools];\n    if (successfulTools.length > 0) {\n      const globalConfig = getGlobalConfig();\n      const profile: Profile = (this.profileOverride as Profile) ?? globalConfig.profile ?? 'core';\n      const delivery: Delivery = globalConfig.delivery ?? 'both';\n      const workflows = getProfileWorkflows(profile, globalConfig.workflows);\n      const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', ');\n      const skillCount = delivery !== 'commands' ? getSkillTemplates(workflows).length : 0;\n      const commandCount = delivery !== 'skills' ? getCommandContents(workflows).length : 0;\n      if (skillCount > 0 && commandCount > 0) {\n        console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`);\n      } else if (skillCount > 0) {\n        console.log(`${skillCount} skills in ${toolDirs}/`);\n      } else if (commandCount > 0) {\n        console.log(`${commandCount} commands in ${toolDirs}/`);\n      }\n    }\n\n    // Show failures\n    if (results.failedTools.length > 0) {\n      console.log(chalk.red(`Failed: ${results.failedTools.map((f) => `${f.name} (${f.error.message})`).join(', ')}`));\n    }\n\n    // Show skipped commands\n    if (results.commandsSkipped.length > 0) {\n      console.log(chalk.dim(`Commands skipped for: ${results.commandsSkipped.join(', ')} (no adapter)`));\n    }\n    if (results.removedCommandCount > 0) {\n      console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (delivery: skills)`));\n    }\n    if (results.removedSkillCount > 0) {\n      console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`));\n    }\n\n    // Config status\n    if (configStatus === 'created') {\n      console.log(`Config: openspec/config.yaml (schema: ${DEFAULT_SCHEMA})`);\n    } else if (configStatus === 'exists') {\n      // Show actual filename (config.yaml or config.yml)\n      const configYaml = path.join(projectPath, OPENSPEC_DIR_NAME, 'config.yaml');\n      const configYml = path.join(projectPath, OPENSPEC_DIR_NAME, 'config.yml');\n      const configName = fs.existsSync(configYaml) ? 'config.yaml' : fs.existsSync(configYml) ? 'config.yml' : 'config.yaml';\n      console.log(`Config: openspec/${configName} (exists)`);\n    } else {\n      console.log(chalk.dim(`Config: skipped (non-interactive mode)`));\n    }\n\n    // Getting started (task 7.6: show propose if in profile)\n    const globalCfg = getGlobalConfig();\n    const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core';\n    const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)];\n    console.log();\n    if (activeWorkflows.includes('propose')) {\n      console.log(chalk.bold('Getting started:'));\n      console.log('  Start your first change: /opsx:propose \"your idea\"');\n    } else if (activeWorkflows.includes('new')) {\n      console.log(chalk.bold('Getting started:'));\n      console.log('  Start your first change: /opsx:new \"your idea\"');\n    } else {\n      console.log(\"Done. Run 'openspec config profile' to configure your workflows.\");\n    }\n\n    // Links\n    console.log();\n    console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);\n    console.log(`Feedback:   ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);\n\n    // Restart instruction if any tools were configured\n    if (results.createdTools.length > 0 || results.refreshedTools.length > 0) {\n      console.log();\n      console.log(chalk.white('Restart your IDE for slash commands to take effect.'));\n    }\n\n    console.log();\n  }\n\n  private startSpinner(text: string) {\n    return ora({\n      text,\n      stream: process.stdout,\n      color: 'gray',\n      spinner: PROGRESS_SPINNER,\n    }).start();\n  }\n\n  private async removeSkillDirs(skillsDir: string): Promise<number> {\n    let removed = 0;\n\n    for (const workflow of ALL_WORKFLOWS) {\n      const dirName = WORKFLOW_TO_SKILL_DIR[workflow];\n      if (!dirName) continue;\n\n      const skillDir = path.join(skillsDir, dirName);\n      try {\n        if (fs.existsSync(skillDir)) {\n          await fs.promises.rm(skillDir, { recursive: true, force: true });\n          removed++;\n        }\n      } catch {\n        // Ignore errors\n      }\n    }\n\n    return removed;\n  }\n\n  private async removeCommandFiles(projectPath: string, toolId: string): Promise<number> {\n    let removed = 0;\n    const adapter = CommandAdapterRegistry.get(toolId);\n    if (!adapter) return 0;\n\n    for (const workflow of ALL_WORKFLOWS) {\n      const cmdPath = adapter.getFilePath(workflow);\n      const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);\n\n      try {\n        if (fs.existsSync(fullPath)) {\n          await fs.promises.unlink(fullPath);\n          removed++;\n        }\n      } catch {\n        // Ignore errors\n      }\n    }\n\n    return removed;\n  }\n}\n"
  },
  {
    "path": "src/core/legacy-cleanup.ts",
    "content": "/**\n * Legacy cleanup module for detecting and removing OpenSpec artifacts\n * from previous init versions during the migration to the skill-based workflow.\n */\n\nimport path from 'path';\nimport { promises as fs } from 'fs';\nimport chalk from 'chalk';\nimport { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js';\nimport { OPENSPEC_MARKERS } from './config.js';\n\n/**\n * Legacy config file names from the old ToolRegistry.\n * These were config files created at project root with OpenSpec markers.\n */\nexport const LEGACY_CONFIG_FILES = [\n  'CLAUDE.md',\n  'CLINE.md',\n  'CODEBUDDY.md',\n  'COSTRICT.md',\n  'QODER.md',\n  'IFLOW.md',\n  'AGENTS.md', // root AGENTS.md (not openspec/AGENTS.md)\n  'QWEN.md',\n] as const;\n\n/**\n * Legacy slash command patterns from the old SlashCommandRegistry.\n * These map toolId to the path pattern where legacy commands were created.\n * Some tools used a directory structure, others used individual files.\n */\nexport const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPattern> = {\n  // Directory-based: .tooldir/commands/openspec/ or .tooldir/commands/openspec/*.md\n  'claude': { type: 'directory', path: '.claude/commands/openspec' },\n  'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' },\n  'qoder': { type: 'directory', path: '.qoder/commands/openspec' },\n  'crush': { type: 'directory', path: '.crush/commands/openspec' },\n  'gemini': { type: 'directory', path: '.gemini/commands/openspec' },\n  'costrict': { type: 'directory', path: '.cospec/openspec/commands' },\n\n  // File-based: individual openspec-*.md files in a commands/workflows/prompts folder\n  'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },\n  'windsurf': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' },\n  'kilocode': { type: 'files', pattern: '.kilocode/workflows/openspec-*.md' },\n  'kiro': { type: 'files', pattern: '.kiro/prompts/openspec-*.prompt.md' },\n  'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' },\n  'amazon-q': { type: 'files', pattern: '.amazonq/prompts/openspec-*.md' },\n  'cline': { type: 'files', pattern: '.clinerules/workflows/openspec-*.md' },\n  'roocode': { type: 'files', pattern: '.roo/commands/openspec-*.md' },\n  'auggie': { type: 'files', pattern: '.augment/commands/openspec-*.md' },\n  'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' },\n  'opencode': { type: 'files', pattern: ['.opencode/command/opsx-*.md', '.opencode/command/openspec-*.md'] },\n  'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },\n  'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },\n  'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },\n  'qwen': { type: 'files', pattern: '.qwen/commands/openspec-*.toml' },\n  'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' },\n};\n\n/**\n * Pattern types for legacy slash commands\n */\nexport interface LegacySlashCommandPattern {\n  type: 'directory' | 'files';\n  path?: string; // For directory type\n  pattern?: string | string[]; // For files type (glob pattern or array of patterns)\n}\n\n/**\n * Result of legacy artifact detection\n */\nexport interface LegacyDetectionResult {\n  /** Config files with OpenSpec markers detected */\n  configFiles: string[];\n  /** Config files to update (remove markers only, never delete) */\n  configFilesToUpdate: string[];\n  /** Legacy slash command directories found */\n  slashCommandDirs: string[];\n  /** Legacy slash command files found (for file-based tools) */\n  slashCommandFiles: string[];\n  /** Whether openspec/AGENTS.md exists */\n  hasOpenspecAgents: boolean;\n  /** Whether openspec/project.md exists (preserved, migration hint only) */\n  hasProjectMd: boolean;\n  /** Whether root AGENTS.md has OpenSpec markers */\n  hasRootAgentsWithMarkers: boolean;\n  /** Whether any legacy artifacts were found */\n  hasLegacyArtifacts: boolean;\n}\n\n/**\n * Detects all legacy OpenSpec artifacts in a project.\n *\n * @param projectPath - The root path of the project\n * @returns Detection result with all found legacy artifacts\n */\nexport async function detectLegacyArtifacts(\n  projectPath: string\n): Promise<LegacyDetectionResult> {\n  const result: LegacyDetectionResult = {\n    configFiles: [],\n    configFilesToUpdate: [],\n    slashCommandDirs: [],\n    slashCommandFiles: [],\n    hasOpenspecAgents: false,\n    hasProjectMd: false,\n    hasRootAgentsWithMarkers: false,\n    hasLegacyArtifacts: false,\n  };\n\n  // Detect legacy config files\n  const configResult = await detectLegacyConfigFiles(projectPath);\n  result.configFiles = configResult.allFiles;\n  result.configFilesToUpdate = configResult.filesToUpdate;\n\n  // Detect legacy slash commands\n  const slashResult = await detectLegacySlashCommands(projectPath);\n  result.slashCommandDirs = slashResult.directories;\n  result.slashCommandFiles = slashResult.files;\n\n  // Detect legacy structure files\n  const structureResult = await detectLegacyStructureFiles(projectPath);\n  result.hasOpenspecAgents = structureResult.hasOpenspecAgents;\n  result.hasProjectMd = structureResult.hasProjectMd;\n  result.hasRootAgentsWithMarkers = structureResult.hasRootAgentsWithMarkers;\n\n  // Determine if any legacy artifacts exist\n  result.hasLegacyArtifacts =\n    result.configFiles.length > 0 ||\n    result.slashCommandDirs.length > 0 ||\n    result.slashCommandFiles.length > 0 ||\n    result.hasOpenspecAgents ||\n    result.hasRootAgentsWithMarkers ||\n    result.hasProjectMd;\n\n  return result;\n}\n\n/**\n * Detects legacy config files with OpenSpec markers.\n * All config files with markers are candidates for update (marker removal only).\n * Config files are NEVER deleted - they belong to the user's project root.\n *\n * @param projectPath - The root path of the project\n * @returns Object with all files found and files to update\n */\nexport async function detectLegacyConfigFiles(\n  projectPath: string\n): Promise<{\n  allFiles: string[];\n  filesToUpdate: string[];\n}> {\n  const allFiles: string[] = [];\n  const filesToUpdate: string[] = [];\n\n  for (const fileName of LEGACY_CONFIG_FILES) {\n    const filePath = FileSystemUtils.joinPath(projectPath, fileName);\n\n    if (await FileSystemUtils.fileExists(filePath)) {\n      const content = await FileSystemUtils.readFile(filePath);\n\n      if (hasOpenSpecMarkers(content)) {\n        allFiles.push(fileName);\n        filesToUpdate.push(fileName); // Always update, never delete config files\n      }\n    }\n  }\n\n  return { allFiles, filesToUpdate };\n}\n\n/**\n * Detects legacy slash command directories and files.\n *\n * @param projectPath - The root path of the project\n * @returns Object with directories and individual files found\n */\nexport async function detectLegacySlashCommands(\n  projectPath: string\n): Promise<{\n  directories: string[];\n  files: string[];\n}> {\n  const directories: string[] = [];\n  const files: string[] = [];\n\n  for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {\n    if (pattern.type === 'directory' && pattern.path) {\n      const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path);\n      if (await FileSystemUtils.directoryExists(dirPath)) {\n        directories.push(pattern.path);\n      }\n    } else if (pattern.type === 'files' && pattern.pattern) {\n      // For file-based patterns, check for individual files\n      const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];\n      for (const p of patterns) {\n        const foundFiles = await findLegacySlashCommandFiles(projectPath, p);\n        files.push(...foundFiles);\n      }\n    }\n  }\n\n  return { directories, files };\n}\n\n/**\n * Finds legacy slash command files matching a glob pattern.\n *\n * @param projectPath - The root path of the project\n * @param pattern - Glob pattern like '.cursor/commands/openspec-*.md'\n * @returns Array of matching file paths relative to projectPath\n */\nasync function findLegacySlashCommandFiles(\n  projectPath: string,\n  pattern: string\n): Promise<string[]> {\n  const foundFiles: string[] = [];\n\n  // Extract directory and file pattern from glob\n  // Handle both forward and backward slashes for Windows compatibility\n  const lastForwardSlash = pattern.lastIndexOf('/');\n  const lastBackSlash = pattern.lastIndexOf('\\\\');\n  const lastSeparator = Math.max(lastForwardSlash, lastBackSlash);\n  const dirPart = pattern.substring(0, lastSeparator);\n  const filePart = pattern.substring(lastSeparator + 1);\n\n  const dirPath = FileSystemUtils.joinPath(projectPath, dirPart);\n\n  if (!(await FileSystemUtils.directoryExists(dirPath))) {\n    return foundFiles;\n  }\n\n  try {\n    const entries = await fs.readdir(dirPath);\n\n    // Convert glob pattern to regex\n    // openspec-*.md -> /^openspec-.*\\.md$/\n    // openspec-*.prompt.md -> /^openspec-.*\\.prompt\\.md$/\n    // openspec-*.toml -> /^openspec-.*\\.toml$/\n    const regexPattern = filePart\n      .replace(/[.+^${}()|[\\]\\\\]/g, '\\\\$&') // Escape regex special chars except *\n      .replace(/\\*/g, '.*'); // Replace * with .*\n    const regex = new RegExp(`^${regexPattern}$`);\n\n    for (const entry of entries) {\n      if (regex.test(entry)) {\n        // Use forward slashes for consistency in relative paths (cross-platform)\n        const normalizedDir = dirPart.replace(/\\\\/g, '/');\n        foundFiles.push(`${normalizedDir}/${entry}`);\n      }\n    }\n  } catch {\n    // Directory doesn't exist or can't be read\n  }\n\n  return foundFiles;\n}\n\n/**\n * Detects legacy OpenSpec structure files (AGENTS.md and project.md).\n *\n * @param projectPath - The root path of the project\n * @returns Object with detection results for structure files\n */\nexport async function detectLegacyStructureFiles(\n  projectPath: string\n): Promise<{\n  hasOpenspecAgents: boolean;\n  hasProjectMd: boolean;\n  hasRootAgentsWithMarkers: boolean;\n}> {\n  let hasOpenspecAgents = false;\n  let hasProjectMd = false;\n  let hasRootAgentsWithMarkers = false;\n\n  // Check for openspec/AGENTS.md\n  const openspecAgentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md');\n  hasOpenspecAgents = await FileSystemUtils.fileExists(openspecAgentsPath);\n\n  // Check for openspec/project.md (for migration messaging, not deleted)\n  const projectMdPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'project.md');\n  hasProjectMd = await FileSystemUtils.fileExists(projectMdPath);\n\n  // Check for root AGENTS.md with OpenSpec markers\n  const rootAgentsPath = FileSystemUtils.joinPath(projectPath, 'AGENTS.md');\n  if (await FileSystemUtils.fileExists(rootAgentsPath)) {\n    const content = await FileSystemUtils.readFile(rootAgentsPath);\n    hasRootAgentsWithMarkers = hasOpenSpecMarkers(content);\n  }\n\n  return { hasOpenspecAgents, hasProjectMd, hasRootAgentsWithMarkers };\n}\n\n/**\n * Checks if content contains OpenSpec markers.\n *\n * @param content - File content to check\n * @returns True if both start and end markers are present\n */\nexport function hasOpenSpecMarkers(content: string): boolean {\n  return (\n    content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end)\n  );\n}\n\n/**\n * Checks if file content is 100% OpenSpec content (only markers and whitespace outside).\n *\n * @param content - File content to check\n * @returns True if content outside markers is only whitespace\n */\nexport function isOnlyOpenSpecContent(content: string): boolean {\n  const startIndex = content.indexOf(OPENSPEC_MARKERS.start);\n  const endIndex = content.indexOf(OPENSPEC_MARKERS.end);\n\n  if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {\n    return false;\n  }\n\n  const before = content.substring(0, startIndex);\n  const after = content.substring(endIndex + OPENSPEC_MARKERS.end.length);\n\n  return before.trim() === '' && after.trim() === '';\n}\n\n/**\n * Removes the OpenSpec marker block from file content.\n * Only removes markers that are on their own lines (ignores inline mentions).\n * Cleans up double blank lines that may result from removal.\n *\n * @param content - File content with OpenSpec markers\n * @returns Content with marker block removed\n */\nexport function removeMarkerBlock(content: string): string {\n  return removeMarkerBlockUtil(content, OPENSPEC_MARKERS.start, OPENSPEC_MARKERS.end);\n}\n\n/**\n * Result of cleanup operation\n */\nexport interface CleanupResult {\n  /** Files that were deleted entirely */\n  deletedFiles: string[];\n  /** Files that had marker blocks removed */\n  modifiedFiles: string[];\n  /** Directories that were deleted */\n  deletedDirs: string[];\n  /** Whether project.md exists and needs manual migration */\n  projectMdNeedsMigration: boolean;\n  /** Error messages if any operations failed */\n  errors: string[];\n}\n\n/**\n * Cleans up legacy OpenSpec artifacts from a project.\n * Preserves openspec/project.md (shows migration hint instead of deleting).\n *\n * @param projectPath - The root path of the project\n * @param detection - Detection result from detectLegacyArtifacts\n * @returns Cleanup result with summary of actions taken\n */\nexport async function cleanupLegacyArtifacts(\n  projectPath: string,\n  detection: LegacyDetectionResult\n): Promise<CleanupResult> {\n  const result: CleanupResult = {\n    deletedFiles: [],\n    modifiedFiles: [],\n    deletedDirs: [],\n    projectMdNeedsMigration: detection.hasProjectMd,\n    errors: [],\n  };\n\n  // Remove marker blocks from config files (NEVER delete config files)\n  // Config files like CLAUDE.md, AGENTS.md belong to the user's project root\n  for (const fileName of detection.configFilesToUpdate) {\n    const filePath = FileSystemUtils.joinPath(projectPath, fileName);\n    try {\n      const content = await FileSystemUtils.readFile(filePath);\n      const newContent = removeMarkerBlock(content);\n      // Always write the file, even if empty - never delete user config files\n      await FileSystemUtils.writeFile(filePath, newContent);\n      result.modifiedFiles.push(fileName);\n    } catch (error: any) {\n      result.errors.push(`Failed to modify ${fileName}: ${error.message}`);\n    }\n  }\n\n  // Delete legacy slash command directories (these are 100% OpenSpec-managed)\n  for (const dirPath of detection.slashCommandDirs) {\n    const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);\n    try {\n      await fs.rm(fullPath, { recursive: true, force: true });\n      result.deletedDirs.push(dirPath);\n    } catch (error: any) {\n      result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);\n    }\n  }\n\n  // Delete legacy slash command files (these are 100% OpenSpec-managed)\n  for (const filePath of detection.slashCommandFiles) {\n    const fullPath = FileSystemUtils.joinPath(projectPath, filePath);\n    try {\n      await fs.unlink(fullPath);\n      result.deletedFiles.push(filePath);\n    } catch (error: any) {\n      result.errors.push(`Failed to delete ${filePath}: ${error.message}`);\n    }\n  }\n\n  // Delete openspec/AGENTS.md (this is inside openspec/, it's OpenSpec-managed)\n  if (detection.hasOpenspecAgents) {\n    const agentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md');\n    if (await FileSystemUtils.fileExists(agentsPath)) {\n      try {\n        await fs.unlink(agentsPath);\n        result.deletedFiles.push('openspec/AGENTS.md');\n      } catch (error: any) {\n        result.errors.push(`Failed to delete openspec/AGENTS.md: ${error.message}`);\n      }\n    }\n  }\n\n  // Handle root AGENTS.md with OpenSpec markers - remove markers only, NEVER delete\n  // Note: Root AGENTS.md is handled via configFilesToUpdate above (it's in LEGACY_CONFIG_FILES)\n  // This hasRootAgentsWithMarkers flag is just for detection, cleanup happens via configFilesToUpdate\n\n  return result;\n}\n\n/**\n * Generates a cleanup summary message for display.\n *\n * @param result - Cleanup result from cleanupLegacyArtifacts\n * @returns Formatted summary string for console output\n */\nexport function formatCleanupSummary(result: CleanupResult): string {\n  const lines: string[] = [];\n\n  if (result.deletedFiles.length > 0 || result.deletedDirs.length > 0 || result.modifiedFiles.length > 0) {\n    lines.push('Cleaned up legacy files:');\n\n    for (const file of result.deletedFiles) {\n      lines.push(`  ✓ Removed ${file}`);\n    }\n\n    for (const dir of result.deletedDirs) {\n      lines.push(`  ✓ Removed ${dir}/ (replaced by /opsx:*)`);\n    }\n\n    for (const file of result.modifiedFiles) {\n      lines.push(`  ✓ Removed OpenSpec markers from ${file}`);\n    }\n  }\n\n  if (result.projectMdNeedsMigration) {\n    if (lines.length > 0) {\n      lines.push('');\n    }\n    lines.push(formatProjectMdMigrationHint());\n  }\n\n  if (result.errors.length > 0) {\n    if (lines.length > 0) {\n      lines.push('');\n    }\n    lines.push('Errors during cleanup:');\n    for (const error of result.errors) {\n      lines.push(`  ⚠ ${error}`);\n    }\n  }\n\n  return lines.join('\\n');\n}\n\n/**\n * Build list of files to be removed with explanations.\n * Only includes OpenSpec-managed files (slash commands, openspec/AGENTS.md).\n * Config files like CLAUDE.md, AGENTS.md are NEVER deleted.\n *\n * @param detection - Detection result from detectLegacyArtifacts\n * @returns Array of objects with path and explanation\n */\nfunction buildRemovalsList(detection: LegacyDetectionResult): Array<{ path: string; explanation: string }> {\n  const removals: Array<{ path: string; explanation: string }> = [];\n\n  // Slash command directories (these are 100% OpenSpec-managed)\n  for (const dir of detection.slashCommandDirs) {\n    // Split on both forward and backward slashes for Windows compatibility\n    const toolDir = dir.split(/[\\/\\\\]/)[0];\n    removals.push({ path: dir + '/', explanation: `replaced by ${toolDir}/skills/` });\n  }\n\n  // Slash command files (these are 100% OpenSpec-managed)\n  for (const file of detection.slashCommandFiles) {\n    removals.push({ path: file, explanation: 'replaced by skills/' });\n  }\n\n  // openspec/AGENTS.md (inside openspec/, it's OpenSpec-managed)\n  if (detection.hasOpenspecAgents) {\n    removals.push({ path: 'openspec/AGENTS.md', explanation: 'obsolete workflow file' });\n  }\n\n  // Note: Config files (CLAUDE.md, AGENTS.md, etc.) are NEVER in the removals list\n  // They always go to the updates list where only markers are removed\n\n  return removals;\n}\n\n/**\n * Build list of files to be updated with explanations.\n * Includes ALL config files with markers - markers are removed, file is never deleted.\n *\n * @param detection - Detection result from detectLegacyArtifacts\n * @returns Array of objects with path and explanation\n */\nfunction buildUpdatesList(detection: LegacyDetectionResult): Array<{ path: string; explanation: string }> {\n  const updates: Array<{ path: string; explanation: string }> = [];\n\n  // All config files with markers get updated (markers removed, file preserved)\n  for (const file of detection.configFilesToUpdate) {\n    updates.push({ path: file, explanation: 'removing OpenSpec markers' });\n  }\n\n  return updates;\n}\n\n/**\n * Generates a detection summary message for display before cleanup.\n * Groups files by action type: removals, updates, and manual migration.\n *\n * @param detection - Detection result from detectLegacyArtifacts\n * @returns Formatted summary string showing what was found\n */\nexport function formatDetectionSummary(detection: LegacyDetectionResult): string {\n  const lines: string[] = [];\n\n  const removals = buildRemovalsList(detection);\n  const updates = buildUpdatesList(detection);\n\n  // If nothing to show, return empty\n  if (removals.length === 0 && updates.length === 0 && !detection.hasProjectMd) {\n    return '';\n  }\n\n  // Header - welcoming upgrade message\n  lines.push(chalk.bold('Upgrading to the new OpenSpec'));\n  lines.push('');\n  lines.push('OpenSpec now uses agent skills, the emerging standard across coding');\n  lines.push('agents. This simplifies your setup while keeping everything working');\n  lines.push('as before.');\n  lines.push('');\n\n  // Section 1: Files to remove (no user content to preserve)\n  if (removals.length > 0) {\n    lines.push(chalk.bold('Files to remove'));\n    lines.push(chalk.dim('No user content to preserve:'));\n    for (const { path } of removals) {\n      lines.push(`  • ${path}`);\n    }\n  }\n\n  // Section 2: Files to update (markers removed, content preserved)\n  if (updates.length > 0) {\n    if (removals.length > 0) lines.push('');\n    lines.push(chalk.bold('Files to update'));\n    lines.push(chalk.dim('OpenSpec markers will be removed, your content preserved:'));\n    for (const { path } of updates) {\n      lines.push(`  • ${path}`);\n    }\n  }\n\n  // Section 3: Manual migration (project.md)\n  if (detection.hasProjectMd) {\n    if (removals.length > 0 || updates.length > 0) lines.push('');\n    lines.push(formatProjectMdMigrationHint());\n  }\n\n  return lines.join('\\n');\n}\n\n/**\n * Extract tool IDs from detected legacy artifacts.\n * Uses LEGACY_SLASH_COMMAND_PATHS to map paths back to tool IDs.\n *\n * @param detection - Detection result from detectLegacyArtifacts\n * @returns Array of tool IDs that had legacy artifacts\n */\nexport function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): string[] {\n  const tools = new Set<string>();\n\n  // Match directories to tool IDs\n  for (const dir of detection.slashCommandDirs) {\n    for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {\n      if (pattern.type === 'directory' && pattern.path === dir) {\n        tools.add(toolId);\n        break;\n      }\n    }\n  }\n\n  // Match files to tool IDs using glob patterns\n  for (const file of detection.slashCommandFiles) {\n    // Normalize file path to use forward slashes for consistent matching (Windows compatibility)\n    const normalizedFile = file.replace(/\\\\/g, '/');\n    for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {\n      if (pattern.type === 'files' && pattern.pattern) {\n        // Convert glob pattern to regex for matching\n        // e.g., '.cursor/commands/openspec-*.md' -> /^\\.cursor\\/commands\\/openspec-.*\\.md$/\n        const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];\n        let matched = false;\n        for (const p of patterns) {\n          const regexPattern = p\n            .replace(/[.+^${}()|[\\]\\\\]/g, '\\\\$&') // Escape regex special chars except *\n            .replace(/\\*/g, '.*'); // Replace * with .*\n          const regex = new RegExp(`^${regexPattern}$`);\n          if (regex.test(normalizedFile)) {\n            tools.add(toolId);\n            matched = true;\n            break;\n          }\n        }\n        if (matched) break;\n      }\n    }\n  }\n\n  return Array.from(tools);\n}\n\n/**\n * Generates a migration hint message for project.md.\n * This is shown when project.md exists and needs manual migration to config.yaml.\n *\n * @returns Formatted migration hint string for console output\n */\nexport function formatProjectMdMigrationHint(): string {\n  const lines: string[] = [];\n  lines.push(chalk.yellow.bold('Needs your attention'));\n  lines.push('  • openspec/project.md');\n  lines.push(chalk.dim('    We won\\'t delete this file. It may contain useful project context.'));\n  lines.push('');\n  lines.push(chalk.dim('    The new openspec/config.yaml has a \"context:\" section for planning'));\n  lines.push(chalk.dim('    context. This is included in every OpenSpec request and works more'));\n  lines.push(chalk.dim('    reliably than the old project.md approach.'));\n  lines.push('');\n  lines.push(chalk.dim('    Review project.md, move any useful content to config.yaml\\'s context'));\n  lines.push(chalk.dim('    section, then delete the file when ready.'));\n  return lines.join('\\n');\n}\n"
  },
  {
    "path": "src/core/list.ts",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\nimport { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\nimport { MarkdownParser } from './parsers/markdown-parser.js';\n\ninterface ChangeInfo {\n  name: string;\n  completedTasks: number;\n  totalTasks: number;\n  lastModified: Date;\n}\n\ninterface ListOptions {\n  sort?: 'recent' | 'name';\n  json?: boolean;\n}\n\n/**\n * Get the most recent modification time of any file in a directory (recursive).\n * Falls back to the directory's own mtime if no files are found.\n */\nasync function getLastModified(dirPath: string): Promise<Date> {\n  let latest: Date | null = null;\n\n  async function walk(dir: string): Promise<void> {\n    const entries = await fs.readdir(dir, { withFileTypes: true });\n    for (const entry of entries) {\n      const fullPath = path.join(dir, entry.name);\n      if (entry.isDirectory()) {\n        await walk(fullPath);\n      } else {\n        const stat = await fs.stat(fullPath);\n        if (latest === null || stat.mtime > latest) {\n          latest = stat.mtime;\n        }\n      }\n    }\n  }\n\n  await walk(dirPath);\n\n  // If no files found, use the directory's own modification time\n  if (latest === null) {\n    const dirStat = await fs.stat(dirPath);\n    return dirStat.mtime;\n  }\n\n  return latest;\n}\n\n/**\n * Format a date as relative time (e.g., \"2 hours ago\", \"3 days ago\")\n */\nfunction formatRelativeTime(date: Date): string {\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffSecs = Math.floor(diffMs / 1000);\n  const diffMins = Math.floor(diffSecs / 60);\n  const diffHours = Math.floor(diffMins / 60);\n  const diffDays = Math.floor(diffHours / 24);\n\n  if (diffDays > 30) {\n    return date.toLocaleDateString();\n  } else if (diffDays > 0) {\n    return `${diffDays}d ago`;\n  } else if (diffHours > 0) {\n    return `${diffHours}h ago`;\n  } else if (diffMins > 0) {\n    return `${diffMins}m ago`;\n  } else {\n    return 'just now';\n  }\n}\n\nexport class ListCommand {\n  async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise<void> {\n    const { sort = 'recent', json = false } = options;\n\n    if (mode === 'changes') {\n      const changesDir = path.join(targetPath, 'openspec', 'changes');\n\n      // Check if changes directory exists\n      try {\n        await fs.access(changesDir);\n      } catch {\n        throw new Error(\"No OpenSpec changes directory found. Run 'openspec init' first.\");\n      }\n\n      // Get all directories in changes (excluding archive)\n      const entries = await fs.readdir(changesDir, { withFileTypes: true });\n      const changeDirs = entries\n        .filter(entry => entry.isDirectory() && entry.name !== 'archive')\n        .map(entry => entry.name);\n\n      if (changeDirs.length === 0) {\n        if (json) {\n          console.log(JSON.stringify({ changes: [] }));\n        } else {\n          console.log('No active changes found.');\n        }\n        return;\n      }\n\n      // Collect information about each change\n      const changes: ChangeInfo[] = [];\n\n      for (const changeDir of changeDirs) {\n        const progress = await getTaskProgressForChange(changesDir, changeDir);\n        const changePath = path.join(changesDir, changeDir);\n        const lastModified = await getLastModified(changePath);\n        changes.push({\n          name: changeDir,\n          completedTasks: progress.completed,\n          totalTasks: progress.total,\n          lastModified\n        });\n      }\n\n      // Sort by preference (default: recent first)\n      if (sort === 'recent') {\n        changes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());\n      } else {\n        changes.sort((a, b) => a.name.localeCompare(b.name));\n      }\n\n      // JSON output for programmatic use\n      if (json) {\n        const jsonOutput = changes.map(c => ({\n          name: c.name,\n          completedTasks: c.completedTasks,\n          totalTasks: c.totalTasks,\n          lastModified: c.lastModified.toISOString(),\n          status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress'\n        }));\n        console.log(JSON.stringify({ changes: jsonOutput }, null, 2));\n        return;\n      }\n\n      // Display results\n      console.log('Changes:');\n      const padding = '  ';\n      const nameWidth = Math.max(...changes.map(c => c.name.length));\n      for (const change of changes) {\n        const paddedName = change.name.padEnd(nameWidth);\n        const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });\n        const timeAgo = formatRelativeTime(change.lastModified);\n        console.log(`${padding}${paddedName}     ${status.padEnd(12)}  ${timeAgo}`);\n      }\n      return;\n    }\n\n    // specs mode\n    const specsDir = path.join(targetPath, 'openspec', 'specs');\n    try {\n      await fs.access(specsDir);\n    } catch {\n      console.log('No specs found.');\n      return;\n    }\n\n    const entries = await fs.readdir(specsDir, { withFileTypes: true });\n    const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name);\n    if (specDirs.length === 0) {\n      console.log('No specs found.');\n      return;\n    }\n\n    type SpecInfo = { id: string; requirementCount: number };\n    const specs: SpecInfo[] = [];\n    for (const id of specDirs) {\n      const specPath = join(specsDir, id, 'spec.md');\n      try {\n        const content = readFileSync(specPath, 'utf-8');\n        const parser = new MarkdownParser(content);\n        const spec = parser.parseSpec(id);\n        specs.push({ id, requirementCount: spec.requirements.length });\n      } catch {\n        // If spec cannot be read or parsed, include with 0 count\n        specs.push({ id, requirementCount: 0 });\n      }\n    }\n\n    specs.sort((a, b) => a.id.localeCompare(b.id));\n    console.log('Specs:');\n    const padding = '  ';\n    const nameWidth = Math.max(...specs.map(s => s.id.length));\n    for (const spec of specs) {\n      const padded = spec.id.padEnd(nameWidth);\n      console.log(`${padding}${padded}     requirements ${spec.requirementCount}`);\n    }\n  }\n}"
  },
  {
    "path": "src/core/migration.ts",
    "content": "/**\n * Migration Utilities\n *\n * One-time migration logic for existing projects when profile system is introduced.\n * Called by both init and update commands before profile resolution.\n */\n\nimport type { AIToolOption } from './config.js';\nimport { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js';\nimport { CommandAdapterRegistry } from './command-generation/index.js';\nimport { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js';\nimport { ALL_WORKFLOWS } from './profiles.js';\nimport path from 'path';\nimport * as fs from 'fs';\n\ninterface InstalledWorkflowArtifacts {\n  workflows: string[];\n  hasSkills: boolean;\n  hasCommands: boolean;\n}\n\nfunction scanInstalledWorkflowArtifacts(\n  projectPath: string,\n  tools: AIToolOption[]\n): InstalledWorkflowArtifacts {\n  const installed = new Set<string>();\n  let hasSkills = false;\n  let hasCommands = false;\n\n  for (const tool of tools) {\n    if (!tool.skillsDir) continue;\n    const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');\n\n    for (const workflowId of ALL_WORKFLOWS) {\n      const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId];\n      const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md');\n      if (fs.existsSync(skillFile)) {\n        installed.add(workflowId);\n        hasSkills = true;\n      }\n    }\n\n    const adapter = CommandAdapterRegistry.get(tool.value);\n    if (!adapter) continue;\n\n    for (const workflowId of ALL_WORKFLOWS) {\n      const commandPath = adapter.getFilePath(workflowId);\n      const fullPath = path.isAbsolute(commandPath)\n        ? commandPath\n        : path.join(projectPath, commandPath);\n      if (fs.existsSync(fullPath)) {\n        installed.add(workflowId);\n        hasCommands = true;\n      }\n    }\n  }\n\n  return {\n    workflows: ALL_WORKFLOWS.filter((workflowId) => installed.has(workflowId)),\n    hasSkills,\n    hasCommands,\n  };\n}\n\n/**\n * Scans installed workflow files across all detected tools and returns\n * the union of installed workflow IDs.\n */\nexport function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[] {\n  return scanInstalledWorkflowArtifacts(projectPath, tools).workflows;\n}\n\nfunction inferDelivery(artifacts: InstalledWorkflowArtifacts): Delivery {\n  if (artifacts.hasSkills && artifacts.hasCommands) {\n    return 'both';\n  }\n  if (artifacts.hasCommands) {\n    return 'commands';\n  }\n  return 'skills';\n}\n\n/**\n * Performs one-time migration if the global config does not yet have a profile field.\n * Called by both init and update before profile resolution.\n *\n * - If no profile field exists and workflows are installed: sets profile to 'custom'\n *   with the detected workflows, preserving the user's existing setup.\n * - If no profile field exists and no workflows are installed: no-op (defaults apply).\n * - If profile field already exists: no-op.\n */\nexport function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): void {\n  const config = getGlobalConfig();\n\n  // Check raw config file for profile field presence\n  const configPath = getGlobalConfigPath();\n\n  let rawConfig: Record<string, unknown> = {};\n  try {\n    if (fs.existsSync(configPath)) {\n      rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));\n    }\n  } catch {\n    return; // Can't read config, skip migration\n  }\n\n  // If profile is already explicitly set, no migration needed\n  if (rawConfig.profile !== undefined) {\n    return;\n  }\n\n  // Scan for installed workflows\n  const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools);\n  const installedWorkflows = artifacts.workflows;\n\n  if (installedWorkflows.length === 0) {\n    // No workflows installed, new user — defaults will apply\n    return;\n  }\n\n  // Migrate: set profile to custom with detected workflows\n  config.profile = 'custom';\n  config.workflows = installedWorkflows;\n  if (rawConfig.delivery === undefined) {\n    config.delivery = inferDelivery(artifacts);\n  }\n  saveGlobalConfig(config);\n\n  console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`);\n  console.log(\"New in this version: /opsx:propose. Try 'openspec config profile core' for the streamlined experience.\");\n}\n"
  },
  {
    "path": "src/core/parsers/change-parser.ts",
    "content": "import { MarkdownParser, Section } from './markdown-parser.js';\nimport { Change, Delta, DeltaOperation, Requirement } from '../schemas/index.js';\nimport path from 'path';\nimport { promises as fs } from 'fs';\n\ninterface DeltaSection {\n  operation: DeltaOperation;\n  requirements: Requirement[];\n  renames?: Array<{ from: string; to: string }>;\n}\n\nexport class ChangeParser extends MarkdownParser {\n  private changeDir: string;\n\n  constructor(content: string, changeDir: string) {\n    super(content);\n    this.changeDir = changeDir;\n  }\n\n  async parseChangeWithDeltas(name: string): Promise<Change> {\n    const sections = this.parseSections();\n    const why = this.findSection(sections, 'Why')?.content || '';\n    const whatChanges = this.findSection(sections, 'What Changes')?.content || '';\n    \n    if (!why) {\n      throw new Error('Change must have a Why section');\n    }\n    \n    if (!whatChanges) {\n      throw new Error('Change must have a What Changes section');\n    }\n\n    // Parse deltas from the What Changes section (simple format)\n    const simpleDeltas = this.parseDeltas(whatChanges);\n    \n    // Check if there are spec files with delta format\n    const specsDir = path.join(this.changeDir, 'specs');\n    const deltaDeltas = await this.parseDeltaSpecs(specsDir);\n    \n    // Combine both types of deltas, preferring delta format if available\n    const deltas = deltaDeltas.length > 0 ? deltaDeltas : simpleDeltas;\n\n    return {\n      name,\n      why: why.trim(),\n      whatChanges: whatChanges.trim(),\n      deltas,\n      metadata: {\n        version: '1.0.0',\n        format: 'openspec-change',\n      },\n    };\n  }\n\n  private async parseDeltaSpecs(specsDir: string): Promise<Delta[]> {\n    const deltas: Delta[] = [];\n    \n    try {\n      const specDirs = await fs.readdir(specsDir, { withFileTypes: true });\n      \n      for (const dir of specDirs) {\n        if (!dir.isDirectory()) continue;\n        \n        const specName = dir.name;\n        const specFile = path.join(specsDir, specName, 'spec.md');\n        \n        try {\n          const content = await fs.readFile(specFile, 'utf-8');\n          const specDeltas = this.parseSpecDeltas(specName, content);\n          deltas.push(...specDeltas);\n        } catch (error) {\n          // Spec file might not exist, which is okay\n          continue;\n        }\n      }\n    } catch (error) {\n      // Specs directory might not exist, which is okay\n      return [];\n    }\n    \n    return deltas;\n  }\n\n  private parseSpecDeltas(specName: string, content: string): Delta[] {\n    const deltas: Delta[] = [];\n    const sections = this.parseSectionsFromContent(content);\n    \n    // Parse ADDED requirements\n    const addedSection = this.findSection(sections, 'ADDED Requirements');\n    if (addedSection) {\n      const requirements = this.parseRequirements(addedSection);\n      requirements.forEach(req => {\n        deltas.push({\n          spec: specName,\n          operation: 'ADDED' as DeltaOperation,\n          description: `Add requirement: ${req.text}`,\n          // Provide both single and plural forms for compatibility\n          requirement: req,\n          requirements: [req],\n        });\n      });\n    }\n    \n    // Parse MODIFIED requirements\n    const modifiedSection = this.findSection(sections, 'MODIFIED Requirements');\n    if (modifiedSection) {\n      const requirements = this.parseRequirements(modifiedSection);\n      requirements.forEach(req => {\n        deltas.push({\n          spec: specName,\n          operation: 'MODIFIED' as DeltaOperation,\n          description: `Modify requirement: ${req.text}`,\n          requirement: req,\n          requirements: [req],\n        });\n      });\n    }\n    \n    // Parse REMOVED requirements\n    const removedSection = this.findSection(sections, 'REMOVED Requirements');\n    if (removedSection) {\n      const requirements = this.parseRequirements(removedSection);\n      requirements.forEach(req => {\n        deltas.push({\n          spec: specName,\n          operation: 'REMOVED' as DeltaOperation,\n          description: `Remove requirement: ${req.text}`,\n          requirement: req,\n          requirements: [req],\n        });\n      });\n    }\n    \n    // Parse RENAMED requirements\n    const renamedSection = this.findSection(sections, 'RENAMED Requirements');\n    if (renamedSection) {\n      const renames = this.parseRenames(renamedSection.content);\n      renames.forEach(rename => {\n        deltas.push({\n          spec: specName,\n          operation: 'RENAMED' as DeltaOperation,\n          description: `Rename requirement from \"${rename.from}\" to \"${rename.to}\"`,\n          rename,\n        });\n      });\n    }\n    \n    return deltas;\n  }\n\n  private parseRenames(content: string): Array<{ from: string; to: string }> {\n    const renames: Array<{ from: string; to: string }> = [];\n    const lines = ChangeParser.normalizeContent(content).split('\\n');\n    \n    let currentRename: { from?: string; to?: string } = {};\n    \n    for (const line of lines) {\n      const fromMatch = line.match(/^\\s*-?\\s*FROM:\\s*`?###\\s*Requirement:\\s*(.+?)`?\\s*$/);\n      const toMatch = line.match(/^\\s*-?\\s*TO:\\s*`?###\\s*Requirement:\\s*(.+?)`?\\s*$/);\n      \n      if (fromMatch) {\n        currentRename.from = fromMatch[1].trim();\n      } else if (toMatch) {\n        currentRename.to = toMatch[1].trim();\n        \n        if (currentRename.from && currentRename.to) {\n          renames.push({\n            from: currentRename.from,\n            to: currentRename.to,\n          });\n          currentRename = {};\n        }\n      }\n    }\n    \n    return renames;\n  }\n\n  private parseSectionsFromContent(content: string): Section[] {\n    const normalizedContent = ChangeParser.normalizeContent(content);\n    const lines = normalizedContent.split('\\n');\n    const sections: Section[] = [];\n    const stack: Section[] = [];\n    \n    for (let i = 0; i < lines.length; i++) {\n      const line = lines[i];\n      const headerMatch = line.match(/^(#{1,6})\\s+(.+)$/);\n      \n      if (headerMatch) {\n        const level = headerMatch[1].length;\n        const title = headerMatch[2].trim();\n        const contentLines = this.getContentUntilNextHeaderFromLines(lines, i + 1, level);\n        \n        const section = {\n          level,\n          title,\n          content: contentLines.join('\\n').trim(),\n          children: [],\n        };\n\n        while (stack.length > 0 && stack[stack.length - 1].level >= level) {\n          stack.pop();\n        }\n\n        if (stack.length === 0) {\n          sections.push(section);\n        } else {\n          stack[stack.length - 1].children.push(section);\n        }\n        \n        stack.push(section);\n      }\n    }\n    \n    return sections;\n  }\n\n  private getContentUntilNextHeaderFromLines(lines: string[], startLine: number, currentLevel: number): string[] {\n    const contentLines: string[] = [];\n    \n    for (let i = startLine; i < lines.length; i++) {\n      const line = lines[i];\n      const headerMatch = line.match(/^(#{1,6})\\s+/);\n      \n      if (headerMatch && headerMatch[1].length <= currentLevel) {\n        break;\n      }\n      \n      contentLines.push(line);\n    }\n    \n    return contentLines;\n  }\n}"
  },
  {
    "path": "src/core/parsers/markdown-parser.ts",
    "content": "import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation } from '../schemas/index.js';\n\nexport interface Section {\n  level: number;\n  title: string;\n  content: string;\n  children: Section[];\n}\n\nexport class MarkdownParser {\n  private lines: string[];\n  private currentLine: number;\n\n  constructor(content: string) {\n    const normalized = MarkdownParser.normalizeContent(content);\n    this.lines = normalized.split('\\n');\n    this.currentLine = 0;\n  }\n\n  protected static normalizeContent(content: string): string {\n    return content.replace(/\\r\\n?/g, '\\n');\n  }\n\n  parseSpec(name: string): Spec {\n    const sections = this.parseSections();\n    const purpose = this.findSection(sections, 'Purpose')?.content || '';\n    \n    const requirementsSection = this.findSection(sections, 'Requirements');\n    \n    if (!purpose) {\n      throw new Error('Spec must have a Purpose section');\n    }\n    \n    if (!requirementsSection) {\n      throw new Error('Spec must have a Requirements section');\n    }\n\n    const requirements = this.parseRequirements(requirementsSection);\n\n    return {\n      name,\n      overview: purpose.trim(),\n      requirements,\n      metadata: {\n        version: '1.0.0',\n        format: 'openspec',\n      },\n    };\n  }\n\n  parseChange(name: string): Change {\n    const sections = this.parseSections();\n    const why = this.findSection(sections, 'Why')?.content || '';\n    const whatChanges = this.findSection(sections, 'What Changes')?.content || '';\n    \n    if (!why) {\n      throw new Error('Change must have a Why section');\n    }\n    \n    if (!whatChanges) {\n      throw new Error('Change must have a What Changes section');\n    }\n\n    const deltas = this.parseDeltas(whatChanges);\n\n    return {\n      name,\n      why: why.trim(),\n      whatChanges: whatChanges.trim(),\n      deltas,\n      metadata: {\n        version: '1.0.0',\n        format: 'openspec-change',\n      },\n    };\n  }\n\n  protected parseSections(): Section[] {\n    const sections: Section[] = [];\n    const stack: Section[] = [];\n    \n    for (let i = 0; i < this.lines.length; i++) {\n      const line = this.lines[i];\n      const headerMatch = line.match(/^(#{1,6})\\s+(.+)$/);\n      \n      if (headerMatch) {\n        const level = headerMatch[1].length;\n        const title = headerMatch[2].trim();\n        const content = this.getContentUntilNextHeader(i + 1, level);\n        \n        const section: Section = {\n          level,\n          title,\n          content,\n          children: [],\n        };\n\n        while (stack.length > 0 && stack[stack.length - 1].level >= level) {\n          stack.pop();\n        }\n\n        if (stack.length === 0) {\n          sections.push(section);\n        } else {\n          stack[stack.length - 1].children.push(section);\n        }\n        \n        stack.push(section);\n      }\n    }\n    \n    return sections;\n  }\n\n  protected getContentUntilNextHeader(startLine: number, currentLevel: number): string {\n    const contentLines: string[] = [];\n    \n    for (let i = startLine; i < this.lines.length; i++) {\n      const line = this.lines[i];\n      const headerMatch = line.match(/^(#{1,6})\\s+/);\n      \n      if (headerMatch && headerMatch[1].length <= currentLevel) {\n        break;\n      }\n      \n      contentLines.push(line);\n    }\n    \n    return contentLines.join('\\n').trim();\n  }\n\n  protected findSection(sections: Section[], title: string): Section | undefined {\n    for (const section of sections) {\n      if (section.title.toLowerCase() === title.toLowerCase()) {\n        return section;\n      }\n      const child = this.findSection(section.children, title);\n      if (child) {\n        return child;\n      }\n    }\n    return undefined;\n  }\n\n  protected parseRequirements(section: Section): Requirement[] {\n    const requirements: Requirement[] = [];\n    \n    for (const child of section.children) {\n      // Extract requirement text from first non-empty content line, fall back to heading\n      let text = child.title;\n      \n      // Get content before any child sections (scenarios)\n      if (child.content.trim()) {\n        // Split content into lines and find content before any child headers\n        const lines = child.content.split('\\n');\n        const contentBeforeChildren: string[] = [];\n        \n        for (const line of lines) {\n          // Stop at child headers (scenarios start with ####)\n          if (line.trim().startsWith('#')) {\n            break;\n          }\n          contentBeforeChildren.push(line);\n        }\n        \n        // Find first non-empty line\n        const directContent = contentBeforeChildren.join('\\n').trim();\n        if (directContent) {\n          const firstLine = directContent.split('\\n').find(l => l.trim());\n          if (firstLine) {\n            text = firstLine.trim();\n          }\n        }\n      }\n      \n      const scenarios = this.parseScenarios(child);\n      \n      requirements.push({\n        text,\n        scenarios,\n      });\n    }\n    \n    return requirements;\n  }\n\n  protected parseScenarios(requirementSection: Section): Scenario[] {\n    const scenarios: Scenario[] = [];\n    \n    for (const scenarioSection of requirementSection.children) {\n      // Store the raw text content of the scenario section\n      if (scenarioSection.content.trim()) {\n        scenarios.push({\n          rawText: scenarioSection.content\n        });\n      }\n    }\n    \n    return scenarios;\n  }\n\n\n  protected parseDeltas(content: string): Delta[] {\n    const deltas: Delta[] = [];\n    const lines = content.split('\\n');\n    \n    for (const line of lines) {\n      // Match both formats: **spec:** and **spec**:\n      const deltaMatch = line.match(/^\\s*-\\s*\\*\\*([^*:]+)(?::\\*\\*|\\*\\*:)\\s*(.+)$/);\n      if (deltaMatch) {\n        const specName = deltaMatch[1].trim();\n        const description = deltaMatch[2].trim();\n        \n        let operation: DeltaOperation = 'MODIFIED';\n        const lowerDesc = description.toLowerCase();\n        \n        // Use word boundaries to avoid false matches (e.g., \"address\" matching \"add\")\n        // Check RENAMED first since it's more specific than patterns containing \"new\"\n        if (/\\brename(s|d|ing)?\\b/.test(lowerDesc) || /\\brenamed\\s+(to|from)\\b/.test(lowerDesc)) {\n          operation = 'RENAMED';\n        } else if (/\\badd(s|ed|ing)?\\b/.test(lowerDesc) || /\\bcreate(s|d|ing)?\\b/.test(lowerDesc) || /\\bnew\\b/.test(lowerDesc)) {\n          operation = 'ADDED';\n        } else if (/\\bremove(s|d|ing)?\\b/.test(lowerDesc) || /\\bdelete(s|d|ing)?\\b/.test(lowerDesc)) {\n          operation = 'REMOVED';\n        }\n        \n        deltas.push({\n          spec: specName,\n          operation,\n          description,\n        });\n      }\n    }\n    \n    return deltas;\n  }\n}"
  },
  {
    "path": "src/core/parsers/requirement-blocks.ts",
    "content": "export interface RequirementBlock {\n  headerLine: string; // e.g., '### Requirement: Something'\n  name: string; // e.g., 'Something'\n  raw: string; // full block including headerLine and following content\n}\n\nexport interface RequirementsSectionParts {\n  before: string;\n  headerLine: string; // the '## Requirements' line\n  preamble: string; // content between headerLine and first requirement block\n  bodyBlocks: RequirementBlock[]; // parsed requirement blocks in order\n  after: string;\n}\n\nexport function normalizeRequirementName(name: string): string {\n  return name.trim();\n}\n\nconst REQUIREMENT_HEADER_REGEX = /^###\\s*Requirement:\\s*(.+)\\s*$/;\n\n/**\n * Extracts the Requirements section from a spec file and parses requirement blocks.\n */\nexport function extractRequirementsSection(content: string): RequirementsSectionParts {\n  const normalized = normalizeLineEndings(content);\n  const lines = normalized.split('\\n');\n  const reqHeaderIndex = lines.findIndex(l => /^##\\s+Requirements\\s*$/i.test(l));\n\n  if (reqHeaderIndex === -1) {\n    // No requirements section; create an empty one at the end\n    const before = content.trimEnd();\n    const headerLine = '## Requirements';\n    return {\n      before: before ? before + '\\n\\n' : '',\n      headerLine,\n      preamble: '',\n      bodyBlocks: [],\n      after: '\\n',\n    };\n  }\n\n  // Find end of this section: next line that starts with '## ' at same or higher level\n  let endIndex = lines.length;\n  for (let i = reqHeaderIndex + 1; i < lines.length; i++) {\n    if (/^##\\s+/.test(lines[i])) {\n      endIndex = i;\n      break;\n    }\n  }\n\n  const before = lines.slice(0, reqHeaderIndex).join('\\n');\n  const headerLine = lines[reqHeaderIndex];\n  const sectionBodyLines = lines.slice(reqHeaderIndex + 1, endIndex);\n\n  // Parse requirement blocks within section body\n  const blocks: RequirementBlock[] = [];\n  let cursor = 0;\n  let preambleLines: string[] = [];\n\n  // Collect preamble lines until first requirement header\n  while (cursor < sectionBodyLines.length && !/^###\\s+Requirement:/.test(sectionBodyLines[cursor])) {\n    preambleLines.push(sectionBodyLines[cursor]);\n    cursor++;\n  }\n\n  while (cursor < sectionBodyLines.length) {\n    const headerStart = cursor;\n    const headerLineCandidate = sectionBodyLines[cursor];\n    const headerMatch = headerLineCandidate.match(REQUIREMENT_HEADER_REGEX);\n    if (!headerMatch) {\n      // Not a requirement header; skip line defensively\n      cursor++;\n      continue;\n    }\n    const name = normalizeRequirementName(headerMatch[1]);\n    cursor++;\n    // Gather lines until next requirement header or end of section\n    const bodyLines: string[] = [headerLineCandidate];\n    while (cursor < sectionBodyLines.length && !/^###\\s+Requirement:/.test(sectionBodyLines[cursor]) && !/^##\\s+/.test(sectionBodyLines[cursor])) {\n      bodyLines.push(sectionBodyLines[cursor]);\n      cursor++;\n    }\n    const raw = bodyLines.join('\\n').trimEnd();\n    blocks.push({ headerLine: headerLineCandidate, name, raw });\n  }\n\n  const after = lines.slice(endIndex).join('\\n');\n  const preamble = preambleLines.join('\\n').trimEnd();\n\n  return {\n    before: before.trimEnd() ? before + '\\n' : before,\n    headerLine,\n    preamble,\n    bodyBlocks: blocks,\n    after: after.startsWith('\\n') ? after : '\\n' + after,\n  };\n}\n\nexport interface DeltaPlan {\n  added: RequirementBlock[];\n  modified: RequirementBlock[];\n  removed: string[]; // requirement names\n  renamed: Array<{ from: string; to: string }>;\n  sectionPresence: {\n    added: boolean;\n    modified: boolean;\n    removed: boolean;\n    renamed: boolean;\n  };\n}\n\nfunction normalizeLineEndings(content: string): string {\n  return content.replace(/\\r\\n?/g, '\\n');\n}\n\n/**\n * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.\n */\nexport function parseDeltaSpec(content: string): DeltaPlan {\n  const normalized = normalizeLineEndings(content);\n  const sections = splitTopLevelSections(normalized);\n  const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements');\n  const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements');\n  const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements');\n  const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements');\n  const added = parseRequirementBlocksFromSection(addedLookup.body);\n  const modified = parseRequirementBlocksFromSection(modifiedLookup.body);\n  const removedNames = parseRemovedNames(removedLookup.body);\n  const renamedPairs = parseRenamedPairs(renamedLookup.body);\n  return {\n    added,\n    modified,\n    removed: removedNames,\n    renamed: renamedPairs,\n    sectionPresence: {\n      added: addedLookup.found,\n      modified: modifiedLookup.found,\n      removed: removedLookup.found,\n      renamed: renamedLookup.found,\n    },\n  };\n}\n\nfunction splitTopLevelSections(content: string): Record<string, string> {\n  const lines = content.split('\\n');\n  const result: Record<string, string> = {};\n  const indices: Array<{ title: string; index: number; level: number }> = [];\n  for (let i = 0; i < lines.length; i++) {\n    const m = lines[i].match(/^(##)\\s+(.+)$/);\n    if (m) {\n      const level = m[1].length; // only care for '##'\n      indices.push({ title: m[2].trim(), index: i, level });\n    }\n  }\n  for (let i = 0; i < indices.length; i++) {\n    const current = indices[i];\n    const next = indices[i + 1];\n    const body = lines.slice(current.index + 1, next ? next.index : lines.length).join('\\n');\n    result[current.title] = body;\n  }\n  return result;\n}\n\nfunction getSectionCaseInsensitive(sections: Record<string, string>, desired: string): { body: string; found: boolean } {\n  const target = desired.toLowerCase();\n  for (const [title, body] of Object.entries(sections)) {\n    if (title.toLowerCase() === target) return { body, found: true };\n  }\n  return { body: '', found: false };\n}\n\nfunction parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {\n  if (!sectionBody) return [];\n  const lines = normalizeLineEndings(sectionBody).split('\\n');\n  const blocks: RequirementBlock[] = [];\n  let i = 0;\n  while (i < lines.length) {\n    // Seek next requirement header\n    while (i < lines.length && !/^###\\s+Requirement:/.test(lines[i])) i++;\n    if (i >= lines.length) break;\n    const headerLine = lines[i];\n    const m = headerLine.match(REQUIREMENT_HEADER_REGEX);\n    if (!m) { i++; continue; }\n    const name = normalizeRequirementName(m[1]);\n    const buf: string[] = [headerLine];\n    i++;\n    while (i < lines.length && !/^###\\s+Requirement:/.test(lines[i]) && !/^##\\s+/.test(lines[i])) {\n      buf.push(lines[i]);\n      i++;\n    }\n    blocks.push({ headerLine, name, raw: buf.join('\\n').trimEnd() });\n  }\n  return blocks;\n}\n\nfunction parseRemovedNames(sectionBody: string): string[] {\n  if (!sectionBody) return [];\n  const names: string[] = [];\n  const lines = normalizeLineEndings(sectionBody).split('\\n');\n  for (const line of lines) {\n    const m = line.match(REQUIREMENT_HEADER_REGEX);\n    if (m) {\n      names.push(normalizeRequirementName(m[1]));\n      continue;\n    }\n    // Also support bullet list of headers\n    const bullet = line.match(/^\\s*-\\s*`?###\\s*Requirement:\\s*(.+?)`?\\s*$/);\n    if (bullet) {\n      names.push(normalizeRequirementName(bullet[1]));\n    }\n  }\n  return names;\n}\n\nfunction parseRenamedPairs(sectionBody: string): Array<{ from: string; to: string }> {\n  if (!sectionBody) return [];\n  const pairs: Array<{ from: string; to: string }> = [];\n  const lines = normalizeLineEndings(sectionBody).split('\\n');\n  let current: { from?: string; to?: string } = {};\n  for (const line of lines) {\n    const fromMatch = line.match(/^\\s*-?\\s*FROM:\\s*`?###\\s*Requirement:\\s*(.+?)`?\\s*$/);\n    const toMatch = line.match(/^\\s*-?\\s*TO:\\s*`?###\\s*Requirement:\\s*(.+?)`?\\s*$/);\n    if (fromMatch) {\n      current.from = normalizeRequirementName(fromMatch[1]);\n    } else if (toMatch) {\n      current.to = normalizeRequirementName(toMatch[1]);\n      if (current.from && current.to) {\n        pairs.push({ from: current.from, to: current.to });\n        current = {};\n      }\n    }\n  }\n  return pairs;\n}\n"
  },
  {
    "path": "src/core/profile-sync-drift.ts",
    "content": "import path from 'path';\nimport * as fs from 'fs';\nimport { AI_TOOLS } from './config.js';\nimport type { Delivery } from './global-config.js';\nimport { ALL_WORKFLOWS } from './profiles.js';\nimport { CommandAdapterRegistry } from './command-generation/index.js';\nimport { COMMAND_IDS, getConfiguredTools } from './shared/index.js';\n\ntype WorkflowId = (typeof ALL_WORKFLOWS)[number];\n\n/**\n * Maps workflow IDs to their skill directory names.\n */\nexport const WORKFLOW_TO_SKILL_DIR: Record<WorkflowId, string> = {\n  'explore': 'openspec-explore',\n  'new': 'openspec-new-change',\n  'continue': 'openspec-continue-change',\n  'apply': 'openspec-apply-change',\n  'ff': 'openspec-ff-change',\n  'sync': 'openspec-sync-specs',\n  'archive': 'openspec-archive-change',\n  'bulk-archive': 'openspec-bulk-archive-change',\n  'verify': 'openspec-verify-change',\n  'onboard': 'openspec-onboard',\n  'propose': 'openspec-propose',\n};\n\nfunction toKnownWorkflows(workflows: readonly string[]): WorkflowId[] {\n  return workflows.filter(\n    (workflow): workflow is WorkflowId =>\n      (ALL_WORKFLOWS as readonly string[]).includes(workflow)\n  );\n}\n\n/**\n * Checks whether a tool has at least one generated OpenSpec command file.\n */\nexport function toolHasAnyConfiguredCommand(projectPath: string, toolId: string): boolean {\n  const adapter = CommandAdapterRegistry.get(toolId);\n  if (!adapter) return false;\n\n  for (const commandId of COMMAND_IDS) {\n    const cmdPath = adapter.getFilePath(commandId);\n    const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);\n    if (fs.existsSync(fullPath)) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\n/**\n * Returns tools with at least one generated command file on disk.\n */\nexport function getCommandConfiguredTools(projectPath: string): string[] {\n  return AI_TOOLS\n    .filter((tool) => {\n      if (!tool.skillsDir) return false;\n      const toolDir = path.join(projectPath, tool.skillsDir);\n      try {\n        return fs.statSync(toolDir).isDirectory();\n      } catch {\n        return false;\n      }\n    })\n    .map((tool) => tool.value)\n    .filter((toolId) => toolHasAnyConfiguredCommand(projectPath, toolId));\n}\n\n/**\n * Returns tools that are configured via either skills or commands.\n */\nexport function getConfiguredToolsForProfileSync(projectPath: string): string[] {\n  const skillConfigured = getConfiguredTools(projectPath);\n  const commandConfigured = getCommandConfiguredTools(projectPath);\n  return [...new Set([...skillConfigured, ...commandConfigured])];\n}\n\n/**\n * Detects if a single tool has profile/delivery drift against the desired state.\n *\n * This function covers:\n * - required artifacts missing for selected workflows\n * - artifacts that should not exist for the selected delivery mode\n * - artifacts for workflows that were deselected from the current profile\n */\nexport function hasToolProfileOrDeliveryDrift(\n  projectPath: string,\n  toolId: string,\n  desiredWorkflows: readonly string[],\n  delivery: Delivery\n): boolean {\n  const tool = AI_TOOLS.find((t) => t.value === toolId);\n  if (!tool?.skillsDir) return false;\n\n  const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows);\n  const desiredWorkflowSet = new Set<WorkflowId>(knownDesiredWorkflows);\n  const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');\n  const adapter = CommandAdapterRegistry.get(toolId);\n  const shouldGenerateSkills = delivery !== 'commands';\n  const shouldGenerateCommands = delivery !== 'skills';\n\n  if (shouldGenerateSkills) {\n    for (const workflow of knownDesiredWorkflows) {\n      const dirName = WORKFLOW_TO_SKILL_DIR[workflow];\n      const skillFile = path.join(skillsDir, dirName, 'SKILL.md');\n      if (!fs.existsSync(skillFile)) {\n        return true;\n      }\n    }\n\n    // Deselecting workflows in a profile should trigger sync.\n    for (const workflow of ALL_WORKFLOWS) {\n      if (desiredWorkflowSet.has(workflow)) continue;\n      const dirName = WORKFLOW_TO_SKILL_DIR[workflow];\n      const skillDir = path.join(skillsDir, dirName);\n      if (fs.existsSync(skillDir)) {\n        return true;\n      }\n    }\n  } else {\n    for (const workflow of ALL_WORKFLOWS) {\n      const dirName = WORKFLOW_TO_SKILL_DIR[workflow];\n      const skillDir = path.join(skillsDir, dirName);\n      if (fs.existsSync(skillDir)) {\n        return true;\n      }\n    }\n  }\n\n  if (shouldGenerateCommands && adapter) {\n    for (const workflow of knownDesiredWorkflows) {\n      const cmdPath = adapter.getFilePath(workflow);\n      const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);\n      if (!fs.existsSync(fullPath)) {\n        return true;\n      }\n    }\n\n    // Deselecting workflows in a profile should trigger sync.\n    for (const workflow of ALL_WORKFLOWS) {\n      if (desiredWorkflowSet.has(workflow)) continue;\n      const cmdPath = adapter.getFilePath(workflow);\n      const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);\n      if (fs.existsSync(fullPath)) {\n        return true;\n      }\n    }\n  } else if (!shouldGenerateCommands && adapter) {\n    for (const workflow of ALL_WORKFLOWS) {\n      const cmdPath = adapter.getFilePath(workflow);\n      const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);\n      if (fs.existsSync(fullPath)) {\n        return true;\n      }\n    }\n  }\n\n  return false;\n}\n\n/**\n * Returns configured tools that currently need a profile/delivery sync.\n */\nexport function getToolsNeedingProfileSync(\n  projectPath: string,\n  desiredWorkflows: readonly string[],\n  delivery: Delivery,\n  configuredTools?: readonly string[]\n): string[] {\n  const tools = configuredTools ? [...new Set(configuredTools)] : getConfiguredToolsForProfileSync(projectPath);\n  return tools.filter((toolId) =>\n    hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery)\n  );\n}\n\nfunction getInstalledWorkflowsForTool(\n  projectPath: string,\n  toolId: string,\n  options: { includeSkills: boolean; includeCommands: boolean }\n): WorkflowId[] {\n  const tool = AI_TOOLS.find((t) => t.value === toolId);\n  if (!tool?.skillsDir) return [];\n\n  const installed = new Set<WorkflowId>();\n  const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');\n\n  if (options.includeSkills) {\n    for (const workflow of ALL_WORKFLOWS) {\n      const dirName = WORKFLOW_TO_SKILL_DIR[workflow];\n      const skillFile = path.join(skillsDir, dirName, 'SKILL.md');\n      if (fs.existsSync(skillFile)) {\n        installed.add(workflow);\n      }\n    }\n  }\n\n  if (options.includeCommands) {\n    const adapter = CommandAdapterRegistry.get(toolId);\n    if (adapter) {\n      for (const workflow of ALL_WORKFLOWS) {\n        const cmdPath = adapter.getFilePath(workflow);\n        const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);\n        if (fs.existsSync(fullPath)) {\n          installed.add(workflow);\n        }\n      }\n    }\n  }\n\n  return [...installed];\n}\n\n/**\n * Detects whether the current project has any profile/delivery drift.\n */\nexport function hasProjectConfigDrift(\n  projectPath: string,\n  desiredWorkflows: readonly string[],\n  delivery: Delivery\n): boolean {\n  const configuredTools = getConfiguredToolsForProfileSync(projectPath);\n  if (getToolsNeedingProfileSync(projectPath, desiredWorkflows, delivery, configuredTools).length > 0) {\n    return true;\n  }\n\n  const desiredSet = new Set(toKnownWorkflows(desiredWorkflows));\n  const includeSkills = delivery !== 'commands';\n  const includeCommands = delivery !== 'skills';\n\n  for (const toolId of configuredTools) {\n    const installed = getInstalledWorkflowsForTool(projectPath, toolId, { includeSkills, includeCommands });\n    if (installed.some((workflow) => !desiredSet.has(workflow))) {\n      return true;\n    }\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "src/core/profiles.ts",
    "content": "/**\n * Profile System\n *\n * Defines workflow profiles that control which workflows are installed.\n * Profiles determine WHICH workflows; delivery (in global config) determines HOW.\n */\n\nimport type { Profile } from './global-config.js';\n\n/**\n * Core workflows included in the 'core' profile.\n * These provide the streamlined experience for new users.\n */\nexport const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'archive'] as const;\n\n/**\n * All available workflows in the system.\n */\nexport const ALL_WORKFLOWS = [\n  'propose',\n  'explore',\n  'new',\n  'continue',\n  'apply',\n  'ff',\n  'sync',\n  'archive',\n  'bulk-archive',\n  'verify',\n  'onboard',\n] as const;\n\nexport type WorkflowId = (typeof ALL_WORKFLOWS)[number];\nexport type CoreWorkflowId = (typeof CORE_WORKFLOWS)[number];\n\n/**\n * Resolves which workflows should be active for a given profile configuration.\n *\n * - 'core' profile always returns CORE_WORKFLOWS\n * - 'custom' profile returns the provided customWorkflows, or empty array if not provided\n */\nexport function getProfileWorkflows(\n  profile: Profile,\n  customWorkflows?: string[]\n): readonly string[] {\n  if (profile === 'custom') {\n    return customWorkflows ?? [];\n  }\n  return CORE_WORKFLOWS;\n}\n"
  },
  {
    "path": "src/core/project-config.ts",
    "content": "import { existsSync, readFileSync, statSync } from 'fs';\nimport path from 'path';\nimport { parse as parseYaml } from 'yaml';\nimport { z } from 'zod';\n\n/**\n * Zod schema for project configuration.\n *\n * Purpose:\n * 1. Documentation - clearly defines the config file structure\n * 2. Type safety - TypeScript infers ProjectConfig type from schema\n * 3. Runtime validation - uses safeParse() for resilient field-by-field validation\n *\n * Why Zod over manual validation:\n * - Helps understand OpenSpec's data interfaces at a glance\n * - Single source of truth for type and validation\n * - Consistent with other OpenSpec schemas\n */\nexport const ProjectConfigSchema = z.object({\n  // Required: which schema to use (e.g., \"spec-driven\", or project-local schema name)\n  schema: z\n    .string()\n    .min(1)\n    .describe('The workflow schema to use (e.g., \"spec-driven\")'),\n\n  // Optional: project context (injected into all artifact instructions)\n  // Max size: 50KB (enforced during parsing)\n  context: z\n    .string()\n    .optional()\n    .describe('Project context injected into all artifact instructions'),\n\n  // Optional: per-artifact rules (additive to schema's built-in guidance)\n  rules: z\n    .record(\n      z.string(), // artifact ID\n      z.array(z.string()) // list of rules\n    )\n    .optional()\n    .describe('Per-artifact rules, keyed by artifact ID'),\n});\n\nexport type ProjectConfig = z.infer<typeof ProjectConfigSchema>;\n\nconst MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit\n\n/**\n * Read and parse openspec/config.yaml from project root.\n * Uses resilient parsing - validates each field independently using Zod safeParse.\n * Returns null if file doesn't exist.\n * Returns partial config if some fields are invalid (with warnings).\n *\n * Performance note (Jan 2025):\n * Benchmarks showed direct file reads are fast enough without caching:\n * - Typical config (1KB): ~0.5ms per read\n * - Large config (50KB): ~1.6ms per read\n * - Missing config: ~0.01ms per read\n * Config is read 1-2 times per command (schema resolution + instruction loading),\n * adding ~1-3ms total overhead. Caching would add complexity (mtime checks,\n * invalidation logic) for negligible benefit. Direct reads also ensure config\n * changes are reflected immediately without stale cache issues.\n *\n * @param projectRoot - The root directory of the project (where `openspec/` lives)\n * @returns Parsed config or null if file doesn't exist\n */\nexport function readProjectConfig(projectRoot: string): ProjectConfig | null {\n  // Try both .yaml and .yml, prefer .yaml\n  let configPath = path.join(projectRoot, 'openspec', 'config.yaml');\n  if (!existsSync(configPath)) {\n    configPath = path.join(projectRoot, 'openspec', 'config.yml');\n    if (!existsSync(configPath)) {\n      return null; // No config is OK\n    }\n  }\n\n  try {\n    const content = readFileSync(configPath, 'utf-8');\n    const raw = parseYaml(content);\n\n    if (!raw || typeof raw !== 'object') {\n      console.warn(`openspec/config.yaml is not a valid YAML object`);\n      return null;\n    }\n\n    const config: Partial<ProjectConfig> = {};\n\n    // Parse schema field using Zod\n    const schemaField = z.string().min(1);\n    const schemaResult = schemaField.safeParse(raw.schema);\n    if (schemaResult.success) {\n      config.schema = schemaResult.data;\n    } else if (raw.schema !== undefined) {\n      console.warn(`Invalid 'schema' field in config (must be non-empty string)`);\n    }\n\n    // Parse context field with size limit\n    if (raw.context !== undefined) {\n      const contextField = z.string();\n      const contextResult = contextField.safeParse(raw.context);\n\n      if (contextResult.success) {\n        const contextSize = Buffer.byteLength(contextResult.data, 'utf-8');\n        if (contextSize > MAX_CONTEXT_SIZE) {\n          console.warn(\n            `Context too large (${(contextSize / 1024).toFixed(1)}KB, limit: ${MAX_CONTEXT_SIZE / 1024}KB)`\n          );\n          console.warn(`Ignoring context field`);\n        } else {\n          config.context = contextResult.data;\n        }\n      } else {\n        console.warn(`Invalid 'context' field in config (must be string)`);\n      }\n    }\n\n    // Parse rules field using Zod\n    if (raw.rules !== undefined) {\n      const rulesField = z.record(z.string(), z.array(z.string()));\n\n      // First check if it's an object structure (guard against null since typeof null === 'object')\n      if (typeof raw.rules === 'object' && raw.rules !== null && !Array.isArray(raw.rules)) {\n        const parsedRules: Record<string, string[]> = {};\n        let hasValidRules = false;\n\n        for (const [artifactId, rules] of Object.entries(raw.rules)) {\n          const rulesArrayResult = z.array(z.string()).safeParse(rules);\n\n          if (rulesArrayResult.success) {\n            // Filter out empty strings\n            const validRules = rulesArrayResult.data.filter((r) => r.length > 0);\n            if (validRules.length > 0) {\n              parsedRules[artifactId] = validRules;\n              hasValidRules = true;\n            }\n            if (validRules.length < rulesArrayResult.data.length) {\n              console.warn(\n                `Some rules for '${artifactId}' are empty strings, ignoring them`\n              );\n            }\n          } else {\n            console.warn(\n              `Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`\n            );\n          }\n        }\n\n        if (hasValidRules) {\n          config.rules = parsedRules;\n        }\n      } else {\n        console.warn(`Invalid 'rules' field in config (must be object)`);\n      }\n    }\n\n    // Return partial config even if some fields failed\n    return Object.keys(config).length > 0 ? (config as ProjectConfig) : null;\n  } catch (error) {\n    console.warn(`Failed to parse openspec/config.yaml:`, error);\n    return null;\n  }\n}\n\n/**\n * Validate artifact IDs in rules against a schema's artifacts.\n * Called during instruction loading (when schema is known).\n * Returns warnings for unknown artifact IDs.\n *\n * @param rules - The rules object from config\n * @param validArtifactIds - Set of valid artifact IDs from the schema\n * @param schemaName - Name of the schema for error messages\n * @returns Array of warning messages for unknown artifact IDs\n */\nexport function validateConfigRules(\n  rules: Record<string, string[]>,\n  validArtifactIds: Set<string>,\n  schemaName: string\n): string[] {\n  const warnings: string[] = [];\n\n  for (const artifactId of Object.keys(rules)) {\n    if (!validArtifactIds.has(artifactId)) {\n      const validIds = Array.from(validArtifactIds).sort().join(', ');\n      warnings.push(\n        `Unknown artifact ID in rules: \"${artifactId}\". ` +\n          `Valid IDs for schema \"${schemaName}\": ${validIds}`\n      );\n    }\n  }\n\n  return warnings;\n}\n\n/**\n * Suggest valid schema names when user provides invalid schema.\n * Uses fuzzy matching to find similar names.\n *\n * @param invalidSchemaName - The invalid schema name from config\n * @param availableSchemas - List of available schemas with their type (built-in or project-local)\n * @returns Error message with suggestions and available schemas\n */\nexport function suggestSchemas(\n  invalidSchemaName: string,\n  availableSchemas: { name: string; isBuiltIn: boolean }[]\n): string {\n  // Simple fuzzy match: Levenshtein distance\n  function levenshtein(a: string, b: string): number {\n    const matrix: number[][] = [];\n    for (let i = 0; i <= b.length; i++) {\n      matrix[i] = [i];\n    }\n    for (let j = 0; j <= a.length; j++) {\n      matrix[0][j] = j;\n    }\n    for (let i = 1; i <= b.length; i++) {\n      for (let j = 1; j <= a.length; j++) {\n        if (b.charAt(i - 1) === a.charAt(j - 1)) {\n          matrix[i][j] = matrix[i - 1][j - 1];\n        } else {\n          matrix[i][j] = Math.min(\n            matrix[i - 1][j - 1] + 1,\n            matrix[i][j - 1] + 1,\n            matrix[i - 1][j] + 1\n          );\n        }\n      }\n    }\n    return matrix[b.length][a.length];\n  }\n\n  // Find closest matches (distance <= 3)\n  const suggestions = availableSchemas\n    .map((s) => ({ ...s, distance: levenshtein(invalidSchemaName, s.name) }))\n    .filter((s) => s.distance <= 3)\n    .sort((a, b) => a.distance - b.distance)\n    .slice(0, 3);\n\n  const builtIn = availableSchemas.filter((s) => s.isBuiltIn).map((s) => s.name);\n  const projectLocal = availableSchemas.filter((s) => !s.isBuiltIn).map((s) => s.name);\n\n  let message = `Schema '${invalidSchemaName}' not found in openspec/config.yaml\\n\\n`;\n\n  if (suggestions.length > 0) {\n    message += `Did you mean one of these?\\n`;\n    suggestions.forEach((s) => {\n      const type = s.isBuiltIn ? 'built-in' : 'project-local';\n      message += `  - ${s.name} (${type})\\n`;\n    });\n    message += '\\n';\n  }\n\n  message += `Available schemas:\\n`;\n  if (builtIn.length > 0) {\n    message += `  Built-in: ${builtIn.join(', ')}\\n`;\n  }\n  if (projectLocal.length > 0) {\n    message += `  Project-local: ${projectLocal.join(', ')}\\n`;\n  } else {\n    message += `  Project-local: (none found)\\n`;\n  }\n\n  message += `\\nFix: Edit openspec/config.yaml and change 'schema: ${invalidSchemaName}' to a valid schema name`;\n\n  return message;\n}\n"
  },
  {
    "path": "src/core/schemas/base.schema.ts",
    "content": "import { z } from 'zod';\nimport { VALIDATION_MESSAGES } from '../validation/constants.js';\n\nexport const ScenarioSchema = z.object({\n  rawText: z.string().min(1, VALIDATION_MESSAGES.SCENARIO_EMPTY),\n});\n\nexport const RequirementSchema = z.object({\n  text: z.string()\n    .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY)\n    .refine(\n      (text) => text.includes('SHALL') || text.includes('MUST'),\n      VALIDATION_MESSAGES.REQUIREMENT_NO_SHALL\n    ),\n  scenarios: z.array(ScenarioSchema)\n    .min(1, VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS),\n});\n\nexport type Scenario = z.infer<typeof ScenarioSchema>;\nexport type Requirement = z.infer<typeof RequirementSchema>;"
  },
  {
    "path": "src/core/schemas/change.schema.ts",
    "content": "import { z } from 'zod';\nimport { RequirementSchema } from './base.schema.js';\nimport { \n  MIN_WHY_SECTION_LENGTH,\n  MAX_WHY_SECTION_LENGTH,\n  MAX_DELTAS_PER_CHANGE,\n  VALIDATION_MESSAGES \n} from '../validation/constants.js';\n\nexport const DeltaOperationType = z.enum(['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED']);\n\nexport const DeltaSchema = z.object({\n  spec: z.string().min(1, VALIDATION_MESSAGES.DELTA_SPEC_EMPTY),\n  operation: DeltaOperationType,\n  description: z.string().min(1, VALIDATION_MESSAGES.DELTA_DESCRIPTION_EMPTY),\n  requirement: RequirementSchema.optional(),\n  requirements: z.array(RequirementSchema).optional(),\n  rename: z.object({\n    from: z.string(),\n    to: z.string(),\n  }).optional(),\n});\n\nexport const ChangeSchema = z.object({\n  name: z.string().min(1, VALIDATION_MESSAGES.CHANGE_NAME_EMPTY),\n  why: z.string()\n    .min(MIN_WHY_SECTION_LENGTH, VALIDATION_MESSAGES.CHANGE_WHY_TOO_SHORT)\n    .max(MAX_WHY_SECTION_LENGTH, VALIDATION_MESSAGES.CHANGE_WHY_TOO_LONG),\n  whatChanges: z.string().min(1, VALIDATION_MESSAGES.CHANGE_WHAT_EMPTY),\n  deltas: z.array(DeltaSchema)\n    .min(1, VALIDATION_MESSAGES.CHANGE_NO_DELTAS)\n    .max(MAX_DELTAS_PER_CHANGE, VALIDATION_MESSAGES.CHANGE_TOO_MANY_DELTAS),\n  metadata: z.object({\n    version: z.string().default('1.0.0'),\n    format: z.literal('openspec-change'),\n    sourcePath: z.string().optional(),\n  }).optional(),\n});\n\nexport type DeltaOperation = z.infer<typeof DeltaOperationType>;\nexport type Delta = z.infer<typeof DeltaSchema>;\nexport type Change = z.infer<typeof ChangeSchema>;"
  },
  {
    "path": "src/core/schemas/index.ts",
    "content": "export {\n  ScenarioSchema,\n  RequirementSchema,\n  type Scenario,\n  type Requirement,\n} from './base.schema.js';\n\nexport {\n  SpecSchema,\n  type Spec,\n} from './spec.schema.js';\n\nexport {\n  DeltaOperationType,\n  DeltaSchema,\n  ChangeSchema,\n  type DeltaOperation,\n  type Delta,\n  type Change,\n} from './change.schema.js';"
  },
  {
    "path": "src/core/schemas/spec.schema.ts",
    "content": "import { z } from 'zod';\nimport { RequirementSchema } from './base.schema.js';\nimport { VALIDATION_MESSAGES } from '../validation/constants.js';\n\nexport const SpecSchema = z.object({\n  name: z.string().min(1, VALIDATION_MESSAGES.SPEC_NAME_EMPTY),\n  overview: z.string().min(1, VALIDATION_MESSAGES.SPEC_PURPOSE_EMPTY),\n  requirements: z.array(RequirementSchema)\n    .min(1, VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS),\n  metadata: z.object({\n    version: z.string().default('1.0.0'),\n    format: z.literal('openspec'),\n    sourcePath: z.string().optional(),\n  }).optional(),\n});\n\nexport type Spec = z.infer<typeof SpecSchema>;"
  },
  {
    "path": "src/core/shared/index.ts",
    "content": "/**\n * Shared Utilities\n *\n * Common code shared between init and update commands.\n */\n\nexport {\n  SKILL_NAMES,\n  type SkillName,\n  COMMAND_IDS,\n  type CommandId,\n  type ToolSkillStatus,\n  type ToolVersionStatus,\n  getToolsWithSkillsDir,\n  getToolSkillStatus,\n  getToolStates,\n  extractGeneratedByVersion,\n  getToolVersionStatus,\n  getConfiguredTools,\n  getAllToolVersionStatus,\n} from './tool-detection.js';\n\nexport {\n  type SkillTemplateEntry,\n  type CommandTemplateEntry,\n  getSkillTemplates,\n  getCommandTemplates,\n  getCommandContents,\n  generateSkillContent,\n} from './skill-generation.js';\n"
  },
  {
    "path": "src/core/shared/skill-generation.ts",
    "content": "/**\n * Skill Generation Utilities\n *\n * Shared utilities for generating skill and command files.\n */\n\nimport {\n  getExploreSkillTemplate,\n  getNewChangeSkillTemplate,\n  getContinueChangeSkillTemplate,\n  getApplyChangeSkillTemplate,\n  getFfChangeSkillTemplate,\n  getSyncSpecsSkillTemplate,\n  getArchiveChangeSkillTemplate,\n  getBulkArchiveChangeSkillTemplate,\n  getVerifyChangeSkillTemplate,\n  getOnboardSkillTemplate,\n  getOpsxProposeSkillTemplate,\n  getOpsxExploreCommandTemplate,\n  getOpsxNewCommandTemplate,\n  getOpsxContinueCommandTemplate,\n  getOpsxApplyCommandTemplate,\n  getOpsxFfCommandTemplate,\n  getOpsxSyncCommandTemplate,\n  getOpsxArchiveCommandTemplate,\n  getOpsxBulkArchiveCommandTemplate,\n  getOpsxVerifyCommandTemplate,\n  getOpsxOnboardCommandTemplate,\n  getOpsxProposeCommandTemplate,\n  type SkillTemplate,\n} from '../templates/skill-templates.js';\nimport type { CommandContent } from '../command-generation/index.js';\n\n/**\n * Skill template with directory name and workflow ID mapping.\n */\nexport interface SkillTemplateEntry {\n  template: SkillTemplate;\n  dirName: string;\n  workflowId: string;\n}\n\n/**\n * Command template with ID mapping.\n */\nexport interface CommandTemplateEntry {\n  template: ReturnType<typeof getOpsxExploreCommandTemplate>;\n  id: string;\n}\n\n/**\n * Gets skill templates with their directory names, optionally filtered by workflow IDs.\n *\n * @param workflowFilter - If provided, only return templates whose workflowId is in this array\n */\nexport function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemplateEntry[] {\n  const all: SkillTemplateEntry[] = [\n    { template: getExploreSkillTemplate(), dirName: 'openspec-explore', workflowId: 'explore' },\n    { template: getNewChangeSkillTemplate(), dirName: 'openspec-new-change', workflowId: 'new' },\n    { template: getContinueChangeSkillTemplate(), dirName: 'openspec-continue-change', workflowId: 'continue' },\n    { template: getApplyChangeSkillTemplate(), dirName: 'openspec-apply-change', workflowId: 'apply' },\n    { template: getFfChangeSkillTemplate(), dirName: 'openspec-ff-change', workflowId: 'ff' },\n    { template: getSyncSpecsSkillTemplate(), dirName: 'openspec-sync-specs', workflowId: 'sync' },\n    { template: getArchiveChangeSkillTemplate(), dirName: 'openspec-archive-change', workflowId: 'archive' },\n    { template: getBulkArchiveChangeSkillTemplate(), dirName: 'openspec-bulk-archive-change', workflowId: 'bulk-archive' },\n    { template: getVerifyChangeSkillTemplate(), dirName: 'openspec-verify-change', workflowId: 'verify' },\n    { template: getOnboardSkillTemplate(), dirName: 'openspec-onboard', workflowId: 'onboard' },\n    { template: getOpsxProposeSkillTemplate(), dirName: 'openspec-propose', workflowId: 'propose' },\n  ];\n\n  if (!workflowFilter) return all;\n\n  const filterSet = new Set(workflowFilter);\n  return all.filter(entry => filterSet.has(entry.workflowId));\n}\n\n/**\n * Gets command templates with their IDs, optionally filtered by workflow IDs.\n *\n * @param workflowFilter - If provided, only return templates whose id is in this array\n */\nexport function getCommandTemplates(workflowFilter?: readonly string[]): CommandTemplateEntry[] {\n  const all: CommandTemplateEntry[] = [\n    { template: getOpsxExploreCommandTemplate(), id: 'explore' },\n    { template: getOpsxNewCommandTemplate(), id: 'new' },\n    { template: getOpsxContinueCommandTemplate(), id: 'continue' },\n    { template: getOpsxApplyCommandTemplate(), id: 'apply' },\n    { template: getOpsxFfCommandTemplate(), id: 'ff' },\n    { template: getOpsxSyncCommandTemplate(), id: 'sync' },\n    { template: getOpsxArchiveCommandTemplate(), id: 'archive' },\n    { template: getOpsxBulkArchiveCommandTemplate(), id: 'bulk-archive' },\n    { template: getOpsxVerifyCommandTemplate(), id: 'verify' },\n    { template: getOpsxOnboardCommandTemplate(), id: 'onboard' },\n    { template: getOpsxProposeCommandTemplate(), id: 'propose' },\n  ];\n\n  if (!workflowFilter) return all;\n\n  const filterSet = new Set(workflowFilter);\n  return all.filter(entry => filterSet.has(entry.id));\n}\n\n/**\n * Converts command templates to CommandContent array, optionally filtered by workflow IDs.\n *\n * @param workflowFilter - If provided, only return contents whose id is in this array\n */\nexport function getCommandContents(workflowFilter?: readonly string[]): CommandContent[] {\n  const commandTemplates = getCommandTemplates(workflowFilter);\n  return commandTemplates.map(({ template, id }) => ({\n    id,\n    name: template.name,\n    description: template.description,\n    category: template.category,\n    tags: template.tags,\n    body: template.content,\n  }));\n}\n\n/**\n * Generates skill file content with YAML frontmatter.\n *\n * @param template - The skill template\n * @param generatedByVersion - The OpenSpec version to embed in the file\n * @param transformInstructions - Optional callback to transform the instructions content\n */\nexport function generateSkillContent(\n  template: SkillTemplate,\n  generatedByVersion: string,\n  transformInstructions?: (instructions: string) => string\n): string {\n  const instructions = transformInstructions\n    ? transformInstructions(template.instructions)\n    : template.instructions;\n\n  return `---\nname: ${template.name}\ndescription: ${template.description}\nlicense: ${template.license || 'MIT'}\ncompatibility: ${template.compatibility || 'Requires openspec CLI.'}\nmetadata:\n  author: ${template.metadata?.author || 'openspec'}\n  version: \"${template.metadata?.version || '1.0'}\"\n  generatedBy: \"${generatedByVersion}\"\n---\n\n${instructions}\n`;\n}\n"
  },
  {
    "path": "src/core/shared/tool-detection.ts",
    "content": "/**\n * Tool Detection Utilities\n *\n * Shared utilities for detecting tool configurations and version status.\n */\n\nimport path from 'path';\nimport * as fs from 'fs';\nimport { AI_TOOLS } from '../config.js';\n\n/**\n * Names of skill directories created by openspec init.\n */\nexport const SKILL_NAMES = [\n  'openspec-explore',\n  'openspec-new-change',\n  'openspec-continue-change',\n  'openspec-apply-change',\n  'openspec-ff-change',\n  'openspec-sync-specs',\n  'openspec-archive-change',\n  'openspec-bulk-archive-change',\n  'openspec-verify-change',\n  'openspec-onboard',\n  'openspec-propose',\n] as const;\n\nexport type SkillName = (typeof SKILL_NAMES)[number];\n\n/**\n * IDs of command templates created by openspec init.\n */\nexport const COMMAND_IDS = [\n  'explore',\n  'new',\n  'continue',\n  'apply',\n  'ff',\n  'sync',\n  'archive',\n  'bulk-archive',\n  'verify',\n  'onboard',\n  'propose',\n] as const;\n\nexport type CommandId = (typeof COMMAND_IDS)[number];\n\n/**\n * Status of skill configuration for a tool.\n */\nexport interface ToolSkillStatus {\n  /** Whether the tool has any skills configured */\n  configured: boolean;\n  /** Whether all skills are configured */\n  fullyConfigured: boolean;\n  /** Number of skills currently configured */\n  skillCount: number;\n}\n\n/**\n * Version information for a tool's skills.\n */\nexport interface ToolVersionStatus {\n  /** The tool ID */\n  toolId: string;\n  /** The tool's display name */\n  toolName: string;\n  /** Whether the tool has any skills configured */\n  configured: boolean;\n  /** The generatedBy version found in the skill files, or null if not found */\n  generatedByVersion: string | null;\n  /** Whether the tool needs updating (version mismatch or missing) */\n  needsUpdate: boolean;\n}\n\n/**\n * Gets the list of tools with skillsDir configured.\n */\nexport function getToolsWithSkillsDir(): string[] {\n  return AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value);\n}\n\n/**\n * Checks which skill files exist for a tool.\n */\nexport function getToolSkillStatus(projectRoot: string, toolId: string): ToolSkillStatus {\n  const tool = AI_TOOLS.find((t) => t.value === toolId);\n  if (!tool?.skillsDir) {\n    return { configured: false, fullyConfigured: false, skillCount: 0 };\n  }\n\n  const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills');\n  let skillCount = 0;\n\n  for (const skillName of SKILL_NAMES) {\n    const skillFile = path.join(skillsDir, skillName, 'SKILL.md');\n    if (fs.existsSync(skillFile)) {\n      skillCount++;\n    }\n  }\n\n  return {\n    configured: skillCount > 0,\n    fullyConfigured: skillCount === SKILL_NAMES.length,\n    skillCount,\n  };\n}\n\n/**\n * Gets the skill status for all tools with skillsDir configured.\n */\nexport function getToolStates(projectRoot: string): Map<string, ToolSkillStatus> {\n  const states = new Map<string, ToolSkillStatus>();\n  const toolIds = AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value);\n\n  for (const toolId of toolIds) {\n    states.set(toolId, getToolSkillStatus(projectRoot, toolId));\n  }\n\n  return states;\n}\n\n/**\n * Extracts the generatedBy version from a skill file's YAML frontmatter.\n * Returns null if the field is not found or the file doesn't exist.\n */\nexport function extractGeneratedByVersion(skillFilePath: string): string | null {\n  try {\n    if (!fs.existsSync(skillFilePath)) {\n      return null;\n    }\n\n    const content = fs.readFileSync(skillFilePath, 'utf-8');\n\n    // Look for generatedBy in the YAML frontmatter\n    // The file format is:\n    // ---\n    // ...\n    // metadata:\n    //   author: openspec\n    //   version: \"1.0\"\n    //   generatedBy: \"0.23.0\"\n    // ---\n    const generatedByMatch = content.match(/^\\s*generatedBy:\\s*[\"']?([^\"'\\n]+)[\"']?\\s*$/m);\n\n    if (generatedByMatch && generatedByMatch[1]) {\n      return generatedByMatch[1].trim();\n    }\n\n    return null;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Gets version status for a tool by reading the first available skill file.\n */\nexport function getToolVersionStatus(\n  projectRoot: string,\n  toolId: string,\n  currentVersion: string\n): ToolVersionStatus {\n  const tool = AI_TOOLS.find((t) => t.value === toolId);\n  if (!tool?.skillsDir) {\n    return {\n      toolId,\n      toolName: toolId,\n      configured: false,\n      generatedByVersion: null,\n      needsUpdate: false,\n    };\n  }\n\n  const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills');\n  let generatedByVersion: string | null = null;\n\n  // Find the first skill file that exists and read its version\n  for (const skillName of SKILL_NAMES) {\n    const skillFile = path.join(skillsDir, skillName, 'SKILL.md');\n    if (fs.existsSync(skillFile)) {\n      generatedByVersion = extractGeneratedByVersion(skillFile);\n      break;\n    }\n  }\n\n  const configured = getToolSkillStatus(projectRoot, toolId).configured;\n  const needsUpdate = configured && (generatedByVersion === null || generatedByVersion !== currentVersion);\n\n  return {\n    toolId,\n    toolName: tool.name,\n    configured,\n    generatedByVersion,\n    needsUpdate,\n  };\n}\n\n/**\n * Gets all configured tools in the project.\n */\nexport function getConfiguredTools(projectRoot: string): string[] {\n  return AI_TOOLS\n    .filter((t) => t.skillsDir && getToolSkillStatus(projectRoot, t.value).configured)\n    .map((t) => t.value);\n}\n\n/**\n * Gets version status for all configured tools.\n */\nexport function getAllToolVersionStatus(\n  projectRoot: string,\n  currentVersion: string\n): ToolVersionStatus[] {\n  const configuredTools = getConfiguredTools(projectRoot);\n  return configuredTools.map((toolId) =>\n    getToolVersionStatus(projectRoot, toolId, currentVersion)\n  );\n}\n"
  },
  {
    "path": "src/core/specs-apply.ts",
    "content": "/**\n * Spec Application Logic\n *\n * Extracted from ArchiveCommand to enable standalone spec application.\n * Applies delta specs from a change to main specs without archiving.\n */\n\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport chalk from 'chalk';\nimport {\n  extractRequirementsSection,\n  parseDeltaSpec,\n  normalizeRequirementName,\n  type RequirementBlock,\n} from './parsers/requirement-blocks.js';\nimport { Validator } from './validation/validator.js';\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\nexport interface SpecUpdate {\n  source: string;\n  target: string;\n  exists: boolean;\n}\n\nexport interface ApplyResult {\n  capability: string;\n  added: number;\n  modified: number;\n  removed: number;\n  renamed: number;\n}\n\nexport interface SpecsApplyOutput {\n  changeName: string;\n  capabilities: ApplyResult[];\n  totals: {\n    added: number;\n    modified: number;\n    removed: number;\n    renamed: number;\n  };\n  noChanges: boolean;\n}\n\n// -----------------------------------------------------------------------------\n// Public API\n// -----------------------------------------------------------------------------\n\n/**\n * Find all delta spec files that need to be applied from a change.\n */\nexport async function findSpecUpdates(changeDir: string, mainSpecsDir: string): Promise<SpecUpdate[]> {\n  const updates: SpecUpdate[] = [];\n  const changeSpecsDir = path.join(changeDir, 'specs');\n\n  try {\n    const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });\n\n    for (const entry of entries) {\n      if (entry.isDirectory()) {\n        const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');\n        const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md');\n\n        try {\n          await fs.access(specFile);\n\n          // Check if target exists\n          let exists = false;\n          try {\n            await fs.access(targetFile);\n            exists = true;\n          } catch {\n            exists = false;\n          }\n\n          updates.push({\n            source: specFile,\n            target: targetFile,\n            exists,\n          });\n        } catch {\n          // Source spec doesn't exist, skip\n        }\n      }\n    }\n  } catch {\n    // No specs directory in change\n  }\n\n  return updates;\n}\n\n/**\n * Build an updated spec by applying delta operations.\n * Returns the rebuilt content and counts of operations.\n */\nexport async function buildUpdatedSpec(\n  update: SpecUpdate,\n  changeName: string\n): Promise<{ rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> {\n  // Read change spec content (delta-format expected)\n  const changeContent = await fs.readFile(update.source, 'utf-8');\n\n  // Parse deltas from the change spec file\n  const plan = parseDeltaSpec(changeContent);\n  const specName = path.basename(path.dirname(update.target));\n\n  // Pre-validate duplicates within sections\n  const addedNames = new Set<string>();\n  for (const add of plan.added) {\n    const name = normalizeRequirementName(add.name);\n    if (addedNames.has(name)) {\n      throw new Error(\n        `${specName} validation failed - duplicate requirement in ADDED for header \"### Requirement: ${add.name}\"`\n      );\n    }\n    addedNames.add(name);\n  }\n  const modifiedNames = new Set<string>();\n  for (const mod of plan.modified) {\n    const name = normalizeRequirementName(mod.name);\n    if (modifiedNames.has(name)) {\n      throw new Error(\n        `${specName} validation failed - duplicate requirement in MODIFIED for header \"### Requirement: ${mod.name}\"`\n      );\n    }\n    modifiedNames.add(name);\n  }\n  const removedNamesSet = new Set<string>();\n  for (const rem of plan.removed) {\n    const name = normalizeRequirementName(rem);\n    if (removedNamesSet.has(name)) {\n      throw new Error(\n        `${specName} validation failed - duplicate requirement in REMOVED for header \"### Requirement: ${rem}\"`\n      );\n    }\n    removedNamesSet.add(name);\n  }\n  const renamedFromSet = new Set<string>();\n  const renamedToSet = new Set<string>();\n  for (const { from, to } of plan.renamed) {\n    const fromNorm = normalizeRequirementName(from);\n    const toNorm = normalizeRequirementName(to);\n    if (renamedFromSet.has(fromNorm)) {\n      throw new Error(\n        `${specName} validation failed - duplicate FROM in RENAMED for header \"### Requirement: ${from}\"`\n      );\n    }\n    if (renamedToSet.has(toNorm)) {\n      throw new Error(\n        `${specName} validation failed - duplicate TO in RENAMED for header \"### Requirement: ${to}\"`\n      );\n    }\n    renamedFromSet.add(fromNorm);\n    renamedToSet.add(toNorm);\n  }\n\n  // Pre-validate cross-section conflicts\n  const conflicts: Array<{ name: string; a: string; b: string }> = [];\n  for (const n of modifiedNames) {\n    if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'REMOVED' });\n    if (addedNames.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'ADDED' });\n  }\n  for (const n of addedNames) {\n    if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'ADDED', b: 'REMOVED' });\n  }\n  // Renamed interplay: MODIFIED must reference the NEW header, not FROM\n  for (const { from, to } of plan.renamed) {\n    const fromNorm = normalizeRequirementName(from);\n    const toNorm = normalizeRequirementName(to);\n    if (modifiedNames.has(fromNorm)) {\n      throw new Error(\n        `${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header \"### Requirement: ${to}\"`\n      );\n    }\n    // Detect ADDED colliding with a RENAMED TO\n    if (addedNames.has(toNorm)) {\n      throw new Error(\n        `${specName} validation failed - RENAMED TO header collides with ADDED for \"### Requirement: ${to}\"`\n      );\n    }\n  }\n  if (conflicts.length > 0) {\n    const c = conflicts[0];\n    throw new Error(\n      `${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header \"### Requirement: ${c.name}\"`\n    );\n  }\n  const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;\n  if (!hasAnyDelta) {\n    throw new Error(\n      `Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +\n        `Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`\n    );\n  }\n\n  // Load or create base target content\n  let targetContent: string;\n  let isNewSpec = false;\n  try {\n    targetContent = await fs.readFile(update.target, 'utf-8');\n  } catch {\n    // Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs\n    // REMOVED will be ignored with a warning since there's nothing to remove\n    if (plan.modified.length > 0 || plan.renamed.length > 0) {\n      throw new Error(\n        `${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`\n      );\n    }\n    // Warn about REMOVED requirements being ignored for new specs\n    if (plan.removed.length > 0) {\n      console.log(\n        chalk.yellow(\n          `⚠️  Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`\n        )\n      );\n    }\n    isNewSpec = true;\n    targetContent = buildSpecSkeleton(specName, changeName);\n  }\n\n  // Extract requirements section and build name->block map\n  const parts = extractRequirementsSection(targetContent);\n  const nameToBlock = new Map<string, RequirementBlock>();\n  for (const block of parts.bodyBlocks) {\n    nameToBlock.set(normalizeRequirementName(block.name), block);\n  }\n\n  // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED\n  // RENAMED\n  for (const r of plan.renamed) {\n    const from = normalizeRequirementName(r.from);\n    const to = normalizeRequirementName(r.to);\n    if (!nameToBlock.has(from)) {\n      throw new Error(`${specName} RENAMED failed for header \"### Requirement: ${r.from}\" - source not found`);\n    }\n    if (nameToBlock.has(to)) {\n      throw new Error(`${specName} RENAMED failed for header \"### Requirement: ${r.to}\" - target already exists`);\n    }\n    const block = nameToBlock.get(from)!;\n    const newHeader = `### Requirement: ${to}`;\n    const rawLines = block.raw.split('\\n');\n    rawLines[0] = newHeader;\n    const renamedBlock: RequirementBlock = {\n      headerLine: newHeader,\n      name: to,\n      raw: rawLines.join('\\n'),\n    };\n    nameToBlock.delete(from);\n    nameToBlock.set(to, renamedBlock);\n  }\n\n  // REMOVED\n  for (const name of plan.removed) {\n    const key = normalizeRequirementName(name);\n    if (!nameToBlock.has(key)) {\n      // For new specs, REMOVED requirements are already warned about and ignored\n      // For existing specs, missing requirements are an error\n      if (!isNewSpec) {\n        throw new Error(`${specName} REMOVED failed for header \"### Requirement: ${name}\" - not found`);\n      }\n      // Skip removal for new specs (already warned above)\n      continue;\n    }\n    nameToBlock.delete(key);\n  }\n\n  // MODIFIED\n  for (const mod of plan.modified) {\n    const key = normalizeRequirementName(mod.name);\n    if (!nameToBlock.has(key)) {\n      throw new Error(`${specName} MODIFIED failed for header \"### Requirement: ${mod.name}\" - not found`);\n    }\n    // Replace block with provided raw (ensure header line matches key)\n    const modHeaderMatch = mod.raw.split('\\n')[0].match(/^###\\s*Requirement:\\s*(.+)\\s*$/);\n    if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {\n      throw new Error(\n        `${specName} MODIFIED failed for header \"### Requirement: ${mod.name}\" - header mismatch in content`\n      );\n    }\n    nameToBlock.set(key, mod);\n  }\n\n  // ADDED\n  for (const add of plan.added) {\n    const key = normalizeRequirementName(add.name);\n    if (nameToBlock.has(key)) {\n      throw new Error(`${specName} ADDED failed for header \"### Requirement: ${add.name}\" - already exists`);\n    }\n    nameToBlock.set(key, add);\n  }\n\n  // Duplicates within resulting map are implicitly prevented by key uniqueness.\n\n  // Recompose requirements section preserving original ordering where possible\n  const keptOrder: RequirementBlock[] = [];\n  const seen = new Set<string>();\n  for (const block of parts.bodyBlocks) {\n    const key = normalizeRequirementName(block.name);\n    const replacement = nameToBlock.get(key);\n    if (replacement) {\n      keptOrder.push(replacement);\n      seen.add(key);\n    }\n  }\n  // Append any newly added that were not in original order\n  for (const [key, block] of nameToBlock.entries()) {\n    if (!seen.has(key)) {\n      keptOrder.push(block);\n    }\n  }\n\n  const reqBody = [parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : '']\n    .filter(Boolean)\n    .concat(keptOrder.map((b) => b.raw))\n    .join('\\n\\n')\n    .trimEnd();\n\n  const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after]\n    .filter((s, idx) => !(idx === 0 && s === ''))\n    .join('\\n')\n    .replace(/\\n{3,}/g, '\\n\\n');\n\n  return {\n    rebuilt,\n    counts: {\n      added: plan.added.length,\n      modified: plan.modified.length,\n      removed: plan.removed.length,\n      renamed: plan.renamed.length,\n    },\n  };\n}\n\n/**\n * Write an updated spec to disk.\n */\nexport async function writeUpdatedSpec(\n  update: SpecUpdate,\n  rebuilt: string,\n  counts: { added: number; modified: number; removed: number; renamed: number }\n): Promise<void> {\n  // Create target directory if needed\n  const targetDir = path.dirname(update.target);\n  await fs.mkdir(targetDir, { recursive: true });\n  await fs.writeFile(update.target, rebuilt);\n\n  const specName = path.basename(path.dirname(update.target));\n  console.log(`Applying changes to openspec/specs/${specName}/spec.md:`);\n  if (counts.added) console.log(`  + ${counts.added} added`);\n  if (counts.modified) console.log(`  ~ ${counts.modified} modified`);\n  if (counts.removed) console.log(`  - ${counts.removed} removed`);\n  if (counts.renamed) console.log(`  → ${counts.renamed} renamed`);\n}\n\n/**\n * Build a skeleton spec for new capabilities.\n */\nexport function buildSpecSkeleton(specFolderName: string, changeName: string): string {\n  const titleBase = specFolderName;\n  return `# ${titleBase} Specification\\n\\n## Purpose\\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\\n\\n## Requirements\\n`;\n}\n\n/**\n * Apply all delta specs from a change to main specs.\n *\n * @param projectRoot - The project root directory\n * @param changeName - The name of the change to apply\n * @param options - Options for the operation\n * @returns Result of the operation with counts\n */\nexport async function applySpecs(\n  projectRoot: string,\n  changeName: string,\n  options: {\n    dryRun?: boolean;\n    skipValidation?: boolean;\n    silent?: boolean;\n  } = {}\n): Promise<SpecsApplyOutput> {\n  const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);\n  const mainSpecsDir = path.join(projectRoot, 'openspec', 'specs');\n\n  // Verify change exists\n  try {\n    const stat = await fs.stat(changeDir);\n    if (!stat.isDirectory()) {\n      throw new Error(`Change '${changeName}' not found.`);\n    }\n  } catch {\n    throw new Error(`Change '${changeName}' not found.`);\n  }\n\n  // Find specs to update\n  const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);\n\n  if (specUpdates.length === 0) {\n    return {\n      changeName,\n      capabilities: [],\n      totals: { added: 0, modified: 0, removed: 0, renamed: 0 },\n      noChanges: true,\n    };\n  }\n\n  // Prepare all updates first (validation pass, no writes)\n  const prepared: Array<{\n    update: SpecUpdate;\n    rebuilt: string;\n    counts: { added: number; modified: number; removed: number; renamed: number };\n  }> = [];\n\n  for (const update of specUpdates) {\n    const built = await buildUpdatedSpec(update, changeName);\n    prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });\n  }\n\n  // Validate rebuilt specs unless validation is skipped\n  if (!options.skipValidation) {\n    const validator = new Validator();\n    for (const p of prepared) {\n      const specName = path.basename(path.dirname(p.update.target));\n      const report = await validator.validateSpecContent(specName, p.rebuilt);\n      if (!report.valid) {\n        const errors = report.issues\n          .filter((i) => i.level === 'ERROR')\n          .map((i) => `  ✗ ${i.message}`)\n          .join('\\n');\n        throw new Error(`Validation errors in rebuilt spec for ${specName}:\\n${errors}`);\n      }\n    }\n  }\n\n  // Build results\n  const capabilities: ApplyResult[] = [];\n  const totals = { added: 0, modified: 0, removed: 0, renamed: 0 };\n\n  for (const p of prepared) {\n    const capability = path.basename(path.dirname(p.update.target));\n\n    if (!options.dryRun) {\n      // Write the updated spec\n      const targetDir = path.dirname(p.update.target);\n      await fs.mkdir(targetDir, { recursive: true });\n      await fs.writeFile(p.update.target, p.rebuilt);\n\n      if (!options.silent) {\n        console.log(`Applying changes to openspec/specs/${capability}/spec.md:`);\n        if (p.counts.added) console.log(`  + ${p.counts.added} added`);\n        if (p.counts.modified) console.log(`  ~ ${p.counts.modified} modified`);\n        if (p.counts.removed) console.log(`  - ${p.counts.removed} removed`);\n        if (p.counts.renamed) console.log(`  → ${p.counts.renamed} renamed`);\n      }\n    } else if (!options.silent) {\n      console.log(`Would apply changes to openspec/specs/${capability}/spec.md:`);\n      if (p.counts.added) console.log(`  + ${p.counts.added} added`);\n      if (p.counts.modified) console.log(`  ~ ${p.counts.modified} modified`);\n      if (p.counts.removed) console.log(`  - ${p.counts.removed} removed`);\n      if (p.counts.renamed) console.log(`  → ${p.counts.renamed} renamed`);\n    }\n\n    capabilities.push({\n      capability,\n      ...p.counts,\n    });\n\n    totals.added += p.counts.added;\n    totals.modified += p.counts.modified;\n    totals.removed += p.counts.removed;\n    totals.renamed += p.counts.renamed;\n  }\n\n  return {\n    changeName,\n    capabilities,\n    totals,\n    noChanges: false,\n  };\n}\n"
  },
  {
    "path": "src/core/styles/palette.ts",
    "content": "import chalk from 'chalk';\n\nexport const PALETTE = {\n  white: chalk.hex('#f4f4f4'),\n  lightGray: chalk.hex('#c8c8c8'),\n  midGray: chalk.hex('#8a8a8a'),\n  darkGray: chalk.hex('#4a4a4a')\n};\n"
  },
  {
    "path": "src/core/templates/index.ts",
    "content": "/**\n * Template exports for OpenSpec.\n *\n * The old config file templates (AGENTS.md, project.md, claude-template, etc.)\n * have been removed. The skill-based workflow uses skill-templates.ts directly.\n */\n\n// Re-export all skill templates and related types through the compatibility facade.\nexport * from './skill-templates.js';\n"
  },
  {
    "path": "src/core/templates/skill-templates.ts",
    "content": "/**\n * Agent Skill Templates\n *\n * Compatibility facade that re-exports split workflow template modules.\n */\n\nexport type { SkillTemplate, CommandTemplate } from './types.js';\n\nexport { getExploreSkillTemplate, getOpsxExploreCommandTemplate } from './workflows/explore.js';\nexport { getNewChangeSkillTemplate, getOpsxNewCommandTemplate } from './workflows/new-change.js';\nexport { getContinueChangeSkillTemplate, getOpsxContinueCommandTemplate } from './workflows/continue-change.js';\nexport { getApplyChangeSkillTemplate, getOpsxApplyCommandTemplate } from './workflows/apply-change.js';\nexport { getFfChangeSkillTemplate, getOpsxFfCommandTemplate } from './workflows/ff-change.js';\nexport { getSyncSpecsSkillTemplate, getOpsxSyncCommandTemplate } from './workflows/sync-specs.js';\nexport { getArchiveChangeSkillTemplate, getOpsxArchiveCommandTemplate } from './workflows/archive-change.js';\nexport { getBulkArchiveChangeSkillTemplate, getOpsxBulkArchiveCommandTemplate } from './workflows/bulk-archive-change.js';\nexport { getVerifyChangeSkillTemplate, getOpsxVerifyCommandTemplate } from './workflows/verify-change.js';\nexport { getOnboardSkillTemplate, getOpsxOnboardCommandTemplate } from './workflows/onboard.js';\nexport { getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate } from './workflows/propose.js';\nexport { getFeedbackSkillTemplate } from './workflows/feedback.js';\n"
  },
  {
    "path": "src/core/templates/types.ts",
    "content": "/**\n * Core template types for skills and slash commands.\n */\n\nexport interface SkillTemplate {\n  name: string;\n  description: string;\n  instructions: string;\n  license?: string;\n  compatibility?: string;\n  metadata?: Record<string, string>;\n}\n\nexport interface CommandTemplate {\n  name: string;\n  description: string;\n  category: string;\n  tags: string[];\n  content: string;\n}\n"
  },
  {
    "path": "src/core/templates/workflows/apply-change.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate, CommandTemplate } from '../types.js';\n\nexport function getApplyChangeSkillTemplate(): SkillTemplate {\n  return {\n    name: 'openspec-apply-change',\n    description: 'Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.',\n    instructions: `Implement tasks from an OpenSpec change.\n\n**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.\n\n**Steps**\n\n1. **Select the change**\n\n   If a name is provided, use it. Otherwise:\n   - Infer from conversation context if the user mentioned a change\n   - Auto-select if only one active change exists\n   - If ambiguous, run \\`openspec list --json\\` to get available changes and use the **AskUserQuestion tool** to let the user select\n\n   Always announce: \"Using change: <name>\" and how to override (e.g., \\`/opsx:apply <other>\\`).\n\n2. **Check status to understand the schema**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\" --json\n   \\`\\`\\`\n   Parse the JSON to understand:\n   - \\`schemaName\\`: The workflow being used (e.g., \"spec-driven\")\n   - Which artifact contains the tasks (typically \"tasks\" for spec-driven, check status for others)\n\n3. **Get apply instructions**\n\n   \\`\\`\\`bash\n   openspec instructions apply --change \"<name>\" --json\n   \\`\\`\\`\n\n   This returns:\n   - Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)\n   - Progress (total, complete, remaining)\n   - Task list with status\n   - Dynamic instruction based on current state\n\n   **Handle states:**\n   - If \\`state: \"blocked\"\\` (missing artifacts): show message, suggest using openspec-continue-change\n   - If \\`state: \"all_done\"\\`: congratulate, suggest archive\n   - Otherwise: proceed to implementation\n\n4. **Read context files**\n\n   Read the files listed in \\`contextFiles\\` from the apply instructions output.\n   The files depend on the schema being used:\n   - **spec-driven**: proposal, specs, design, tasks\n   - Other schemas: follow the contextFiles from CLI output\n\n5. **Show current progress**\n\n   Display:\n   - Schema being used\n   - Progress: \"N/M tasks complete\"\n   - Remaining tasks overview\n   - Dynamic instruction from CLI\n\n6. **Implement tasks (loop until done or blocked)**\n\n   For each pending task:\n   - Show which task is being worked on\n   - Make the code changes required\n   - Keep changes minimal and focused\n   - Mark task complete in the tasks file: \\`- [ ]\\` → \\`- [x]\\`\n   - Continue to next task\n\n   **Pause if:**\n   - Task is unclear → ask for clarification\n   - Implementation reveals a design issue → suggest updating artifacts\n   - Error or blocker encountered → report and wait for guidance\n   - User interrupts\n\n7. **On completion or pause, show status**\n\n   Display:\n   - Tasks completed this session\n   - Overall progress: \"N/M tasks complete\"\n   - If all done: suggest archive\n   - If paused: explain why and wait for guidance\n\n**Output During Implementation**\n\n\\`\\`\\`\n## Implementing: <change-name> (schema: <schema-name>)\n\nWorking on task 3/7: <task description>\n[...implementation happening...]\n✓ Task complete\n\nWorking on task 4/7: <task description>\n[...implementation happening...]\n✓ Task complete\n\\`\\`\\`\n\n**Output On Completion**\n\n\\`\\`\\`\n## Implementation Complete\n\n**Change:** <change-name>\n**Schema:** <schema-name>\n**Progress:** 7/7 tasks complete ✓\n\n### Completed This Session\n- [x] Task 1\n- [x] Task 2\n...\n\nAll tasks complete! Ready to archive this change.\n\\`\\`\\`\n\n**Output On Pause (Issue Encountered)**\n\n\\`\\`\\`\n## Implementation Paused\n\n**Change:** <change-name>\n**Schema:** <schema-name>\n**Progress:** 4/7 tasks complete\n\n### Issue Encountered\n<description of the issue>\n\n**Options:**\n1. <option 1>\n2. <option 2>\n3. Other approach\n\nWhat would you like to do?\n\\`\\`\\`\n\n**Guardrails**\n- Keep going through tasks until done or blocked\n- Always read context files before starting (from the apply instructions output)\n- If task is ambiguous, pause and ask before implementing\n- If implementation reveals issues, pause and suggest artifact updates\n- Keep code changes minimal and scoped to each task\n- Update task checkbox immediately after completing each task\n- Pause on errors, blockers, or unclear requirements - don't guess\n- Use contextFiles from CLI output, don't assume specific file names\n\n**Fluid Workflow Integration**\n\nThis skill supports the \"actions on a change\" model:\n\n- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions\n- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly`,\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n\nexport function getOpsxApplyCommandTemplate(): CommandTemplate {\n  return {\n    name: 'OPSX: Apply',\n    description: 'Implement tasks from an OpenSpec change (Experimental)',\n    category: 'Workflow',\n    tags: ['workflow', 'artifacts', 'experimental'],\n    content: `Implement tasks from an OpenSpec change.\n\n**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.\n\n**Steps**\n\n1. **Select the change**\n\n   If a name is provided, use it. Otherwise:\n   - Infer from conversation context if the user mentioned a change\n   - Auto-select if only one active change exists\n   - If ambiguous, run \\`openspec list --json\\` to get available changes and use the **AskUserQuestion tool** to let the user select\n\n   Always announce: \"Using change: <name>\" and how to override (e.g., \\`/opsx:apply <other>\\`).\n\n2. **Check status to understand the schema**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\" --json\n   \\`\\`\\`\n   Parse the JSON to understand:\n   - \\`schemaName\\`: The workflow being used (e.g., \"spec-driven\")\n   - Which artifact contains the tasks (typically \"tasks\" for spec-driven, check status for others)\n\n3. **Get apply instructions**\n\n   \\`\\`\\`bash\n   openspec instructions apply --change \"<name>\" --json\n   \\`\\`\\`\n\n   This returns:\n   - Context file paths (varies by schema)\n   - Progress (total, complete, remaining)\n   - Task list with status\n   - Dynamic instruction based on current state\n\n   **Handle states:**\n   - If \\`state: \"blocked\"\\` (missing artifacts): show message, suggest using \\`/opsx:continue\\`\n   - If \\`state: \"all_done\"\\`: congratulate, suggest archive\n   - Otherwise: proceed to implementation\n\n4. **Read context files**\n\n   Read the files listed in \\`contextFiles\\` from the apply instructions output.\n   The files depend on the schema being used:\n   - **spec-driven**: proposal, specs, design, tasks\n   - Other schemas: follow the contextFiles from CLI output\n\n5. **Show current progress**\n\n   Display:\n   - Schema being used\n   - Progress: \"N/M tasks complete\"\n   - Remaining tasks overview\n   - Dynamic instruction from CLI\n\n6. **Implement tasks (loop until done or blocked)**\n\n   For each pending task:\n   - Show which task is being worked on\n   - Make the code changes required\n   - Keep changes minimal and focused\n   - Mark task complete in the tasks file: \\`- [ ]\\` → \\`- [x]\\`\n   - Continue to next task\n\n   **Pause if:**\n   - Task is unclear → ask for clarification\n   - Implementation reveals a design issue → suggest updating artifacts\n   - Error or blocker encountered → report and wait for guidance\n   - User interrupts\n\n7. **On completion or pause, show status**\n\n   Display:\n   - Tasks completed this session\n   - Overall progress: \"N/M tasks complete\"\n   - If all done: suggest archive\n   - If paused: explain why and wait for guidance\n\n**Output During Implementation**\n\n\\`\\`\\`\n## Implementing: <change-name> (schema: <schema-name>)\n\nWorking on task 3/7: <task description>\n[...implementation happening...]\n✓ Task complete\n\nWorking on task 4/7: <task description>\n[...implementation happening...]\n✓ Task complete\n\\`\\`\\`\n\n**Output On Completion**\n\n\\`\\`\\`\n## Implementation Complete\n\n**Change:** <change-name>\n**Schema:** <schema-name>\n**Progress:** 7/7 tasks complete ✓\n\n### Completed This Session\n- [x] Task 1\n- [x] Task 2\n...\n\nAll tasks complete! You can archive this change with \\`/opsx:archive\\`.\n\\`\\`\\`\n\n**Output On Pause (Issue Encountered)**\n\n\\`\\`\\`\n## Implementation Paused\n\n**Change:** <change-name>\n**Schema:** <schema-name>\n**Progress:** 4/7 tasks complete\n\n### Issue Encountered\n<description of the issue>\n\n**Options:**\n1. <option 1>\n2. <option 2>\n3. Other approach\n\nWhat would you like to do?\n\\`\\`\\`\n\n**Guardrails**\n- Keep going through tasks until done or blocked\n- Always read context files before starting (from the apply instructions output)\n- If task is ambiguous, pause and ask before implementing\n- If implementation reveals issues, pause and suggest artifact updates\n- Keep code changes minimal and scoped to each task\n- Update task checkbox immediately after completing each task\n- Pause on errors, blockers, or unclear requirements - don't guess\n- Use contextFiles from CLI output, don't assume specific file names\n\n**Fluid Workflow Integration**\n\nThis skill supports the \"actions on a change\" model:\n\n- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions\n- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly`\n  };\n}\n"
  },
  {
    "path": "src/core/templates/workflows/archive-change.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate, CommandTemplate } from '../types.js';\n\nexport function getArchiveChangeSkillTemplate(): SkillTemplate {\n  return {\n    name: 'openspec-archive-change',\n    description: 'Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.',\n    instructions: `Archive a completed change in the experimental workflow.\n\n**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.\n\n**Steps**\n\n1. **If no change name provided, prompt for selection**\n\n   Run \\`openspec list --json\\` to get available changes. Use the **AskUserQuestion tool** to let the user select.\n\n   Show only active changes (not already archived).\n   Include the schema used for each change if available.\n\n   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.\n\n2. **Check artifact completion status**\n\n   Run \\`openspec status --change \"<name>\" --json\\` to check artifact completion.\n\n   Parse the JSON to understand:\n   - \\`schemaName\\`: The workflow being used\n   - \\`artifacts\\`: List of artifacts with their status (\\`done\\` or other)\n\n   **If any artifacts are not \\`done\\`:**\n   - Display warning listing incomplete artifacts\n   - Use **AskUserQuestion tool** to confirm user wants to proceed\n   - Proceed if user confirms\n\n3. **Check task completion status**\n\n   Read the tasks file (typically \\`tasks.md\\`) to check for incomplete tasks.\n\n   Count tasks marked with \\`- [ ]\\` (incomplete) vs \\`- [x]\\` (complete).\n\n   **If incomplete tasks found:**\n   - Display warning showing count of incomplete tasks\n   - Use **AskUserQuestion tool** to confirm user wants to proceed\n   - Proceed if user confirms\n\n   **If no tasks file exists:** Proceed without task-related warning.\n\n4. **Assess delta spec sync state**\n\n   Check for delta specs at \\`openspec/changes/<name>/specs/\\`. If none exist, proceed without sync prompt.\n\n   **If delta specs exist:**\n   - Compare each delta spec with its corresponding main spec at \\`openspec/specs/<capability>/spec.md\\`\n   - Determine what changes would be applied (adds, modifications, removals, renames)\n   - Show a combined summary before prompting\n\n   **Prompt options:**\n   - If changes needed: \"Sync now (recommended)\", \"Archive without syncing\"\n   - If already synced: \"Archive now\", \"Sync anyway\", \"Cancel\"\n\n   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.\n\n5. **Perform the archive**\n\n   Create the archive directory if it doesn't exist:\n   \\`\\`\\`bash\n   mkdir -p openspec/changes/archive\n   \\`\\`\\`\n\n   Generate target name using current date: \\`YYYY-MM-DD-<change-name>\\`\n\n   **Check if target already exists:**\n   - If yes: Fail with error, suggest renaming existing archive or using different date\n   - If no: Move the change directory to archive\n\n   \\`\\`\\`bash\n   mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>\n   \\`\\`\\`\n\n6. **Display summary**\n\n   Show archive completion summary including:\n   - Change name\n   - Schema that was used\n   - Archive location\n   - Whether specs were synced (if applicable)\n   - Note about any warnings (incomplete artifacts/tasks)\n\n**Output On Success**\n\n\\`\\`\\`\n## Archive Complete\n\n**Change:** <change-name>\n**Schema:** <schema-name>\n**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/\n**Specs:** ✓ Synced to main specs (or \"No delta specs\" or \"Sync skipped\")\n\nAll artifacts complete. All tasks complete.\n\\`\\`\\`\n\n**Guardrails**\n- Always prompt for change selection if not provided\n- Use artifact graph (openspec status --json) for completion checking\n- Don't block archive on warnings - just inform and confirm\n- Preserve .openspec.yaml when moving to archive (it moves with the directory)\n- Show clear summary of what happened\n- If sync is requested, use openspec-sync-specs approach (agent-driven)\n- If delta specs exist, always run the sync assessment and show the combined summary before prompting`,\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n\nexport function getOpsxArchiveCommandTemplate(): CommandTemplate {\n  return {\n    name: 'OPSX: Archive',\n    description: 'Archive a completed change in the experimental workflow',\n    category: 'Workflow',\n    tags: ['workflow', 'archive', 'experimental'],\n    content: `Archive a completed change in the experimental workflow.\n\n**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.\n\n**Steps**\n\n1. **If no change name provided, prompt for selection**\n\n   Run \\`openspec list --json\\` to get available changes. Use the **AskUserQuestion tool** to let the user select.\n\n   Show only active changes (not already archived).\n   Include the schema used for each change if available.\n\n   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.\n\n2. **Check artifact completion status**\n\n   Run \\`openspec status --change \"<name>\" --json\\` to check artifact completion.\n\n   Parse the JSON to understand:\n   - \\`schemaName\\`: The workflow being used\n   - \\`artifacts\\`: List of artifacts with their status (\\`done\\` or other)\n\n   **If any artifacts are not \\`done\\`:**\n   - Display warning listing incomplete artifacts\n   - Prompt user for confirmation to continue\n   - Proceed if user confirms\n\n3. **Check task completion status**\n\n   Read the tasks file (typically \\`tasks.md\\`) to check for incomplete tasks.\n\n   Count tasks marked with \\`- [ ]\\` (incomplete) vs \\`- [x]\\` (complete).\n\n   **If incomplete tasks found:**\n   - Display warning showing count of incomplete tasks\n   - Prompt user for confirmation to continue\n   - Proceed if user confirms\n\n   **If no tasks file exists:** Proceed without task-related warning.\n\n4. **Assess delta spec sync state**\n\n   Check for delta specs at \\`openspec/changes/<name>/specs/\\`. If none exist, proceed without sync prompt.\n\n   **If delta specs exist:**\n   - Compare each delta spec with its corresponding main spec at \\`openspec/specs/<capability>/spec.md\\`\n   - Determine what changes would be applied (adds, modifications, removals, renames)\n   - Show a combined summary before prompting\n\n   **Prompt options:**\n   - If changes needed: \"Sync now (recommended)\", \"Archive without syncing\"\n   - If already synced: \"Archive now\", \"Sync anyway\", \"Cancel\"\n\n   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.\n\n5. **Perform the archive**\n\n   Create the archive directory if it doesn't exist:\n   \\`\\`\\`bash\n   mkdir -p openspec/changes/archive\n   \\`\\`\\`\n\n   Generate target name using current date: \\`YYYY-MM-DD-<change-name>\\`\n\n   **Check if target already exists:**\n   - If yes: Fail with error, suggest renaming existing archive or using different date\n   - If no: Move the change directory to archive\n\n   \\`\\`\\`bash\n   mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>\n   \\`\\`\\`\n\n6. **Display summary**\n\n   Show archive completion summary including:\n   - Change name\n   - Schema that was used\n   - Archive location\n   - Spec sync status (synced / sync skipped / no delta specs)\n   - Note about any warnings (incomplete artifacts/tasks)\n\n**Output On Success**\n\n\\`\\`\\`\n## Archive Complete\n\n**Change:** <change-name>\n**Schema:** <schema-name>\n**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/\n**Specs:** ✓ Synced to main specs\n\nAll artifacts complete. All tasks complete.\n\\`\\`\\`\n\n**Output On Success (No Delta Specs)**\n\n\\`\\`\\`\n## Archive Complete\n\n**Change:** <change-name>\n**Schema:** <schema-name>\n**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/\n**Specs:** No delta specs\n\nAll artifacts complete. All tasks complete.\n\\`\\`\\`\n\n**Output On Success With Warnings**\n\n\\`\\`\\`\n## Archive Complete (with warnings)\n\n**Change:** <change-name>\n**Schema:** <schema-name>\n**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/\n**Specs:** Sync skipped (user chose to skip)\n\n**Warnings:**\n- Archived with 2 incomplete artifacts\n- Archived with 3 incomplete tasks\n- Delta spec sync was skipped (user chose to skip)\n\nReview the archive if this was not intentional.\n\\`\\`\\`\n\n**Output On Error (Archive Exists)**\n\n\\`\\`\\`\n## Archive Failed\n\n**Change:** <change-name>\n**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/\n\nTarget archive directory already exists.\n\n**Options:**\n1. Rename the existing archive\n2. Delete the existing archive if it's a duplicate\n3. Wait until a different date to archive\n\\`\\`\\`\n\n**Guardrails**\n- Always prompt for change selection if not provided\n- Use artifact graph (openspec status --json) for completion checking\n- Don't block archive on warnings - just inform and confirm\n- Preserve .openspec.yaml when moving to archive (it moves with the directory)\n- Show clear summary of what happened\n- If sync is requested, use the Skill tool to invoke \\`openspec-sync-specs\\` (agent-driven)\n- If delta specs exist, always run the sync assessment and show the combined summary before prompting`\n  };\n}\n"
  },
  {
    "path": "src/core/templates/workflows/bulk-archive-change.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate, CommandTemplate } from '../types.js';\n\nexport function getBulkArchiveChangeSkillTemplate(): SkillTemplate {\n  return {\n    name: 'openspec-bulk-archive-change',\n    description: 'Archive multiple completed changes at once. Use when archiving several parallel changes.',\n    instructions: `Archive multiple completed changes in a single operation.\n\nThis skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.\n\n**Input**: None required (prompts for selection)\n\n**Steps**\n\n1. **Get active changes**\n\n   Run \\`openspec list --json\\` to get all active changes.\n\n   If no active changes exist, inform user and stop.\n\n2. **Prompt for change selection**\n\n   Use **AskUserQuestion tool** with multi-select to let user choose changes:\n   - Show each change with its schema\n   - Include an option for \"All changes\"\n   - Allow any number of selections (1+ works, 2+ is the typical use case)\n\n   **IMPORTANT**: Do NOT auto-select. Always let the user choose.\n\n3. **Batch validation - gather status for all selected changes**\n\n   For each selected change, collect:\n\n   a. **Artifact status** - Run \\`openspec status --change \"<name>\" --json\\`\n      - Parse \\`schemaName\\` and \\`artifacts\\` list\n      - Note which artifacts are \\`done\\` vs other states\n\n   b. **Task completion** - Read \\`openspec/changes/<name>/tasks.md\\`\n      - Count \\`- [ ]\\` (incomplete) vs \\`- [x]\\` (complete)\n      - If no tasks file exists, note as \"No tasks\"\n\n   c. **Delta specs** - Check \\`openspec/changes/<name>/specs/\\` directory\n      - List which capability specs exist\n      - For each, extract requirement names (lines matching \\`### Requirement: <name>\\`)\n\n4. **Detect spec conflicts**\n\n   Build a map of \\`capability -> [changes that touch it]\\`:\n\n   \\`\\`\\`\n   auth -> [change-a, change-b]  <- CONFLICT (2+ changes)\n   api  -> [change-c]            <- OK (only 1 change)\n   \\`\\`\\`\n\n   A conflict exists when 2+ selected changes have delta specs for the same capability.\n\n5. **Resolve conflicts agentically**\n\n   **For each conflict**, investigate the codebase:\n\n   a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify\n\n   b. **Search the codebase** for implementation evidence:\n      - Look for code implementing requirements from each delta spec\n      - Check for related files, functions, or tests\n\n   c. **Determine resolution**:\n      - If only one change is actually implemented -> sync that one's specs\n      - If both implemented -> apply in chronological order (older first, newer overwrites)\n      - If neither implemented -> skip spec sync, warn user\n\n   d. **Record resolution** for each conflict:\n      - Which change's specs to apply\n      - In what order (if both)\n      - Rationale (what was found in codebase)\n\n6. **Show consolidated status table**\n\n   Display a table summarizing all changes:\n\n   \\`\\`\\`\n   | Change               | Artifacts | Tasks | Specs   | Conflicts | Status |\n   |---------------------|-----------|-------|---------|-----------|--------|\n   | schema-management   | Done      | 5/5   | 2 delta | None      | Ready  |\n   | project-config      | Done      | 3/3   | 1 delta | None      | Ready  |\n   | add-oauth           | Done      | 4/4   | 1 delta | auth (!)  | Ready* |\n   | add-verify-skill    | 1 left    | 2/5   | None    | None      | Warn   |\n   \\`\\`\\`\n\n   For conflicts, show the resolution:\n   \\`\\`\\`\n   * Conflict resolution:\n     - auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)\n   \\`\\`\\`\n\n   For incomplete changes, show warnings:\n   \\`\\`\\`\n   Warnings:\n   - add-verify-skill: 1 incomplete artifact, 3 incomplete tasks\n   \\`\\`\\`\n\n7. **Confirm batch operation**\n\n   Use **AskUserQuestion tool** with a single confirmation:\n\n   - \"Archive N changes?\" with options based on status\n   - Options might include:\n     - \"Archive all N changes\"\n     - \"Archive only N ready changes (skip incomplete)\"\n     - \"Cancel\"\n\n   If there are incomplete changes, make clear they'll be archived with warnings.\n\n8. **Execute archive for each confirmed change**\n\n   Process changes in the determined order (respecting conflict resolution):\n\n   a. **Sync specs** if delta specs exist:\n      - Use the openspec-sync-specs approach (agent-driven intelligent merge)\n      - For conflicts, apply in resolved order\n      - Track if sync was done\n\n   b. **Perform the archive**:\n      \\`\\`\\`bash\n      mkdir -p openspec/changes/archive\n      mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>\n      \\`\\`\\`\n\n   c. **Track outcome** for each change:\n      - Success: archived successfully\n      - Failed: error during archive (record error)\n      - Skipped: user chose not to archive (if applicable)\n\n9. **Display summary**\n\n   Show final results:\n\n   \\`\\`\\`\n   ## Bulk Archive Complete\n\n   Archived 3 changes:\n   - schema-management-cli -> archive/2026-01-19-schema-management-cli/\n   - project-config -> archive/2026-01-19-project-config/\n   - add-oauth -> archive/2026-01-19-add-oauth/\n\n   Skipped 1 change:\n   - add-verify-skill (user chose not to archive incomplete)\n\n   Spec sync summary:\n   - 4 delta specs synced to main specs\n   - 1 conflict resolved (auth: applied both in chronological order)\n   \\`\\`\\`\n\n   If any failures:\n   \\`\\`\\`\n   Failed 1 change:\n   - some-change: Archive directory already exists\n   \\`\\`\\`\n\n**Conflict Resolution Examples**\n\nExample 1: Only one implemented\n\\`\\`\\`\nConflict: specs/auth/spec.md touched by [add-oauth, add-jwt]\n\nChecking add-oauth:\n- Delta adds \"OAuth Provider Integration\" requirement\n- Searching codebase... found src/auth/oauth.ts implementing OAuth flow\n\nChecking add-jwt:\n- Delta adds \"JWT Token Handling\" requirement\n- Searching codebase... no JWT implementation found\n\nResolution: Only add-oauth is implemented. Will sync add-oauth specs only.\n\\`\\`\\`\n\nExample 2: Both implemented\n\\`\\`\\`\nConflict: specs/api/spec.md touched by [add-rest-api, add-graphql]\n\nChecking add-rest-api (created 2026-01-10):\n- Delta adds \"REST Endpoints\" requirement\n- Searching codebase... found src/api/rest.ts\n\nChecking add-graphql (created 2026-01-15):\n- Delta adds \"GraphQL Schema\" requirement\n- Searching codebase... found src/api/graphql.ts\n\nResolution: Both implemented. Will apply add-rest-api specs first,\nthen add-graphql specs (chronological order, newer takes precedence).\n\\`\\`\\`\n\n**Output On Success**\n\n\\`\\`\\`\n## Bulk Archive Complete\n\nArchived N changes:\n- <change-1> -> archive/YYYY-MM-DD-<change-1>/\n- <change-2> -> archive/YYYY-MM-DD-<change-2>/\n\nSpec sync summary:\n- N delta specs synced to main specs\n- No conflicts (or: M conflicts resolved)\n\\`\\`\\`\n\n**Output On Partial Success**\n\n\\`\\`\\`\n## Bulk Archive Complete (partial)\n\nArchived N changes:\n- <change-1> -> archive/YYYY-MM-DD-<change-1>/\n\nSkipped M changes:\n- <change-2> (user chose not to archive incomplete)\n\nFailed K changes:\n- <change-3>: Archive directory already exists\n\\`\\`\\`\n\n**Output When No Changes**\n\n\\`\\`\\`\n## No Changes to Archive\n\nNo active changes found. Create a new change to get started.\n\\`\\`\\`\n\n**Guardrails**\n- Allow any number of changes (1+ is fine, 2+ is the typical use case)\n- Always prompt for selection, never auto-select\n- Detect spec conflicts early and resolve by checking codebase\n- When both changes are implemented, apply specs in chronological order\n- Skip spec sync only when implementation is missing (warn user)\n- Show clear per-change status before confirming\n- Use single confirmation for entire batch\n- Track and report all outcomes (success/skip/fail)\n- Preserve .openspec.yaml when moving to archive\n- Archive directory target uses current date: YYYY-MM-DD-<name>\n- If archive target exists, fail that change but continue with others`,\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n\nexport function getOpsxBulkArchiveCommandTemplate(): CommandTemplate {\n  return {\n    name: 'OPSX: Bulk Archive',\n    description: 'Archive multiple completed changes at once',\n    category: 'Workflow',\n    tags: ['workflow', 'archive', 'experimental', 'bulk'],\n    content: `Archive multiple completed changes in a single operation.\n\nThis skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.\n\n**Input**: None required (prompts for selection)\n\n**Steps**\n\n1. **Get active changes**\n\n   Run \\`openspec list --json\\` to get all active changes.\n\n   If no active changes exist, inform user and stop.\n\n2. **Prompt for change selection**\n\n   Use **AskUserQuestion tool** with multi-select to let user choose changes:\n   - Show each change with its schema\n   - Include an option for \"All changes\"\n   - Allow any number of selections (1+ works, 2+ is the typical use case)\n\n   **IMPORTANT**: Do NOT auto-select. Always let the user choose.\n\n3. **Batch validation - gather status for all selected changes**\n\n   For each selected change, collect:\n\n   a. **Artifact status** - Run \\`openspec status --change \"<name>\" --json\\`\n      - Parse \\`schemaName\\` and \\`artifacts\\` list\n      - Note which artifacts are \\`done\\` vs other states\n\n   b. **Task completion** - Read \\`openspec/changes/<name>/tasks.md\\`\n      - Count \\`- [ ]\\` (incomplete) vs \\`- [x]\\` (complete)\n      - If no tasks file exists, note as \"No tasks\"\n\n   c. **Delta specs** - Check \\`openspec/changes/<name>/specs/\\` directory\n      - List which capability specs exist\n      - For each, extract requirement names (lines matching \\`### Requirement: <name>\\`)\n\n4. **Detect spec conflicts**\n\n   Build a map of \\`capability -> [changes that touch it]\\`:\n\n   \\`\\`\\`\n   auth -> [change-a, change-b]  <- CONFLICT (2+ changes)\n   api  -> [change-c]            <- OK (only 1 change)\n   \\`\\`\\`\n\n   A conflict exists when 2+ selected changes have delta specs for the same capability.\n\n5. **Resolve conflicts agentically**\n\n   **For each conflict**, investigate the codebase:\n\n   a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify\n\n   b. **Search the codebase** for implementation evidence:\n      - Look for code implementing requirements from each delta spec\n      - Check for related files, functions, or tests\n\n   c. **Determine resolution**:\n      - If only one change is actually implemented -> sync that one's specs\n      - If both implemented -> apply in chronological order (older first, newer overwrites)\n      - If neither implemented -> skip spec sync, warn user\n\n   d. **Record resolution** for each conflict:\n      - Which change's specs to apply\n      - In what order (if both)\n      - Rationale (what was found in codebase)\n\n6. **Show consolidated status table**\n\n   Display a table summarizing all changes:\n\n   \\`\\`\\`\n   | Change               | Artifacts | Tasks | Specs   | Conflicts | Status |\n   |---------------------|-----------|-------|---------|-----------|--------|\n   | schema-management   | Done      | 5/5   | 2 delta | None      | Ready  |\n   | project-config      | Done      | 3/3   | 1 delta | None      | Ready  |\n   | add-oauth           | Done      | 4/4   | 1 delta | auth (!)  | Ready* |\n   | add-verify-skill    | 1 left    | 2/5   | None    | None      | Warn   |\n   \\`\\`\\`\n\n   For conflicts, show the resolution:\n   \\`\\`\\`\n   * Conflict resolution:\n     - auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)\n   \\`\\`\\`\n\n   For incomplete changes, show warnings:\n   \\`\\`\\`\n   Warnings:\n   - add-verify-skill: 1 incomplete artifact, 3 incomplete tasks\n   \\`\\`\\`\n\n7. **Confirm batch operation**\n\n   Use **AskUserQuestion tool** with a single confirmation:\n\n   - \"Archive N changes?\" with options based on status\n   - Options might include:\n     - \"Archive all N changes\"\n     - \"Archive only N ready changes (skip incomplete)\"\n     - \"Cancel\"\n\n   If there are incomplete changes, make clear they'll be archived with warnings.\n\n8. **Execute archive for each confirmed change**\n\n   Process changes in the determined order (respecting conflict resolution):\n\n   a. **Sync specs** if delta specs exist:\n      - Use the openspec-sync-specs approach (agent-driven intelligent merge)\n      - For conflicts, apply in resolved order\n      - Track if sync was done\n\n   b. **Perform the archive**:\n      \\`\\`\\`bash\n      mkdir -p openspec/changes/archive\n      mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>\n      \\`\\`\\`\n\n   c. **Track outcome** for each change:\n      - Success: archived successfully\n      - Failed: error during archive (record error)\n      - Skipped: user chose not to archive (if applicable)\n\n9. **Display summary**\n\n   Show final results:\n\n   \\`\\`\\`\n   ## Bulk Archive Complete\n\n   Archived 3 changes:\n   - schema-management-cli -> archive/2026-01-19-schema-management-cli/\n   - project-config -> archive/2026-01-19-project-config/\n   - add-oauth -> archive/2026-01-19-add-oauth/\n\n   Skipped 1 change:\n   - add-verify-skill (user chose not to archive incomplete)\n\n   Spec sync summary:\n   - 4 delta specs synced to main specs\n   - 1 conflict resolved (auth: applied both in chronological order)\n   \\`\\`\\`\n\n   If any failures:\n   \\`\\`\\`\n   Failed 1 change:\n   - some-change: Archive directory already exists\n   \\`\\`\\`\n\n**Conflict Resolution Examples**\n\nExample 1: Only one implemented\n\\`\\`\\`\nConflict: specs/auth/spec.md touched by [add-oauth, add-jwt]\n\nChecking add-oauth:\n- Delta adds \"OAuth Provider Integration\" requirement\n- Searching codebase... found src/auth/oauth.ts implementing OAuth flow\n\nChecking add-jwt:\n- Delta adds \"JWT Token Handling\" requirement\n- Searching codebase... no JWT implementation found\n\nResolution: Only add-oauth is implemented. Will sync add-oauth specs only.\n\\`\\`\\`\n\nExample 2: Both implemented\n\\`\\`\\`\nConflict: specs/api/spec.md touched by [add-rest-api, add-graphql]\n\nChecking add-rest-api (created 2026-01-10):\n- Delta adds \"REST Endpoints\" requirement\n- Searching codebase... found src/api/rest.ts\n\nChecking add-graphql (created 2026-01-15):\n- Delta adds \"GraphQL Schema\" requirement\n- Searching codebase... found src/api/graphql.ts\n\nResolution: Both implemented. Will apply add-rest-api specs first,\nthen add-graphql specs (chronological order, newer takes precedence).\n\\`\\`\\`\n\n**Output On Success**\n\n\\`\\`\\`\n## Bulk Archive Complete\n\nArchived N changes:\n- <change-1> -> archive/YYYY-MM-DD-<change-1>/\n- <change-2> -> archive/YYYY-MM-DD-<change-2>/\n\nSpec sync summary:\n- N delta specs synced to main specs\n- No conflicts (or: M conflicts resolved)\n\\`\\`\\`\n\n**Output On Partial Success**\n\n\\`\\`\\`\n## Bulk Archive Complete (partial)\n\nArchived N changes:\n- <change-1> -> archive/YYYY-MM-DD-<change-1>/\n\nSkipped M changes:\n- <change-2> (user chose not to archive incomplete)\n\nFailed K changes:\n- <change-3>: Archive directory already exists\n\\`\\`\\`\n\n**Output When No Changes**\n\n\\`\\`\\`\n## No Changes to Archive\n\nNo active changes found. Create a new change to get started.\n\\`\\`\\`\n\n**Guardrails**\n- Allow any number of changes (1+ is fine, 2+ is the typical use case)\n- Always prompt for selection, never auto-select\n- Detect spec conflicts early and resolve by checking codebase\n- When both changes are implemented, apply specs in chronological order\n- Skip spec sync only when implementation is missing (warn user)\n- Show clear per-change status before confirming\n- Use single confirmation for entire batch\n- Track and report all outcomes (success/skip/fail)\n- Preserve .openspec.yaml when moving to archive\n- Archive directory target uses current date: YYYY-MM-DD-<name>\n- If archive target exists, fail that change but continue with others`\n  };\n}\n"
  },
  {
    "path": "src/core/templates/workflows/continue-change.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate, CommandTemplate } from '../types.js';\n\nexport function getContinueChangeSkillTemplate(): SkillTemplate {\n  return {\n    name: 'openspec-continue-change',\n    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.',\n    instructions: `Continue working on a change by creating the next artifact.\n\n**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.\n\n**Steps**\n\n1. **If no change name provided, prompt for selection**\n\n   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.\n\n   Present the top 3-4 most recently modified changes as options, showing:\n   - Change name\n   - Schema (from \\`schema\\` field if present, otherwise \"spec-driven\")\n   - Status (e.g., \"0/5 tasks\", \"complete\", \"no tasks\")\n   - How recently it was modified (from \\`lastModified\\` field)\n\n   Mark the most recently modified change as \"(Recommended)\" since it's likely what the user wants to continue.\n\n   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.\n\n2. **Check current status**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\" --json\n   \\`\\`\\`\n   Parse the JSON to understand current state. The response includes:\n   - \\`schemaName\\`: The workflow schema being used (e.g., \"spec-driven\")\n   - \\`artifacts\\`: Array of artifacts with their status (\"done\", \"ready\", \"blocked\")\n   - \\`isComplete\\`: Boolean indicating if all artifacts are complete\n\n3. **Act based on status**:\n\n   ---\n\n   **If all artifacts are complete (\\`isComplete: true\\`)**:\n   - Congratulate the user\n   - Show final status including the schema used\n   - Suggest: \"All artifacts created! You can now implement this change or archive it.\"\n   - STOP\n\n   ---\n\n   **If artifacts are ready to create** (status shows artifacts with \\`status: \"ready\"\\`):\n   - Pick the FIRST artifact with \\`status: \"ready\"\\` from the status output\n   - Get its instructions:\n     \\`\\`\\`bash\n     openspec instructions <artifact-id> --change \"<name>\" --json\n     \\`\\`\\`\n   - Parse the JSON. The key fields are:\n     - \\`context\\`: Project background (constraints for you - do NOT include in output)\n     - \\`rules\\`: Artifact-specific rules (constraints for you - do NOT include in output)\n     - \\`template\\`: The structure to use for your output file\n     - \\`instruction\\`: Schema-specific guidance\n     - \\`outputPath\\`: Where to write the artifact\n     - \\`dependencies\\`: Completed artifacts to read for context\n   - **Create the artifact file**:\n     - Read any completed dependency files for context\n     - Use \\`template\\` as the structure - fill in its sections\n     - Apply \\`context\\` and \\`rules\\` as constraints when writing - but do NOT copy them into the file\n     - Write to the output path specified in instructions\n   - Show what was created and what's now unlocked\n   - STOP after creating ONE artifact\n\n   ---\n\n   **If no artifacts are ready (all blocked)**:\n   - This shouldn't happen with a valid schema\n   - Show status and suggest checking for issues\n\n4. **After creating an artifact, show progress**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\"\n   \\`\\`\\`\n\n**Output**\n\nAfter each invocation, show:\n- Which artifact was created\n- Schema workflow being used\n- Current progress (N/M complete)\n- What artifacts are now unlocked\n- Prompt: \"Want to continue? Just ask me to continue or tell me what to do next.\"\n\n**Artifact Creation Guidelines**\n\nThe artifact types and their purpose depend on the schema. Use the \\`instruction\\` field from the instructions output to understand what to create.\n\nCommon artifact patterns:\n\n**spec-driven schema** (proposal → specs → design → tasks):\n- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.\n  - The Capabilities section is critical - each capability listed will need a spec file.\n- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).\n- **design.md**: Document technical decisions, architecture, and implementation approach.\n- **tasks.md**: Break down implementation into checkboxed tasks.\n\nFor other schemas, follow the \\`instruction\\` field from the CLI output.\n\n**Guardrails**\n- Create ONE artifact per invocation\n- Always read dependency artifacts before creating a new one\n- Never skip artifacts or create out of order\n- If context is unclear, ask the user before creating\n- Verify the artifact file exists after writing before marking progress\n- Use the schema's artifact sequence, don't assume specific artifact names\n- **IMPORTANT**: \\`context\\` and \\`rules\\` are constraints for YOU, not content for the file\n  - Do NOT copy \\`<context>\\`, \\`<rules>\\`, \\`<project_context>\\` blocks into the artifact\n  - These guide what you write, but should never appear in the output`,\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n\nexport function getOpsxContinueCommandTemplate(): CommandTemplate {\n  return {\n    name: 'OPSX: Continue',\n    description: 'Continue working on a change - create the next artifact (Experimental)',\n    category: 'Workflow',\n    tags: ['workflow', 'artifacts', 'experimental'],\n    content: `Continue working on a change by creating the next artifact.\n\n**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.\n\n**Steps**\n\n1. **If no change name provided, prompt for selection**\n\n   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.\n\n   Present the top 3-4 most recently modified changes as options, showing:\n   - Change name\n   - Schema (from \\`schema\\` field if present, otherwise \"spec-driven\")\n   - Status (e.g., \"0/5 tasks\", \"complete\", \"no tasks\")\n   - How recently it was modified (from \\`lastModified\\` field)\n\n   Mark the most recently modified change as \"(Recommended)\" since it's likely what the user wants to continue.\n\n   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.\n\n2. **Check current status**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\" --json\n   \\`\\`\\`\n   Parse the JSON to understand current state. The response includes:\n   - \\`schemaName\\`: The workflow schema being used (e.g., \"spec-driven\")\n   - \\`artifacts\\`: Array of artifacts with their status (\"done\", \"ready\", \"blocked\")\n   - \\`isComplete\\`: Boolean indicating if all artifacts are complete\n\n3. **Act based on status**:\n\n   ---\n\n   **If all artifacts are complete (\\`isComplete: true\\`)**:\n   - Congratulate the user\n   - Show final status including the schema used\n   - Suggest: \"All artifacts created! You can now implement this change with \\`/opsx:apply\\` or archive it with \\`/opsx:archive\\`.\"\n   - STOP\n\n   ---\n\n   **If artifacts are ready to create** (status shows artifacts with \\`status: \"ready\"\\`):\n   - Pick the FIRST artifact with \\`status: \"ready\"\\` from the status output\n   - Get its instructions:\n     \\`\\`\\`bash\n     openspec instructions <artifact-id> --change \"<name>\" --json\n     \\`\\`\\`\n   - Parse the JSON. The key fields are:\n     - \\`context\\`: Project background (constraints for you - do NOT include in output)\n     - \\`rules\\`: Artifact-specific rules (constraints for you - do NOT include in output)\n     - \\`template\\`: The structure to use for your output file\n     - \\`instruction\\`: Schema-specific guidance\n     - \\`outputPath\\`: Where to write the artifact\n     - \\`dependencies\\`: Completed artifacts to read for context\n   - **Create the artifact file**:\n     - Read any completed dependency files for context\n     - Use \\`template\\` as the structure - fill in its sections\n     - Apply \\`context\\` and \\`rules\\` as constraints when writing - but do NOT copy them into the file\n     - Write to the output path specified in instructions\n   - Show what was created and what's now unlocked\n   - STOP after creating ONE artifact\n\n   ---\n\n   **If no artifacts are ready (all blocked)**:\n   - This shouldn't happen with a valid schema\n   - Show status and suggest checking for issues\n\n4. **After creating an artifact, show progress**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\"\n   \\`\\`\\`\n\n**Output**\n\nAfter each invocation, show:\n- Which artifact was created\n- Schema workflow being used\n- Current progress (N/M complete)\n- What artifacts are now unlocked\n- Prompt: \"Run \\`/opsx:continue\\` to create the next artifact\"\n\n**Artifact Creation Guidelines**\n\nThe artifact types and their purpose depend on the schema. Use the \\`instruction\\` field from the instructions output to understand what to create.\n\nCommon artifact patterns:\n\n**spec-driven schema** (proposal → specs → design → tasks):\n- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.\n  - The Capabilities section is critical - each capability listed will need a spec file.\n- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).\n- **design.md**: Document technical decisions, architecture, and implementation approach.\n- **tasks.md**: Break down implementation into checkboxed tasks.\n\nFor other schemas, follow the \\`instruction\\` field from the CLI output.\n\n**Guardrails**\n- Create ONE artifact per invocation\n- Always read dependency artifacts before creating a new one\n- Never skip artifacts or create out of order\n- If context is unclear, ask the user before creating\n- Verify the artifact file exists after writing before marking progress\n- Use the schema's artifact sequence, don't assume specific artifact names\n- **IMPORTANT**: \\`context\\` and \\`rules\\` are constraints for YOU, not content for the file\n  - Do NOT copy \\`<context>\\`, \\`<rules>\\`, \\`<project_context>\\` blocks into the artifact\n  - These guide what you write, but should never appear in the output`\n  };\n}\n"
  },
  {
    "path": "src/core/templates/workflows/explore.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate, CommandTemplate } from '../types.js';\n\nexport function getExploreSkillTemplate(): SkillTemplate {\n  return {\n    name: 'openspec-explore',\n    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.',\n    instructions: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.\n\n**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.\n\n**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.\n\n---\n\n## The Stance\n\n- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script\n- **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.\n- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking\n- **Adaptive** - Follow interesting threads, pivot when new information emerges\n- **Patient** - Don't rush to conclusions, let the shape of the problem emerge\n- **Grounded** - Explore the actual codebase when relevant, don't just theorize\n\n---\n\n## What You Might Do\n\nDepending on what the user brings, you might:\n\n**Explore the problem space**\n- Ask clarifying questions that emerge from what they said\n- Challenge assumptions\n- Reframe the problem\n- Find analogies\n\n**Investigate the codebase**\n- Map existing architecture relevant to the discussion\n- Find integration points\n- Identify patterns already in use\n- Surface hidden complexity\n\n**Compare options**\n- Brainstorm multiple approaches\n- Build comparison tables\n- Sketch tradeoffs\n- Recommend a path (if asked)\n\n**Visualize**\n\\`\\`\\`\n┌─────────────────────────────────────────┐\n│     Use ASCII diagrams liberally        │\n├─────────────────────────────────────────┤\n│                                         │\n│   ┌────────┐         ┌────────┐        │\n│   │ State  │────────▶│ State  │        │\n│   │   A    │         │   B    │        │\n│   └────────┘         └────────┘        │\n│                                         │\n│   System diagrams, state machines,      │\n│   data flows, architecture sketches,    │\n│   dependency graphs, comparison tables  │\n│                                         │\n└─────────────────────────────────────────┘\n\\`\\`\\`\n\n**Surface risks and unknowns**\n- Identify what could go wrong\n- Find gaps in understanding\n- Suggest spikes or investigations\n\n---\n\n## OpenSpec Awareness\n\nYou have full context of the OpenSpec system. Use it naturally, don't force it.\n\n### Check for context\n\nAt the start, quickly check what exists:\n\\`\\`\\`bash\nopenspec list --json\n\\`\\`\\`\n\nThis tells you:\n- If there are active changes\n- Their names, schemas, and status\n- What the user might be working on\n\n### When no change exists\n\nThink freely. When insights crystallize, you might offer:\n\n- \"This feels solid enough to start a change. Want me to create a proposal?\"\n- Or keep exploring - no pressure to formalize\n\n### When a change exists\n\nIf the user mentions a change or you detect one is relevant:\n\n1. **Read existing artifacts for context**\n   - \\`openspec/changes/<name>/proposal.md\\`\n   - \\`openspec/changes/<name>/design.md\\`\n   - \\`openspec/changes/<name>/tasks.md\\`\n   - etc.\n\n2. **Reference them naturally in conversation**\n   - \"Your design mentions using Redis, but we just realized SQLite fits better...\"\n   - \"The proposal scopes this to premium users, but we're now thinking everyone...\"\n\n3. **Offer to capture when decisions are made**\n\n   | Insight Type | Where to Capture |\n   |--------------|------------------|\n   | New requirement discovered | \\`specs/<capability>/spec.md\\` |\n   | Requirement changed | \\`specs/<capability>/spec.md\\` |\n   | Design decision made | \\`design.md\\` |\n   | Scope changed | \\`proposal.md\\` |\n   | New work identified | \\`tasks.md\\` |\n   | Assumption invalidated | Relevant artifact |\n\n   Example offers:\n   - \"That's a design decision. Capture it in design.md?\"\n   - \"This is a new requirement. Add it to specs?\"\n   - \"This changes scope. Update the proposal?\"\n\n4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.\n\n---\n\n## What You Don't Have To Do\n\n- Follow a script\n- Ask the same questions every time\n- Produce a specific artifact\n- Reach a conclusion\n- Stay on topic if a tangent is valuable\n- Be brief (this is thinking time)\n\n---\n\n## Handling Different Entry Points\n\n**User brings a vague idea:**\n\\`\\`\\`\nUser: I'm thinking about adding real-time collaboration\n\nYou: Real-time collab is a big space. Let me think about this...\n\n      COLLABORATION SPECTRUM\n      ════════════════════════════════════════════\n\n      Awareness          Coordination         Sync\n          │                   │                 │\n          ▼                   ▼                 ▼\n      ┌────────┐         ┌────────┐        ┌────────┐\n      │Presence│         │Cursors │        │  CRDT  │\n      │  \"3    │         │ Multi  │        │Conflict│\n      │online\" │         │ select │        │  free  │\n      └────────┘         └────────┘        └────────┘\n          │                   │                 │\n       trivial            moderate           complex\n\n      Where's your head at?\n\\`\\`\\`\n\n**User brings a specific problem:**\n\\`\\`\\`\nUser: The auth system is a mess\n\nYou: [reads codebase]\n\n     ┌─────────────────────────────────────────────┐\n     │              CURRENT AUTH FLOW              │\n     └─────────────────────────────────────────────┘\n                        │\n          ┌─────────────┼─────────────┐\n          ▼             ▼             ▼\n     ┌─────────┐  ┌─────────┐  ┌─────────┐\n     │ Google  │  │  GitHub │  │  Email  │\n     │  OAuth  │  │  OAuth  │  │  Magic  │\n     └────┬────┘  └────┬────┘  └────┬────┘\n          │             │             │\n          └─────────────┼─────────────┘\n                        ▼\n                  ┌───────────┐\n                  │  Session  │\n                  └─────┬─────┘\n                        │\n                        ▼\n                  ┌───────────┐\n                  │   Perms   │\n                  └───────────┘\n\n     I see three tangles. Which one's burning?\n\\`\\`\\`\n\n**User is stuck mid-implementation:**\n\\`\\`\\`\nUser: /opsx:explore add-auth-system\n      The OAuth integration is more complex than expected\n\nYou: [reads change artifacts]\n\n     You're on task 4: \"Implement OAuth flow\"\n\n     Let me trace what's involved...\n\n     [draws diagram, explores options, suggests paths]\n\n     Want to update the design to reflect this?\n     Or add a spike task to investigate?\n\\`\\`\\`\n\n**User wants to compare options:**\n\\`\\`\\`\nUser: Should we use Postgres or SQLite?\n\nYou: Generic answer is boring. What's the context?\n\nUser: A CLI tool that tracks local dev environments\n\nYou: That changes everything.\n\n     ┌─────────────────────────────────────────────────┐\n     │         CLI TOOL DATA STORAGE                  │\n     └─────────────────────────────────────────────────┘\n\n     Key constraints:\n     • No daemon running\n     • Must work offline\n     • Single user\n\n                  SQLite          Postgres\n     Deployment   embedded ✓      needs server ✗\n     Offline      yes ✓           no ✗\n     Single file  yes ✓           no ✗\n\n     SQLite. Not even close.\n\n     Unless... is there a sync component?\n\\`\\`\\`\n\n---\n\n## Ending Discovery\n\nThere's no required ending. Discovery might:\n\n- **Flow into a proposal**: \"Ready to start? I can create a change proposal.\"\n- **Result in artifact updates**: \"Updated design.md with these decisions\"\n- **Just provide clarity**: User has what they need, moves on\n- **Continue later**: \"We can pick this up anytime\"\n\nWhen it feels like things are crystallizing, you might summarize:\n\n\\`\\`\\`\n## What We Figured Out\n\n**The problem**: [crystallized understanding]\n\n**The approach**: [if one emerged]\n\n**Open questions**: [if any remain]\n\n**Next steps** (if ready):\n- Create a change proposal\n- Keep exploring: just keep talking\n\\`\\`\\`\n\nBut this summary is optional. Sometimes the thinking IS the value.\n\n---\n\n## Guardrails\n\n- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.\n- **Don't fake understanding** - If something is unclear, dig deeper\n- **Don't rush** - Discovery is thinking time, not task time\n- **Don't force structure** - Let patterns emerge naturally\n- **Don't auto-capture** - Offer to save insights, don't just do it\n- **Do visualize** - A good diagram is worth many paragraphs\n- **Do explore the codebase** - Ground discussions in reality\n- **Do question assumptions** - Including the user's and your own`,\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n\nexport function getOpsxExploreCommandTemplate(): CommandTemplate {\n  return {\n    name: 'OPSX: Explore',\n    description: 'Enter explore mode - think through ideas, investigate problems, clarify requirements',\n    category: 'Workflow',\n    tags: ['workflow', 'explore', 'experimental', 'thinking'],\n    content: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.\n\n**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.\n\n**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.\n\n**Input**: The argument after \\`/opsx:explore\\` is whatever the user wants to think about. Could be:\n- A vague idea: \"real-time collaboration\"\n- A specific problem: \"the auth system is getting unwieldy\"\n- A change name: \"add-dark-mode\" (to explore in context of that change)\n- A comparison: \"postgres vs sqlite for this\"\n- Nothing (just enter explore mode)\n\n---\n\n## The Stance\n\n- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script\n- **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.\n- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking\n- **Adaptive** - Follow interesting threads, pivot when new information emerges\n- **Patient** - Don't rush to conclusions, let the shape of the problem emerge\n- **Grounded** - Explore the actual codebase when relevant, don't just theorize\n\n---\n\n## What You Might Do\n\nDepending on what the user brings, you might:\n\n**Explore the problem space**\n- Ask clarifying questions that emerge from what they said\n- Challenge assumptions\n- Reframe the problem\n- Find analogies\n\n**Investigate the codebase**\n- Map existing architecture relevant to the discussion\n- Find integration points\n- Identify patterns already in use\n- Surface hidden complexity\n\n**Compare options**\n- Brainstorm multiple approaches\n- Build comparison tables\n- Sketch tradeoffs\n- Recommend a path (if asked)\n\n**Visualize**\n\\`\\`\\`\n┌─────────────────────────────────────────┐\n│     Use ASCII diagrams liberally        │\n├─────────────────────────────────────────┤\n│                                         │\n│   ┌────────┐         ┌────────┐        │\n│   │ State  │────────▶│ State  │        │\n│   │   A    │         │   B    │        │\n│   └────────┘         └────────┘        │\n│                                         │\n│   System diagrams, state machines,      │\n│   data flows, architecture sketches,    │\n│   dependency graphs, comparison tables  │\n│                                         │\n└─────────────────────────────────────────┘\n\\`\\`\\`\n\n**Surface risks and unknowns**\n- Identify what could go wrong\n- Find gaps in understanding\n- Suggest spikes or investigations\n\n---\n\n## OpenSpec Awareness\n\nYou have full context of the OpenSpec system. Use it naturally, don't force it.\n\n### Check for context\n\nAt the start, quickly check what exists:\n\\`\\`\\`bash\nopenspec list --json\n\\`\\`\\`\n\nThis tells you:\n- If there are active changes\n- Their names, schemas, and status\n- What the user might be working on\n\nIf the user mentioned a specific change name, read its artifacts for context.\n\n### When no change exists\n\nThink freely. When insights crystallize, you might offer:\n\n- \"This feels solid enough to start a change. Want me to create a proposal?\"\n- Or keep exploring - no pressure to formalize\n\n### When a change exists\n\nIf the user mentions a change or you detect one is relevant:\n\n1. **Read existing artifacts for context**\n   - \\`openspec/changes/<name>/proposal.md\\`\n   - \\`openspec/changes/<name>/design.md\\`\n   - \\`openspec/changes/<name>/tasks.md\\`\n   - etc.\n\n2. **Reference them naturally in conversation**\n   - \"Your design mentions using Redis, but we just realized SQLite fits better...\"\n   - \"The proposal scopes this to premium users, but we're now thinking everyone...\"\n\n3. **Offer to capture when decisions are made**\n\n   | Insight Type | Where to Capture |\n   |--------------|------------------|\n   | New requirement discovered | \\`specs/<capability>/spec.md\\` |\n   | Requirement changed | \\`specs/<capability>/spec.md\\` |\n   | Design decision made | \\`design.md\\` |\n   | Scope changed | \\`proposal.md\\` |\n   | New work identified | \\`tasks.md\\` |\n   | Assumption invalidated | Relevant artifact |\n\n   Example offers:\n   - \"That's a design decision. Capture it in design.md?\"\n   - \"This is a new requirement. Add it to specs?\"\n   - \"This changes scope. Update the proposal?\"\n\n4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.\n\n---\n\n## What You Don't Have To Do\n\n- Follow a script\n- Ask the same questions every time\n- Produce a specific artifact\n- Reach a conclusion\n- Stay on topic if a tangent is valuable\n- Be brief (this is thinking time)\n\n---\n\n## Ending Discovery\n\nThere's no required ending. Discovery might:\n\n- **Flow into a proposal**: \"Ready to start? I can create a change proposal.\"\n- **Result in artifact updates**: \"Updated design.md with these decisions\"\n- **Just provide clarity**: User has what they need, moves on\n- **Continue later**: \"We can pick this up anytime\"\n\nWhen things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.\n\n---\n\n## Guardrails\n\n- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.\n- **Don't fake understanding** - If something is unclear, dig deeper\n- **Don't rush** - Discovery is thinking time, not task time\n- **Don't force structure** - Let patterns emerge naturally\n- **Don't auto-capture** - Offer to save insights, don't just do it\n- **Do visualize** - A good diagram is worth many paragraphs\n- **Do explore the codebase** - Ground discussions in reality\n- **Do question assumptions** - Including the user's and your own`\n  };\n}\n"
  },
  {
    "path": "src/core/templates/workflows/feedback.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate } from '../types.js';\n\nexport function getFeedbackSkillTemplate(): SkillTemplate {\n  return {\n    name: 'feedback',\n    description: 'Collect and submit user feedback about OpenSpec with context enrichment and anonymization.',\n    instructions: `Help the user submit feedback about OpenSpec.\n\n**Goal**: Guide the user through collecting, enriching, and submitting feedback while ensuring privacy through anonymization.\n\n**Process**\n\n1. **Gather context from the conversation**\n   - Review recent conversation history for context\n   - Identify what task was being performed\n   - Note what worked well or poorly\n   - Capture specific friction points or praise\n\n2. **Draft enriched feedback**\n   - Create a clear, descriptive title (single sentence, no \"Feedback:\" prefix needed)\n   - Write a body that includes:\n     - What the user was trying to do\n     - What happened (good or bad)\n     - Relevant context from the conversation\n     - Any specific suggestions or requests\n\n3. **Anonymize sensitive information**\n   - Replace file paths with \\`<path>\\` or generic descriptions\n   - Replace API keys, tokens, secrets with \\`<redacted>\\`\n   - Replace company/organization names with \\`<company>\\`\n   - Replace personal names with \\`<user>\\`\n   - Replace specific URLs with \\`<url>\\` unless public/relevant\n   - Keep technical details that help understand the issue\n\n4. **Present draft for approval**\n   - Show the complete draft to the user\n   - Display both title and body clearly\n   - Ask for explicit approval before submitting\n   - Allow the user to request modifications\n\n5. **Submit on confirmation**\n   - Use the \\`openspec feedback\\` command to submit\n   - Format: \\`openspec feedback \"title\" --body \"body content\"\\`\n   - The command will automatically add metadata (version, platform, timestamp)\n\n**Example Draft**\n\n\\`\\`\\`\nTitle: Error handling in artifact workflow needs improvement\n\nBody:\nI was working on creating a new change and encountered an issue with\nthe artifact workflow. When I tried to continue after creating the\nproposal, the system didn't clearly indicate that I needed to complete\nthe specs first.\n\nSuggestion: Add clearer error messages that explain dependency chains\nin the artifact workflow. Something like \"Cannot create design.md\nbecause specs are not complete (0/2 done).\"\n\nContext: Using the spec-driven schema with <path>/my-project\n\\`\\`\\`\n\n**Anonymization Examples**\n\nBefore:\n\\`\\`\\`\nWorking on /Users/john/mycompany/auth-service/src/oauth.ts\nFailed with API key: sk_live_abc123xyz\nWorking at Acme Corp\n\\`\\`\\`\n\nAfter:\n\\`\\`\\`\nWorking on <path>/oauth.ts\nFailed with API key: <redacted>\nWorking at <company>\n\\`\\`\\`\n\n**Guardrails**\n\n- MUST show complete draft before submitting\n- MUST ask for explicit approval\n- MUST anonymize sensitive information\n- ALLOW user to modify draft before submitting\n- DO NOT submit without user confirmation\n- DO include relevant technical context\n- DO keep conversation-specific insights\n\n**User Confirmation Required**\n\nAlways ask:\n\\`\\`\\`\nHere's the feedback I've drafted:\n\nTitle: [title]\n\nBody:\n[body]\n\nDoes this look good? I can modify it if you'd like, or submit it as-is.\n\\`\\`\\`\n\nOnly proceed with submission after user confirms.`,\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n"
  },
  {
    "path": "src/core/templates/workflows/ff-change.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate, CommandTemplate } from '../types.js';\n\nexport function getFfChangeSkillTemplate(): SkillTemplate {\n  return {\n    name: 'openspec-ff-change',\n    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.',\n    instructions: `Fast-forward through artifact creation - generate everything needed to start implementation in one go.\n\n**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.\n\n**Steps**\n\n1. **If no clear input provided, ask what they want to build**\n\n   Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:\n   > \"What change do you want to work on? Describe what you want to build or fix.\"\n\n   From their description, derive a kebab-case name (e.g., \"add user authentication\" → \\`add-user-auth\\`).\n\n   **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.\n\n2. **Create the change directory**\n   \\`\\`\\`bash\n   openspec new change \"<name>\"\n   \\`\\`\\`\n   This creates a scaffolded change at \\`openspec/changes/<name>/\\`.\n\n3. **Get the artifact build order**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\" --json\n   \\`\\`\\`\n   Parse the JSON to get:\n   - \\`applyRequires\\`: array of artifact IDs needed before implementation (e.g., \\`[\"tasks\"]\\`)\n   - \\`artifacts\\`: list of all artifacts with their status and dependencies\n\n4. **Create artifacts in sequence until apply-ready**\n\n   Use the **TodoWrite tool** to track progress through the artifacts.\n\n   Loop through artifacts in dependency order (artifacts with no pending dependencies first):\n\n   a. **For each artifact that is \\`ready\\` (dependencies satisfied)**:\n      - Get instructions:\n        \\`\\`\\`bash\n        openspec instructions <artifact-id> --change \"<name>\" --json\n        \\`\\`\\`\n      - The instructions JSON includes:\n        - \\`context\\`: Project background (constraints for you - do NOT include in output)\n        - \\`rules\\`: Artifact-specific rules (constraints for you - do NOT include in output)\n        - \\`template\\`: The structure to use for your output file\n        - \\`instruction\\`: Schema-specific guidance for this artifact type\n        - \\`outputPath\\`: Where to write the artifact\n        - \\`dependencies\\`: Completed artifacts to read for context\n      - Read any completed dependency files for context\n      - Create the artifact file using \\`template\\` as the structure\n      - Apply \\`context\\` and \\`rules\\` as constraints - but do NOT copy them into the file\n      - Show brief progress: \"✓ Created <artifact-id>\"\n\n   b. **Continue until all \\`applyRequires\\` artifacts are complete**\n      - After creating each artifact, re-run \\`openspec status --change \"<name>\" --json\\`\n      - Check if every artifact ID in \\`applyRequires\\` has \\`status: \"done\"\\` in the artifacts array\n      - Stop when all \\`applyRequires\\` artifacts are done\n\n   c. **If an artifact requires user input** (unclear context):\n      - Use **AskUserQuestion tool** to clarify\n      - Then continue with creation\n\n5. **Show final status**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\"\n   \\`\\`\\`\n\n**Output**\n\nAfter completing all artifacts, summarize:\n- Change name and location\n- List of artifacts created with brief descriptions\n- What's ready: \"All artifacts created! Ready for implementation.\"\n- Prompt: \"Run \\`/opsx:apply\\` or ask me to implement to start working on the tasks.\"\n\n**Artifact Creation Guidelines**\n\n- Follow the \\`instruction\\` field from \\`openspec instructions\\` for each artifact type\n- The schema defines what each artifact should contain - follow it\n- Read dependency artifacts for context before creating new ones\n- Use \\`template\\` as the structure for your output file - fill in its sections\n- **IMPORTANT**: \\`context\\` and \\`rules\\` are constraints for YOU, not content for the file\n  - Do NOT copy \\`<context>\\`, \\`<rules>\\`, \\`<project_context>\\` blocks into the artifact\n  - These guide what you write, but should never appear in the output\n\n**Guardrails**\n- Create ALL artifacts needed for implementation (as defined by schema's \\`apply.requires\\`)\n- Always read dependency artifacts before creating a new one\n- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum\n- If a change with that name already exists, suggest continuing that change instead\n- Verify each artifact file exists after writing before proceeding to next`,\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n\nexport function getOpsxFfCommandTemplate(): CommandTemplate {\n  return {\n    name: 'OPSX: Fast Forward',\n    description: 'Create a change and generate all artifacts needed for implementation in one go',\n    category: 'Workflow',\n    tags: ['workflow', 'artifacts', 'experimental'],\n    content: `Fast-forward through artifact creation - generate everything needed to start implementation.\n\n**Input**: The argument after \\`/opsx:ff\\` is the change name (kebab-case), OR a description of what the user wants to build.\n\n**Steps**\n\n1. **If no input provided, ask what they want to build**\n\n   Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:\n   > \"What change do you want to work on? Describe what you want to build or fix.\"\n\n   From their description, derive a kebab-case name (e.g., \"add user authentication\" → \\`add-user-auth\\`).\n\n   **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.\n\n2. **Create the change directory**\n   \\`\\`\\`bash\n   openspec new change \"<name>\"\n   \\`\\`\\`\n   This creates a scaffolded change at \\`openspec/changes/<name>/\\`.\n\n3. **Get the artifact build order**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\" --json\n   \\`\\`\\`\n   Parse the JSON to get:\n   - \\`applyRequires\\`: array of artifact IDs needed before implementation (e.g., \\`[\"tasks\"]\\`)\n   - \\`artifacts\\`: list of all artifacts with their status and dependencies\n\n4. **Create artifacts in sequence until apply-ready**\n\n   Use the **TodoWrite tool** to track progress through the artifacts.\n\n   Loop through artifacts in dependency order (artifacts with no pending dependencies first):\n\n   a. **For each artifact that is \\`ready\\` (dependencies satisfied)**:\n      - Get instructions:\n        \\`\\`\\`bash\n        openspec instructions <artifact-id> --change \"<name>\" --json\n        \\`\\`\\`\n      - The instructions JSON includes:\n        - \\`context\\`: Project background (constraints for you - do NOT include in output)\n        - \\`rules\\`: Artifact-specific rules (constraints for you - do NOT include in output)\n        - \\`template\\`: The structure to use for your output file\n        - \\`instruction\\`: Schema-specific guidance for this artifact type\n        - \\`outputPath\\`: Where to write the artifact\n        - \\`dependencies\\`: Completed artifacts to read for context\n      - Read any completed dependency files for context\n      - Create the artifact file using \\`template\\` as the structure\n      - Apply \\`context\\` and \\`rules\\` as constraints - but do NOT copy them into the file\n      - Show brief progress: \"✓ Created <artifact-id>\"\n\n   b. **Continue until all \\`applyRequires\\` artifacts are complete**\n      - After creating each artifact, re-run \\`openspec status --change \"<name>\" --json\\`\n      - Check if every artifact ID in \\`applyRequires\\` has \\`status: \"done\"\\` in the artifacts array\n      - Stop when all \\`applyRequires\\` artifacts are done\n\n   c. **If an artifact requires user input** (unclear context):\n      - Use **AskUserQuestion tool** to clarify\n      - Then continue with creation\n\n5. **Show final status**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\"\n   \\`\\`\\`\n\n**Output**\n\nAfter completing all artifacts, summarize:\n- Change name and location\n- List of artifacts created with brief descriptions\n- What's ready: \"All artifacts created! Ready for implementation.\"\n- Prompt: \"Run \\`/opsx:apply\\` to start implementing.\"\n\n**Artifact Creation Guidelines**\n\n- Follow the \\`instruction\\` field from \\`openspec instructions\\` for each artifact type\n- The schema defines what each artifact should contain - follow it\n- Read dependency artifacts for context before creating new ones\n- Use \\`template\\` as the structure for your output file - fill in its sections\n- **IMPORTANT**: \\`context\\` and \\`rules\\` are constraints for YOU, not content for the file\n  - Do NOT copy \\`<context>\\`, \\`<rules>\\`, \\`<project_context>\\` blocks into the artifact\n  - These guide what you write, but should never appear in the output\n\n**Guardrails**\n- Create ALL artifacts needed for implementation (as defined by schema's \\`apply.requires\\`)\n- Always read dependency artifacts before creating a new one\n- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum\n- If a change with that name already exists, ask if user wants to continue it or create a new one\n- Verify each artifact file exists after writing before proceeding to next`\n  };\n}\n"
  },
  {
    "path": "src/core/templates/workflows/new-change.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate, CommandTemplate } from '../types.js';\n\nexport function getNewChangeSkillTemplate(): SkillTemplate {\n  return {\n    name: 'openspec-new-change',\n    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.',\n    instructions: `Start a new change using the experimental artifact-driven approach.\n\n**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.\n\n**Steps**\n\n1. **If no clear input provided, ask what they want to build**\n\n   Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:\n   > \"What change do you want to work on? Describe what you want to build or fix.\"\n\n   From their description, derive a kebab-case name (e.g., \"add user authentication\" → \\`add-user-auth\\`).\n\n   **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.\n\n2. **Determine the workflow schema**\n\n   Use the default schema (omit \\`--schema\\`) unless the user explicitly requests a different workflow.\n\n   **Use a different schema only if the user mentions:**\n   - A specific schema name → use \\`--schema <name>\\`\n   - \"show workflows\" or \"what workflows\" → run \\`openspec schemas --json\\` and let them choose\n\n   **Otherwise**: Omit \\`--schema\\` to use the default.\n\n3. **Create the change directory**\n   \\`\\`\\`bash\n   openspec new change \"<name>\"\n   \\`\\`\\`\n   Add \\`--schema <name>\\` only if the user requested a specific workflow.\n   This creates a scaffolded change at \\`openspec/changes/<name>/\\` with the selected schema.\n\n4. **Show the artifact status**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\"\n   \\`\\`\\`\n   This shows which artifacts need to be created and which are ready (dependencies satisfied).\n\n5. **Get instructions for the first artifact**\n   The first artifact depends on the schema (e.g., \\`proposal\\` for spec-driven).\n   Check the status output to find the first artifact with status \"ready\".\n   \\`\\`\\`bash\n   openspec instructions <first-artifact-id> --change \"<name>\"\n   \\`\\`\\`\n   This outputs the template and context for creating the first artifact.\n\n6. **STOP and wait for user direction**\n\n**Output**\n\nAfter completing the steps, summarize:\n- Change name and location\n- Schema/workflow being used and its artifact sequence\n- Current status (0/N artifacts complete)\n- The template for the first artifact\n- Prompt: \"Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue.\"\n\n**Guardrails**\n- Do NOT create any artifacts yet - just show the instructions\n- Do NOT advance beyond showing the first artifact template\n- If the name is invalid (not kebab-case), ask for a valid name\n- If a change with that name already exists, suggest continuing that change instead\n- Pass --schema if using a non-default workflow`,\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n\nexport function getOpsxNewCommandTemplate(): CommandTemplate {\n  return {\n    name: 'OPSX: New',\n    description: 'Start a new change using the experimental artifact workflow (OPSX)',\n    category: 'Workflow',\n    tags: ['workflow', 'artifacts', 'experimental'],\n    content: `Start a new change using the experimental artifact-driven approach.\n\n**Input**: The argument after \\`/opsx:new\\` is the change name (kebab-case), OR a description of what the user wants to build.\n\n**Steps**\n\n1. **If no input provided, ask what they want to build**\n\n   Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:\n   > \"What change do you want to work on? Describe what you want to build or fix.\"\n\n   From their description, derive a kebab-case name (e.g., \"add user authentication\" → \\`add-user-auth\\`).\n\n   **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.\n\n2. **Determine the workflow schema**\n\n   Use the default schema (omit \\`--schema\\`) unless the user explicitly requests a different workflow.\n\n   **Use a different schema only if the user mentions:**\n   - A specific schema name → use \\`--schema <name>\\`\n   - \"show workflows\" or \"what workflows\" → run \\`openspec schemas --json\\` and let them choose\n\n   **Otherwise**: Omit \\`--schema\\` to use the default.\n\n3. **Create the change directory**\n   \\`\\`\\`bash\n   openspec new change \"<name>\"\n   \\`\\`\\`\n   Add \\`--schema <name>\\` only if the user requested a specific workflow.\n   This creates a scaffolded change at \\`openspec/changes/<name>/\\` with the selected schema.\n\n4. **Show the artifact status**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\"\n   \\`\\`\\`\n   This shows which artifacts need to be created and which are ready (dependencies satisfied).\n\n5. **Get instructions for the first artifact**\n   The first artifact depends on the schema. Check the status output to find the first artifact with status \"ready\".\n   \\`\\`\\`bash\n   openspec instructions <first-artifact-id> --change \"<name>\"\n   \\`\\`\\`\n   This outputs the template and context for creating the first artifact.\n\n6. **STOP and wait for user direction**\n\n**Output**\n\nAfter completing the steps, summarize:\n- Change name and location\n- Schema/workflow being used and its artifact sequence\n- Current status (0/N artifacts complete)\n- The template for the first artifact\n- Prompt: \"Ready to create the first artifact? Run \\`/opsx:continue\\` or just describe what this change is about and I'll draft it.\"\n\n**Guardrails**\n- Do NOT create any artifacts yet - just show the instructions\n- Do NOT advance beyond showing the first artifact template\n- If the name is invalid (not kebab-case), ask for a valid name\n- If a change with that name already exists, suggest using \\`/opsx:continue\\` instead\n- Pass --schema if using a non-default workflow`\n  };\n}\n"
  },
  {
    "path": "src/core/templates/workflows/onboard.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate, CommandTemplate } from '../types.js';\n\nexport function getOnboardSkillTemplate(): SkillTemplate {\n  return {\n    name: 'openspec-onboard',\n    description: 'Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.',\n    instructions: getOnboardInstructions(),\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n\nfunction getOnboardInstructions(): string {\n  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.\n\n---\n\n## Preflight\n\nBefore starting, check if the OpenSpec CLI is installed:\n\n\\`\\`\\`bash\n# Unix/macOS\nopenspec --version 2>&1 || echo \"CLI_NOT_INSTALLED\"\n# Windows (PowerShell)\n# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo \"CLI_NOT_INSTALLED\" }\n\\`\\`\\`\n\n**If CLI not installed:**\n> OpenSpec CLI is not installed. Install it first, then come back to \\`/opsx:onboard\\`.\n\nStop here if not installed.\n\n---\n\n## Phase 1: Welcome\n\nDisplay:\n\n\\`\\`\\`\n## Welcome to OpenSpec!\n\nI'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.\n\n**What we'll do:**\n1. Pick a small, real task in your codebase\n2. Explore the problem briefly\n3. Create a change (the container for our work)\n4. Build the artifacts: proposal → specs → design → tasks\n5. Implement the tasks\n6. Archive the completed change\n\n**Time:** ~15-20 minutes\n\nLet's start by finding something to work on.\n\\`\\`\\`\n\n---\n\n## Phase 2: Task Selection\n\n### Codebase Analysis\n\nScan the codebase for small improvement opportunities. Look for:\n\n1. **TODO/FIXME comments** - Search for \\`TODO\\`, \\`FIXME\\`, \\`HACK\\`, \\`XXX\\` in code files\n2. **Missing error handling** - \\`catch\\` blocks that swallow errors, risky operations without try-catch\n3. **Functions without tests** - Cross-reference \\`src/\\` with test directories\n4. **Type issues** - \\`any\\` types in TypeScript files (\\`: any\\`, \\`as any\\`)\n5. **Debug artifacts** - \\`console.log\\`, \\`console.debug\\`, \\`debugger\\` statements in non-debug code\n6. **Missing validation** - User input handlers without validation\n\nAlso check recent git activity:\n\\`\\`\\`bash\n# Unix/macOS\ngit log --oneline -10 2>/dev/null || echo \"No git history\"\n# Windows (PowerShell)\n# git log --oneline -10 2>$null; if ($LASTEXITCODE -ne 0) { echo \"No git history\" }\n\\`\\`\\`\n\n### Present Suggestions\n\nFrom your analysis, present 3-4 specific suggestions:\n\n\\`\\`\\`\n## Task Suggestions\n\nBased on scanning your codebase, here are some good starter tasks:\n\n**1. [Most promising task]**\n   Location: \\`src/path/to/file.ts:42\\`\n   Scope: ~1-2 files, ~20-30 lines\n   Why it's good: [brief reason]\n\n**2. [Second task]**\n   Location: \\`src/another/file.ts\\`\n   Scope: ~1 file, ~15 lines\n   Why it's good: [brief reason]\n\n**3. [Third task]**\n   Location: [location]\n   Scope: [estimate]\n   Why it's good: [brief reason]\n\n**4. Something else?**\n   Tell me what you'd like to work on.\n\nWhich task interests you? (Pick a number or describe your own)\n\\`\\`\\`\n\n**If nothing found:** Fall back to asking what the user wants to build:\n> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?\n\n### Scope Guardrail\n\nIf the user picks or describes something too large (major feature, multi-day work):\n\n\\`\\`\\`\nThat's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.\n\nFor learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.\n\n**Options:**\n1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?\n2. **Pick something else** - One of the other suggestions, or a different small task?\n3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.\n\nWhat would you prefer?\n\\`\\`\\`\n\nLet the user override if they insist—this is a soft guardrail.\n\n---\n\n## Phase 3: Explore Demo\n\nOnce a task is selected, briefly demonstrate explore mode:\n\n\\`\\`\\`\nBefore we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.\n\\`\\`\\`\n\nSpend 1-2 minutes investigating the relevant code:\n- Read the file(s) involved\n- Draw a quick ASCII diagram if it helps\n- Note any considerations\n\n\\`\\`\\`\n## Quick Exploration\n\n[Your brief analysis—what you found, any considerations]\n\n┌─────────────────────────────────────────┐\n│   [Optional: ASCII diagram if helpful]  │\n└─────────────────────────────────────────┘\n\nExplore mode (\\`/opsx:explore\\`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.\n\nNow let's create a change to hold our work.\n\\`\\`\\`\n\n**PAUSE** - Wait for user acknowledgment before proceeding.\n\n---\n\n## Phase 4: Create the Change\n\n**EXPLAIN:**\n\\`\\`\\`\n## Creating a Change\n\nA \"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.\n\nLet me create one for our task.\n\\`\\`\\`\n\n**DO:** Create the change with a derived kebab-case name:\n\\`\\`\\`bash\nopenspec new change \"<derived-name>\"\n\\`\\`\\`\n\n**SHOW:**\n\\`\\`\\`\nCreated: \\`openspec/changes/<name>/\\`\n\nThe folder structure:\n\\`\\`\\`\nopenspec/changes/<name>/\n├── proposal.md    ← Why we're doing this (empty, we'll fill it)\n├── design.md      ← How we'll build it (empty)\n├── specs/         ← Detailed requirements (empty)\n└── tasks.md       ← Implementation checklist (empty)\n\\`\\`\\`\n\nNow let's fill in the first artifact—the proposal.\n\\`\\`\\`\n\n---\n\n## Phase 5: Proposal\n\n**EXPLAIN:**\n\\`\\`\\`\n## The Proposal\n\nThe proposal captures **why** we're making this change and **what** it involves at a high level. It's the \"elevator pitch\" for the work.\n\nI'll draft one based on our task.\n\\`\\`\\`\n\n**DO:** Draft the proposal content (don't save yet):\n\n\\`\\`\\`\nHere's a draft proposal:\n\n---\n\n## Why\n\n[1-2 sentences explaining the problem/opportunity]\n\n## What Changes\n\n[Bullet points of what will be different]\n\n## Capabilities\n\n### New Capabilities\n- \\`<capability-name>\\`: [brief description]\n\n### Modified Capabilities\n<!-- If modifying existing behavior -->\n\n## Impact\n\n- \\`src/path/to/file.ts\\`: [what changes]\n- [other files if applicable]\n\n---\n\nDoes this capture the intent? I can adjust before we save it.\n\\`\\`\\`\n\n**PAUSE** - Wait for user approval/feedback.\n\nAfter approval, save the proposal:\n\\`\\`\\`bash\nopenspec instructions proposal --change \"<name>\" --json\n\\`\\`\\`\nThen write the content to \\`openspec/changes/<name>/proposal.md\\`.\n\n\\`\\`\\`\nProposal saved. This is your \"why\" document—you can always come back and refine it as understanding evolves.\n\nNext up: specs.\n\\`\\`\\`\n\n---\n\n## Phase 6: Specs\n\n**EXPLAIN:**\n\\`\\`\\`\n## Specs\n\nSpecs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.\n\nFor a small task like this, we might only need one spec file.\n\\`\\`\\`\n\n**DO:** Create the spec file:\n\\`\\`\\`bash\n# Unix/macOS\nmkdir -p openspec/changes/<name>/specs/<capability-name>\n# Windows (PowerShell)\n# New-Item -ItemType Directory -Force -Path \"openspec/changes/<name>/specs/<capability-name>\"\n\\`\\`\\`\n\nDraft the spec content:\n\n\\`\\`\\`\nHere's the spec:\n\n---\n\n## ADDED Requirements\n\n### Requirement: <Name>\n\n<Description of what the system should do>\n\n#### Scenario: <Scenario name>\n\n- **WHEN** <trigger condition>\n- **THEN** <expected outcome>\n- **AND** <additional outcome if needed>\n\n---\n\nThis format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.\n\\`\\`\\`\n\nSave to \\`openspec/changes/<name>/specs/<capability>/spec.md\\`.\n\n---\n\n## Phase 7: Design\n\n**EXPLAIN:**\n\\`\\`\\`\n## Design\n\nThe design captures **how** we'll build it—technical decisions, tradeoffs, approach.\n\nFor small changes, this might be brief. That's fine—not every change needs deep design discussion.\n\\`\\`\\`\n\n**DO:** Draft design.md:\n\n\\`\\`\\`\nHere's the design:\n\n---\n\n## Context\n\n[Brief context about the current state]\n\n## Goals / Non-Goals\n\n**Goals:**\n- [What we're trying to achieve]\n\n**Non-Goals:**\n- [What's explicitly out of scope]\n\n## Decisions\n\n### Decision 1: [Key decision]\n\n[Explanation of approach and rationale]\n\n---\n\nFor a small task, this captures the key decisions without over-engineering.\n\\`\\`\\`\n\nSave to \\`openspec/changes/<name>/design.md\\`.\n\n---\n\n## Phase 8: Tasks\n\n**EXPLAIN:**\n\\`\\`\\`\n## Tasks\n\nFinally, we break the work into implementation tasks—checkboxes that drive the apply phase.\n\nThese should be small, clear, and in logical order.\n\\`\\`\\`\n\n**DO:** Generate tasks based on specs and design:\n\n\\`\\`\\`\nHere are the implementation tasks:\n\n---\n\n## 1. [Category or file]\n\n- [ ] 1.1 [Specific task]\n- [ ] 1.2 [Specific task]\n\n## 2. Verify\n\n- [ ] 2.1 [Verification step]\n\n---\n\nEach checkbox becomes a unit of work in the apply phase. Ready to implement?\n\\`\\`\\`\n\n**PAUSE** - Wait for user to confirm they're ready to implement.\n\nSave to \\`openspec/changes/<name>/tasks.md\\`.\n\n---\n\n## Phase 9: Apply (Implementation)\n\n**EXPLAIN:**\n\\`\\`\\`\n## Implementation\n\nNow 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.\n\\`\\`\\`\n\n**DO:** For each task:\n\n1. Announce: \"Working on task N: [description]\"\n2. Implement the change in the codebase\n3. Reference specs/design naturally: \"The spec says X, so I'm doing Y\"\n4. Mark complete in tasks.md: \\`- [ ]\\` → \\`- [x]\\`\n5. Brief status: \"✓ Task N complete\"\n\nKeep narration light—don't over-explain every line of code.\n\nAfter all tasks:\n\n\\`\\`\\`\n## Implementation Complete\n\nAll tasks done:\n- [x] Task 1\n- [x] Task 2\n- [x] ...\n\nThe change is implemented! One more step—let's archive it.\n\\`\\`\\`\n\n---\n\n## Phase 10: Archive\n\n**EXPLAIN:**\n\\`\\`\\`\n## Archiving\n\nWhen a change is complete, we archive it. This moves it from \\`openspec/changes/\\` to \\`openspec/changes/archive/YYYY-MM-DD-<name>/\\`.\n\nArchived changes become your project's decision history—you can always find them later to understand why something was built a certain way.\n\\`\\`\\`\n\n**DO:**\n\\`\\`\\`bash\nopenspec archive \"<name>\"\n\\`\\`\\`\n\n**SHOW:**\n\\`\\`\\`\nArchived to: \\`openspec/changes/archive/YYYY-MM-DD-<name>/\\`\n\nThe change is now part of your project's history. The code is in your codebase, the decision record is preserved.\n\\`\\`\\`\n\n---\n\n## Phase 11: Recap & Next Steps\n\n\\`\\`\\`\n## Congratulations!\n\nYou just completed a full OpenSpec cycle:\n\n1. **Explore** - Thought through the problem\n2. **New** - Created a change container\n3. **Proposal** - Captured WHY\n4. **Specs** - Defined WHAT in detail\n5. **Design** - Decided HOW\n6. **Tasks** - Broke it into steps\n7. **Apply** - Implemented the work\n8. **Archive** - Preserved the record\n\nThis same rhythm works for any size change—a small fix or a major feature.\n\n---\n\n## Command Reference\n\n**Core workflow:**\n\n| Command | What it does |\n|---------|--------------|\n| \\`/opsx:propose\\` | Create a change and generate all artifacts |\n| \\`/opsx:explore\\` | Think through problems before/during work |\n| \\`/opsx:apply\\` | Implement tasks from a change |\n| \\`/opsx:archive\\` | Archive a completed change |\n\n**Additional commands:**\n\n| Command | What it does |\n|---------|--------------|\n| \\`/opsx:new\\` | Start a new change, step through artifacts one at a time |\n| \\`/opsx:continue\\` | Continue working on an existing change |\n| \\`/opsx:ff\\` | Fast-forward: create all artifacts at once |\n| \\`/opsx:verify\\` | Verify implementation matches artifacts |\n\n---\n\n## What's Next?\n\nTry \\`/opsx:propose\\` on something you actually want to build. You've got the rhythm now!\n\\`\\`\\`\n\n---\n\n## Graceful Exit Handling\n\n### User wants to stop mid-way\n\nIf the user says they need to stop, want to pause, or seem disengaged:\n\n\\`\\`\\`\nNo problem! Your change is saved at \\`openspec/changes/<name>/\\`.\n\nTo pick up where we left off later:\n- \\`/opsx:continue <name>\\` - Resume artifact creation\n- \\`/opsx:apply <name>\\` - Jump to implementation (if tasks exist)\n\nThe work won't be lost. Come back whenever you're ready.\n\\`\\`\\`\n\nExit gracefully without pressure.\n\n### User just wants command reference\n\nIf the user says they just want to see the commands or skip the tutorial:\n\n\\`\\`\\`\n## OpenSpec Quick Reference\n\n**Core workflow:**\n\n| Command | What it does |\n|---------|--------------|\n| \\`/opsx:propose <name>\\` | Create a change and generate all artifacts |\n| \\`/opsx:explore\\` | Think through problems (no code changes) |\n| \\`/opsx:apply <name>\\` | Implement tasks |\n| \\`/opsx:archive <name>\\` | Archive when done |\n\n**Additional commands:**\n\n| Command | What it does |\n|---------|--------------|\n| \\`/opsx:new <name>\\` | Start a new change, step by step |\n| \\`/opsx:continue <name>\\` | Continue an existing change |\n| \\`/opsx:ff <name>\\` | Fast-forward: all artifacts at once |\n| \\`/opsx:verify <name>\\` | Verify implementation |\n\nTry \\`/opsx:propose\\` to start your first change.\n\\`\\`\\`\n\nExit gracefully.\n\n---\n\n## Guardrails\n\n- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)\n- **Keep narration light** during implementation—teach without lecturing\n- **Don't skip phases** even if the change is small—the goal is teaching the workflow\n- **Pause for acknowledgment** at marked points, but don't over-pause\n- **Handle exits gracefully**—never pressure the user to continue\n- **Use real codebase tasks**—don't simulate or use fake examples\n- **Adjust scope gently**—guide toward smaller tasks but respect user choice`;\n}\n\nexport function getOpsxOnboardCommandTemplate(): CommandTemplate {\n  return {\n    name: 'OPSX: Onboard',\n    description: 'Guided onboarding - walk through a complete OpenSpec workflow cycle with narration',\n    category: 'Workflow',\n    tags: ['workflow', 'onboarding', 'tutorial', 'learning'],\n    content: getOnboardInstructions(),\n  };\n}\n"
  },
  {
    "path": "src/core/templates/workflows/propose.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate, CommandTemplate } from '../types.js';\n\nexport function getOpsxProposeSkillTemplate(): SkillTemplate {\n  return {\n    name: 'openspec-propose',\n    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.',\n    instructions: `Propose a new change - create the change and generate all artifacts in one step.\n\nI'll create a change with artifacts:\n- proposal.md (what & why)\n- design.md (how)\n- tasks.md (implementation steps)\n\nWhen ready to implement, run /opsx:apply\n\n---\n\n**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.\n\n**Steps**\n\n1. **If no clear input provided, ask what they want to build**\n\n   Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:\n   > \"What change do you want to work on? Describe what you want to build or fix.\"\n\n   From their description, derive a kebab-case name (e.g., \"add user authentication\" → \\`add-user-auth\\`).\n\n   **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.\n\n2. **Create the change directory**\n   \\`\\`\\`bash\n   openspec new change \"<name>\"\n   \\`\\`\\`\n   This creates a scaffolded change at \\`openspec/changes/<name>/\\` with \\`.openspec.yaml\\`.\n\n3. **Get the artifact build order**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\" --json\n   \\`\\`\\`\n   Parse the JSON to get:\n   - \\`applyRequires\\`: array of artifact IDs needed before implementation (e.g., \\`[\"tasks\"]\\`)\n   - \\`artifacts\\`: list of all artifacts with their status and dependencies\n\n4. **Create artifacts in sequence until apply-ready**\n\n   Use the **TodoWrite tool** to track progress through the artifacts.\n\n   Loop through artifacts in dependency order (artifacts with no pending dependencies first):\n\n   a. **For each artifact that is \\`ready\\` (dependencies satisfied)**:\n      - Get instructions:\n        \\`\\`\\`bash\n        openspec instructions <artifact-id> --change \"<name>\" --json\n        \\`\\`\\`\n      - The instructions JSON includes:\n        - \\`context\\`: Project background (constraints for you - do NOT include in output)\n        - \\`rules\\`: Artifact-specific rules (constraints for you - do NOT include in output)\n        - \\`template\\`: The structure to use for your output file\n        - \\`instruction\\`: Schema-specific guidance for this artifact type\n        - \\`outputPath\\`: Where to write the artifact\n        - \\`dependencies\\`: Completed artifacts to read for context\n      - Read any completed dependency files for context\n      - Create the artifact file using \\`template\\` as the structure\n      - Apply \\`context\\` and \\`rules\\` as constraints - but do NOT copy them into the file\n      - Show brief progress: \"Created <artifact-id>\"\n\n   b. **Continue until all \\`applyRequires\\` artifacts are complete**\n      - After creating each artifact, re-run \\`openspec status --change \"<name>\" --json\\`\n      - Check if every artifact ID in \\`applyRequires\\` has \\`status: \"done\"\\` in the artifacts array\n      - Stop when all \\`applyRequires\\` artifacts are done\n\n   c. **If an artifact requires user input** (unclear context):\n      - Use **AskUserQuestion tool** to clarify\n      - Then continue with creation\n\n5. **Show final status**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\"\n   \\`\\`\\`\n\n**Output**\n\nAfter completing all artifacts, summarize:\n- Change name and location\n- List of artifacts created with brief descriptions\n- What's ready: \"All artifacts created! Ready for implementation.\"\n- Prompt: \"Run \\`/opsx:apply\\` or ask me to implement to start working on the tasks.\"\n\n**Artifact Creation Guidelines**\n\n- Follow the \\`instruction\\` field from \\`openspec instructions\\` for each artifact type\n- The schema defines what each artifact should contain - follow it\n- Read dependency artifacts for context before creating new ones\n- Use \\`template\\` as the structure for your output file - fill in its sections\n- **IMPORTANT**: \\`context\\` and \\`rules\\` are constraints for YOU, not content for the file\n  - Do NOT copy \\`<context>\\`, \\`<rules>\\`, \\`<project_context>\\` blocks into the artifact\n  - These guide what you write, but should never appear in the output\n\n**Guardrails**\n- Create ALL artifacts needed for implementation (as defined by schema's \\`apply.requires\\`)\n- Always read dependency artifacts before creating a new one\n- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum\n- If a change with that name already exists, ask if user wants to continue it or create a new one\n- Verify each artifact file exists after writing before proceeding to next`,\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n\nexport function getOpsxProposeCommandTemplate(): CommandTemplate {\n  return {\n    name: 'OPSX: Propose',\n    description: 'Propose a new change - create it and generate all artifacts in one step',\n    category: 'Workflow',\n    tags: ['workflow', 'artifacts', 'experimental'],\n    content: `Propose a new change - create the change and generate all artifacts in one step.\n\nI'll create a change with artifacts:\n- proposal.md (what & why)\n- design.md (how)\n- tasks.md (implementation steps)\n\nWhen ready to implement, run /opsx:apply\n\n---\n\n**Input**: The argument after \\`/opsx:propose\\` is the change name (kebab-case), OR a description of what the user wants to build.\n\n**Steps**\n\n1. **If no input provided, ask what they want to build**\n\n   Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:\n   > \"What change do you want to work on? Describe what you want to build or fix.\"\n\n   From their description, derive a kebab-case name (e.g., \"add user authentication\" → \\`add-user-auth\\`).\n\n   **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.\n\n2. **Create the change directory**\n   \\`\\`\\`bash\n   openspec new change \"<name>\"\n   \\`\\`\\`\n   This creates a scaffolded change at \\`openspec/changes/<name>/\\` with \\`.openspec.yaml\\`.\n\n3. **Get the artifact build order**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\" --json\n   \\`\\`\\`\n   Parse the JSON to get:\n   - \\`applyRequires\\`: array of artifact IDs needed before implementation (e.g., \\`[\"tasks\"]\\`)\n   - \\`artifacts\\`: list of all artifacts with their status and dependencies\n\n4. **Create artifacts in sequence until apply-ready**\n\n   Use the **TodoWrite tool** to track progress through the artifacts.\n\n   Loop through artifacts in dependency order (artifacts with no pending dependencies first):\n\n   a. **For each artifact that is \\`ready\\` (dependencies satisfied)**:\n      - Get instructions:\n        \\`\\`\\`bash\n        openspec instructions <artifact-id> --change \"<name>\" --json\n        \\`\\`\\`\n      - The instructions JSON includes:\n        - \\`context\\`: Project background (constraints for you - do NOT include in output)\n        - \\`rules\\`: Artifact-specific rules (constraints for you - do NOT include in output)\n        - \\`template\\`: The structure to use for your output file\n        - \\`instruction\\`: Schema-specific guidance for this artifact type\n        - \\`outputPath\\`: Where to write the artifact\n        - \\`dependencies\\`: Completed artifacts to read for context\n      - Read any completed dependency files for context\n      - Create the artifact file using \\`template\\` as the structure\n      - Apply \\`context\\` and \\`rules\\` as constraints - but do NOT copy them into the file\n      - Show brief progress: \"Created <artifact-id>\"\n\n   b. **Continue until all \\`applyRequires\\` artifacts are complete**\n      - After creating each artifact, re-run \\`openspec status --change \"<name>\" --json\\`\n      - Check if every artifact ID in \\`applyRequires\\` has \\`status: \"done\"\\` in the artifacts array\n      - Stop when all \\`applyRequires\\` artifacts are done\n\n   c. **If an artifact requires user input** (unclear context):\n      - Use **AskUserQuestion tool** to clarify\n      - Then continue with creation\n\n5. **Show final status**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\"\n   \\`\\`\\`\n\n**Output**\n\nAfter completing all artifacts, summarize:\n- Change name and location\n- List of artifacts created with brief descriptions\n- What's ready: \"All artifacts created! Ready for implementation.\"\n- Prompt: \"Run \\`/opsx:apply\\` to start implementing.\"\n\n**Artifact Creation Guidelines**\n\n- Follow the \\`instruction\\` field from \\`openspec instructions\\` for each artifact type\n- The schema defines what each artifact should contain - follow it\n- Read dependency artifacts for context before creating new ones\n- Use \\`template\\` as the structure for your output file - fill in its sections\n- **IMPORTANT**: \\`context\\` and \\`rules\\` are constraints for YOU, not content for the file\n  - Do NOT copy \\`<context>\\`, \\`<rules>\\`, \\`<project_context>\\` blocks into the artifact\n  - These guide what you write, but should never appear in the output\n\n**Guardrails**\n- Create ALL artifacts needed for implementation (as defined by schema's \\`apply.requires\\`)\n- Always read dependency artifacts before creating a new one\n- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum\n- If a change with that name already exists, ask if user wants to continue it or create a new one\n- Verify each artifact file exists after writing before proceeding to next`\n  };\n}\n"
  },
  {
    "path": "src/core/templates/workflows/sync-specs.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate, CommandTemplate } from '../types.js';\n\nexport function getSyncSpecsSkillTemplate(): SkillTemplate {\n  return {\n    name: 'openspec-sync-specs',\n    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.',\n    instructions: `Sync delta specs from a change to main specs.\n\nThis 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).\n\n**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.\n\n**Steps**\n\n1. **If no change name provided, prompt for selection**\n\n   Run \\`openspec list --json\\` to get available changes. Use the **AskUserQuestion tool** to let the user select.\n\n   Show changes that have delta specs (under \\`specs/\\` directory).\n\n   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.\n\n2. **Find delta specs**\n\n   Look for delta spec files in \\`openspec/changes/<name>/specs/*/spec.md\\`.\n\n   Each delta spec file contains sections like:\n   - \\`## ADDED Requirements\\` - New requirements to add\n   - \\`## MODIFIED Requirements\\` - Changes to existing requirements\n   - \\`## REMOVED Requirements\\` - Requirements to remove\n   - \\`## RENAMED Requirements\\` - Requirements to rename (FROM:/TO: format)\n\n   If no delta specs found, inform user and stop.\n\n3. **For each delta spec, apply changes to main specs**\n\n   For each capability with a delta spec at \\`openspec/changes/<name>/specs/<capability>/spec.md\\`:\n\n   a. **Read the delta spec** to understand the intended changes\n\n   b. **Read the main spec** at \\`openspec/specs/<capability>/spec.md\\` (may not exist yet)\n\n   c. **Apply changes intelligently**:\n\n      **ADDED Requirements:**\n      - If requirement doesn't exist in main spec → add it\n      - If requirement already exists → update it to match (treat as implicit MODIFIED)\n\n      **MODIFIED Requirements:**\n      - Find the requirement in main spec\n      - Apply the changes - this can be:\n        - Adding new scenarios (don't need to copy existing ones)\n        - Modifying existing scenarios\n        - Changing the requirement description\n      - Preserve scenarios/content not mentioned in the delta\n\n      **REMOVED Requirements:**\n      - Remove the entire requirement block from main spec\n\n      **RENAMED Requirements:**\n      - Find the FROM requirement, rename to TO\n\n   d. **Create new main spec** if capability doesn't exist yet:\n      - Create \\`openspec/specs/<capability>/spec.md\\`\n      - Add Purpose section (can be brief, mark as TBD)\n      - Add Requirements section with the ADDED requirements\n\n4. **Show summary**\n\n   After applying all changes, summarize:\n   - Which capabilities were updated\n   - What changes were made (requirements added/modified/removed/renamed)\n\n**Delta Spec Format Reference**\n\n\\`\\`\\`markdown\n## ADDED Requirements\n\n### Requirement: New Feature\nThe system SHALL do something new.\n\n#### Scenario: Basic case\n- **WHEN** user does X\n- **THEN** system does Y\n\n## MODIFIED Requirements\n\n### Requirement: Existing Feature\n#### Scenario: New scenario to add\n- **WHEN** user does A\n- **THEN** system does B\n\n## REMOVED Requirements\n\n### Requirement: Deprecated Feature\n\n## RENAMED Requirements\n\n- FROM: \\`### Requirement: Old Name\\`\n- TO: \\`### Requirement: New Name\\`\n\\`\\`\\`\n\n**Key Principle: Intelligent Merging**\n\nUnlike programmatic merging, you can apply **partial updates**:\n- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios\n- The delta represents *intent*, not a wholesale replacement\n- Use your judgment to merge changes sensibly\n\n**Output On Success**\n\n\\`\\`\\`\n## Specs Synced: <change-name>\n\nUpdated main specs:\n\n**<capability-1>**:\n- Added requirement: \"New Feature\"\n- Modified requirement: \"Existing Feature\" (added 1 scenario)\n\n**<capability-2>**:\n- Created new spec file\n- Added requirement: \"Another Feature\"\n\nMain specs are now updated. The change remains active - archive when implementation is complete.\n\\`\\`\\`\n\n**Guardrails**\n- Read both delta and main specs before making changes\n- Preserve existing content not mentioned in delta\n- If something is unclear, ask for clarification\n- Show what you're changing as you go\n- The operation should be idempotent - running twice should give same result`,\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n\nexport function getOpsxSyncCommandTemplate(): CommandTemplate {\n  return {\n    name: 'OPSX: Sync',\n    description: 'Sync delta specs from a change to main specs',\n    category: 'Workflow',\n    tags: ['workflow', 'specs', 'experimental'],\n    content: `Sync delta specs from a change to main specs.\n\nThis 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).\n\n**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.\n\n**Steps**\n\n1. **If no change name provided, prompt for selection**\n\n   Run \\`openspec list --json\\` to get available changes. Use the **AskUserQuestion tool** to let the user select.\n\n   Show changes that have delta specs (under \\`specs/\\` directory).\n\n   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.\n\n2. **Find delta specs**\n\n   Look for delta spec files in \\`openspec/changes/<name>/specs/*/spec.md\\`.\n\n   Each delta spec file contains sections like:\n   - \\`## ADDED Requirements\\` - New requirements to add\n   - \\`## MODIFIED Requirements\\` - Changes to existing requirements\n   - \\`## REMOVED Requirements\\` - Requirements to remove\n   - \\`## RENAMED Requirements\\` - Requirements to rename (FROM:/TO: format)\n\n   If no delta specs found, inform user and stop.\n\n3. **For each delta spec, apply changes to main specs**\n\n   For each capability with a delta spec at \\`openspec/changes/<name>/specs/<capability>/spec.md\\`:\n\n   a. **Read the delta spec** to understand the intended changes\n\n   b. **Read the main spec** at \\`openspec/specs/<capability>/spec.md\\` (may not exist yet)\n\n   c. **Apply changes intelligently**:\n\n      **ADDED Requirements:**\n      - If requirement doesn't exist in main spec → add it\n      - If requirement already exists → update it to match (treat as implicit MODIFIED)\n\n      **MODIFIED Requirements:**\n      - Find the requirement in main spec\n      - Apply the changes - this can be:\n        - Adding new scenarios (don't need to copy existing ones)\n        - Modifying existing scenarios\n        - Changing the requirement description\n      - Preserve scenarios/content not mentioned in the delta\n\n      **REMOVED Requirements:**\n      - Remove the entire requirement block from main spec\n\n      **RENAMED Requirements:**\n      - Find the FROM requirement, rename to TO\n\n   d. **Create new main spec** if capability doesn't exist yet:\n      - Create \\`openspec/specs/<capability>/spec.md\\`\n      - Add Purpose section (can be brief, mark as TBD)\n      - Add Requirements section with the ADDED requirements\n\n4. **Show summary**\n\n   After applying all changes, summarize:\n   - Which capabilities were updated\n   - What changes were made (requirements added/modified/removed/renamed)\n\n**Delta Spec Format Reference**\n\n\\`\\`\\`markdown\n## ADDED Requirements\n\n### Requirement: New Feature\nThe system SHALL do something new.\n\n#### Scenario: Basic case\n- **WHEN** user does X\n- **THEN** system does Y\n\n## MODIFIED Requirements\n\n### Requirement: Existing Feature\n#### Scenario: New scenario to add\n- **WHEN** user does A\n- **THEN** system does B\n\n## REMOVED Requirements\n\n### Requirement: Deprecated Feature\n\n## RENAMED Requirements\n\n- FROM: \\`### Requirement: Old Name\\`\n- TO: \\`### Requirement: New Name\\`\n\\`\\`\\`\n\n**Key Principle: Intelligent Merging**\n\nUnlike programmatic merging, you can apply **partial updates**:\n- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios\n- The delta represents *intent*, not a wholesale replacement\n- Use your judgment to merge changes sensibly\n\n**Output On Success**\n\n\\`\\`\\`\n## Specs Synced: <change-name>\n\nUpdated main specs:\n\n**<capability-1>**:\n- Added requirement: \"New Feature\"\n- Modified requirement: \"Existing Feature\" (added 1 scenario)\n\n**<capability-2>**:\n- Created new spec file\n- Added requirement: \"Another Feature\"\n\nMain specs are now updated. The change remains active - archive when implementation is complete.\n\\`\\`\\`\n\n**Guardrails**\n- Read both delta and main specs before making changes\n- Preserve existing content not mentioned in delta\n- If something is unclear, ask for clarification\n- Show what you're changing as you go\n- The operation should be idempotent - running twice should give same result`\n  };\n}\n"
  },
  {
    "path": "src/core/templates/workflows/verify-change.ts",
    "content": "/**\n * Skill Template Workflow Modules\n *\n * This file is generated by splitting the legacy monolithic\n * templates file into workflow-focused modules.\n */\nimport type { SkillTemplate, CommandTemplate } from '../types.js';\n\nexport function getVerifyChangeSkillTemplate(): SkillTemplate {\n  return {\n    name: 'openspec-verify-change',\n    description: 'Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.',\n    instructions: `Verify that an implementation matches the change artifacts (specs, tasks, design).\n\n**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.\n\n**Steps**\n\n1. **If no change name provided, prompt for selection**\n\n   Run \\`openspec list --json\\` to get available changes. Use the **AskUserQuestion tool** to let the user select.\n\n   Show changes that have implementation tasks (tasks artifact exists).\n   Include the schema used for each change if available.\n   Mark changes with incomplete tasks as \"(In Progress)\".\n\n   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.\n\n2. **Check status to understand the schema**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\" --json\n   \\`\\`\\`\n   Parse the JSON to understand:\n   - \\`schemaName\\`: The workflow being used (e.g., \"spec-driven\")\n   - Which artifacts exist for this change\n\n3. **Get the change directory and load artifacts**\n\n   \\`\\`\\`bash\n   openspec instructions apply --change \"<name>\" --json\n   \\`\\`\\`\n\n   This returns the change directory and context files. Read all available artifacts from \\`contextFiles\\`.\n\n4. **Initialize verification report structure**\n\n   Create a report structure with three dimensions:\n   - **Completeness**: Track tasks and spec coverage\n   - **Correctness**: Track requirement implementation and scenario coverage\n   - **Coherence**: Track design adherence and pattern consistency\n\n   Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.\n\n5. **Verify Completeness**\n\n   **Task Completion**:\n   - If tasks.md exists in contextFiles, read it\n   - Parse checkboxes: \\`- [ ]\\` (incomplete) vs \\`- [x]\\` (complete)\n   - Count complete vs total tasks\n   - If incomplete tasks exist:\n     - Add CRITICAL issue for each incomplete task\n     - Recommendation: \"Complete task: <description>\" or \"Mark as done if already implemented\"\n\n   **Spec Coverage**:\n   - If delta specs exist in \\`openspec/changes/<name>/specs/\\`:\n     - Extract all requirements (marked with \"### Requirement:\")\n     - For each requirement:\n       - Search codebase for keywords related to the requirement\n       - Assess if implementation likely exists\n     - If requirements appear unimplemented:\n       - Add CRITICAL issue: \"Requirement not found: <requirement name>\"\n       - Recommendation: \"Implement requirement X: <description>\"\n\n6. **Verify Correctness**\n\n   **Requirement Implementation Mapping**:\n   - For each requirement from delta specs:\n     - Search codebase for implementation evidence\n     - If found, note file paths and line ranges\n     - Assess if implementation matches requirement intent\n     - If divergence detected:\n       - Add WARNING: \"Implementation may diverge from spec: <details>\"\n       - Recommendation: \"Review <file>:<lines> against requirement X\"\n\n   **Scenario Coverage**:\n   - For each scenario in delta specs (marked with \"#### Scenario:\"):\n     - Check if conditions are handled in code\n     - Check if tests exist covering the scenario\n     - If scenario appears uncovered:\n       - Add WARNING: \"Scenario not covered: <scenario name>\"\n       - Recommendation: \"Add test or implementation for scenario: <description>\"\n\n7. **Verify Coherence**\n\n   **Design Adherence**:\n   - If design.md exists in contextFiles:\n     - Extract key decisions (look for sections like \"Decision:\", \"Approach:\", \"Architecture:\")\n     - Verify implementation follows those decisions\n     - If contradiction detected:\n       - Add WARNING: \"Design decision not followed: <decision>\"\n       - Recommendation: \"Update implementation or revise design.md to match reality\"\n   - If no design.md: Skip design adherence check, note \"No design.md to verify against\"\n\n   **Code Pattern Consistency**:\n   - Review new code for consistency with project patterns\n   - Check file naming, directory structure, coding style\n   - If significant deviations found:\n     - Add SUGGESTION: \"Code pattern deviation: <details>\"\n     - Recommendation: \"Consider following project pattern: <example>\"\n\n8. **Generate Verification Report**\n\n   **Summary Scorecard**:\n   \\`\\`\\`\n   ## Verification Report: <change-name>\n\n   ### Summary\n   | Dimension    | Status           |\n   |--------------|------------------|\n   | Completeness | X/Y tasks, N reqs|\n   | Correctness  | M/N reqs covered |\n   | Coherence    | Followed/Issues  |\n   \\`\\`\\`\n\n   **Issues by Priority**:\n\n   1. **CRITICAL** (Must fix before archive):\n      - Incomplete tasks\n      - Missing requirement implementations\n      - Each with specific, actionable recommendation\n\n   2. **WARNING** (Should fix):\n      - Spec/design divergences\n      - Missing scenario coverage\n      - Each with specific recommendation\n\n   3. **SUGGESTION** (Nice to fix):\n      - Pattern inconsistencies\n      - Minor improvements\n      - Each with specific recommendation\n\n   **Final Assessment**:\n   - If CRITICAL issues: \"X critical issue(s) found. Fix before archiving.\"\n   - If only warnings: \"No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements).\"\n   - If all clear: \"All checks passed. Ready for archive.\"\n\n**Verification Heuristics**\n\n- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)\n- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty\n- **Coherence**: Look for glaring inconsistencies, don't nitpick style\n- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL\n- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable\n\n**Graceful Degradation**\n\n- If only tasks.md exists: verify task completion only, skip spec/design checks\n- If tasks + specs exist: verify completeness and correctness, skip design\n- If full artifacts: verify all three dimensions\n- Always note which checks were skipped and why\n\n**Output Format**\n\nUse clear markdown with:\n- Table for summary scorecard\n- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)\n- Code references in format: \\`file.ts:123\\`\n- Specific, actionable recommendations\n- No vague suggestions like \"consider reviewing\"`,\n    license: 'MIT',\n    compatibility: 'Requires openspec CLI.',\n    metadata: { author: 'openspec', version: '1.0' },\n  };\n}\n\nexport function getOpsxVerifyCommandTemplate(): CommandTemplate {\n  return {\n    name: 'OPSX: Verify',\n    description: 'Verify implementation matches change artifacts before archiving',\n    category: 'Workflow',\n    tags: ['workflow', 'verify', 'experimental'],\n    content: `Verify that an implementation matches the change artifacts (specs, tasks, design).\n\n**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.\n\n**Steps**\n\n1. **If no change name provided, prompt for selection**\n\n   Run \\`openspec list --json\\` to get available changes. Use the **AskUserQuestion tool** to let the user select.\n\n   Show changes that have implementation tasks (tasks artifact exists).\n   Include the schema used for each change if available.\n   Mark changes with incomplete tasks as \"(In Progress)\".\n\n   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.\n\n2. **Check status to understand the schema**\n   \\`\\`\\`bash\n   openspec status --change \"<name>\" --json\n   \\`\\`\\`\n   Parse the JSON to understand:\n   - \\`schemaName\\`: The workflow being used (e.g., \"spec-driven\")\n   - Which artifacts exist for this change\n\n3. **Get the change directory and load artifacts**\n\n   \\`\\`\\`bash\n   openspec instructions apply --change \"<name>\" --json\n   \\`\\`\\`\n\n   This returns the change directory and context files. Read all available artifacts from \\`contextFiles\\`.\n\n4. **Initialize verification report structure**\n\n   Create a report structure with three dimensions:\n   - **Completeness**: Track tasks and spec coverage\n   - **Correctness**: Track requirement implementation and scenario coverage\n   - **Coherence**: Track design adherence and pattern consistency\n\n   Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.\n\n5. **Verify Completeness**\n\n   **Task Completion**:\n   - If tasks.md exists in contextFiles, read it\n   - Parse checkboxes: \\`- [ ]\\` (incomplete) vs \\`- [x]\\` (complete)\n   - Count complete vs total tasks\n   - If incomplete tasks exist:\n     - Add CRITICAL issue for each incomplete task\n     - Recommendation: \"Complete task: <description>\" or \"Mark as done if already implemented\"\n\n   **Spec Coverage**:\n   - If delta specs exist in \\`openspec/changes/<name>/specs/\\`:\n     - Extract all requirements (marked with \"### Requirement:\")\n     - For each requirement:\n       - Search codebase for keywords related to the requirement\n       - Assess if implementation likely exists\n     - If requirements appear unimplemented:\n       - Add CRITICAL issue: \"Requirement not found: <requirement name>\"\n       - Recommendation: \"Implement requirement X: <description>\"\n\n6. **Verify Correctness**\n\n   **Requirement Implementation Mapping**:\n   - For each requirement from delta specs:\n     - Search codebase for implementation evidence\n     - If found, note file paths and line ranges\n     - Assess if implementation matches requirement intent\n     - If divergence detected:\n       - Add WARNING: \"Implementation may diverge from spec: <details>\"\n       - Recommendation: \"Review <file>:<lines> against requirement X\"\n\n   **Scenario Coverage**:\n   - For each scenario in delta specs (marked with \"#### Scenario:\"):\n     - Check if conditions are handled in code\n     - Check if tests exist covering the scenario\n     - If scenario appears uncovered:\n       - Add WARNING: \"Scenario not covered: <scenario name>\"\n       - Recommendation: \"Add test or implementation for scenario: <description>\"\n\n7. **Verify Coherence**\n\n   **Design Adherence**:\n   - If design.md exists in contextFiles:\n     - Extract key decisions (look for sections like \"Decision:\", \"Approach:\", \"Architecture:\")\n     - Verify implementation follows those decisions\n     - If contradiction detected:\n       - Add WARNING: \"Design decision not followed: <decision>\"\n       - Recommendation: \"Update implementation or revise design.md to match reality\"\n   - If no design.md: Skip design adherence check, note \"No design.md to verify against\"\n\n   **Code Pattern Consistency**:\n   - Review new code for consistency with project patterns\n   - Check file naming, directory structure, coding style\n   - If significant deviations found:\n     - Add SUGGESTION: \"Code pattern deviation: <details>\"\n     - Recommendation: \"Consider following project pattern: <example>\"\n\n8. **Generate Verification Report**\n\n   **Summary Scorecard**:\n   \\`\\`\\`\n   ## Verification Report: <change-name>\n\n   ### Summary\n   | Dimension    | Status           |\n   |--------------|------------------|\n   | Completeness | X/Y tasks, N reqs|\n   | Correctness  | M/N reqs covered |\n   | Coherence    | Followed/Issues  |\n   \\`\\`\\`\n\n   **Issues by Priority**:\n\n   1. **CRITICAL** (Must fix before archive):\n      - Incomplete tasks\n      - Missing requirement implementations\n      - Each with specific, actionable recommendation\n\n   2. **WARNING** (Should fix):\n      - Spec/design divergences\n      - Missing scenario coverage\n      - Each with specific recommendation\n\n   3. **SUGGESTION** (Nice to fix):\n      - Pattern inconsistencies\n      - Minor improvements\n      - Each with specific recommendation\n\n   **Final Assessment**:\n   - If CRITICAL issues: \"X critical issue(s) found. Fix before archiving.\"\n   - If only warnings: \"No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements).\"\n   - If all clear: \"All checks passed. Ready for archive.\"\n\n**Verification Heuristics**\n\n- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)\n- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty\n- **Coherence**: Look for glaring inconsistencies, don't nitpick style\n- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL\n- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable\n\n**Graceful Degradation**\n\n- If only tasks.md exists: verify task completion only, skip spec/design checks\n- If tasks + specs exist: verify completeness and correctness, skip design\n- If full artifacts: verify all three dimensions\n- Always note which checks were skipped and why\n\n**Output Format**\n\nUse clear markdown with:\n- Table for summary scorecard\n- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)\n- Code references in format: \\`file.ts:123\\`\n- Specific, actionable recommendations\n- No vague suggestions like \"consider reviewing\"`\n  };\n}\n"
  },
  {
    "path": "src/core/update.ts",
    "content": "/**\n * Update Command\n *\n * Refreshes OpenSpec skills and commands for configured tools.\n * Supports profile-aware updates, delivery changes, migration, and smart update detection.\n */\n\nimport path from 'path';\nimport chalk from 'chalk';\nimport ora from 'ora';\nimport * as fs from 'fs';\nimport { createRequire } from 'module';\nimport { FileSystemUtils } from '../utils/file-system.js';\nimport { transformToHyphenCommands } from '../utils/command-references.js';\nimport { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';\nimport {\n  generateCommands,\n  CommandAdapterRegistry,\n} from './command-generation/index.js';\nimport {\n  getToolVersionStatus,\n  getSkillTemplates,\n  getCommandContents,\n  generateSkillContent,\n  getToolsWithSkillsDir,\n  type ToolVersionStatus,\n} from './shared/index.js';\nimport {\n  detectLegacyArtifacts,\n  cleanupLegacyArtifacts,\n  formatCleanupSummary,\n  formatDetectionSummary,\n  getToolsFromLegacyArtifacts,\n  type LegacyDetectionResult,\n} from './legacy-cleanup.js';\nimport { isInteractive } from '../utils/interactive.js';\nimport { getGlobalConfig, type Delivery } from './global-config.js';\nimport { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';\nimport { getAvailableTools } from './available-tools.js';\nimport {\n  WORKFLOW_TO_SKILL_DIR,\n  getCommandConfiguredTools,\n  getConfiguredToolsForProfileSync,\n  getToolsNeedingProfileSync,\n} from './profile-sync-drift.js';\nimport {\n  scanInstalledWorkflows as scanInstalledWorkflowsShared,\n  migrateIfNeeded as migrateIfNeededShared,\n} from './migration.js';\n\nconst require = createRequire(import.meta.url);\nconst { version: OPENSPEC_VERSION } = require('../../package.json');\n\n/**\n * Options for the update command.\n */\nexport interface UpdateCommandOptions {\n  /** Force update even when tools are up to date */\n  force?: boolean;\n}\n\n/**\n * Scans installed workflow artifacts (skills and managed commands) across all configured tools.\n * Returns the union of detected workflow IDs that match ALL_WORKFLOWS.\n *\n * Wrapper around the shared migration module's scanInstalledWorkflows that accepts tool IDs.\n */\nexport function scanInstalledWorkflows(projectPath: string, toolIds: string[]): string[] {\n  const tools = toolIds\n    .map((id) => AI_TOOLS.find((t) => t.value === id))\n    .filter((t): t is NonNullable<typeof t> => t != null);\n  return scanInstalledWorkflowsShared(projectPath, tools);\n}\n\nexport class UpdateCommand {\n  private readonly force: boolean;\n\n  constructor(options: UpdateCommandOptions = {}) {\n    this.force = options.force ?? false;\n  }\n\n  async execute(projectPath: string): Promise<void> {\n    const resolvedProjectPath = path.resolve(projectPath);\n    const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME);\n\n    // 1. Check openspec directory exists\n    if (!await FileSystemUtils.directoryExists(openspecPath)) {\n      throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);\n    }\n\n    // 2. Perform one-time migration if needed before any legacy upgrade generation.\n    // Use detected tool directories to preserve existing opsx skills/commands.\n    const detectedTools = getAvailableTools(resolvedProjectPath);\n    migrateIfNeededShared(resolvedProjectPath, detectedTools);\n\n    // 3. Read global config for profile/delivery\n    const globalConfig = getGlobalConfig();\n    const profile = globalConfig.profile ?? 'core';\n    const delivery: Delivery = globalConfig.delivery ?? 'both';\n    const profileWorkflows = getProfileWorkflows(profile, globalConfig.workflows);\n    const desiredWorkflows = profileWorkflows.filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] =>\n      (ALL_WORKFLOWS as readonly string[]).includes(workflow)\n    );\n    const shouldGenerateSkills = delivery !== 'commands';\n    const shouldGenerateCommands = delivery !== 'skills';\n\n    // 4. Detect and handle legacy artifacts + upgrade legacy tools using effective config\n    const newlyConfiguredTools = await this.handleLegacyCleanup(\n      resolvedProjectPath,\n      desiredWorkflows,\n      delivery\n    );\n\n    // 5. Find configured tools\n    const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath);\n\n    if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {\n      console.log(chalk.yellow('No configured tools found.'));\n      console.log(chalk.dim('Run \"openspec init\" to set up tools.'));\n      return;\n    }\n\n    // 6. Check version status for all configured tools\n    const commandConfiguredTools = getCommandConfiguredTools(resolvedProjectPath);\n    const commandConfiguredSet = new Set(commandConfiguredTools);\n    const toolStatuses = configuredTools.map((toolId) => {\n      const status = getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION);\n      if (!status.configured && commandConfiguredSet.has(toolId)) {\n        return { ...status, configured: true };\n      }\n      return status;\n    });\n    const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status] as const));\n\n    // 7. Smart update detection\n    const toolsNeedingVersionUpdate = toolStatuses\n      .filter((s) => s.needsUpdate)\n      .map((s) => s.toolId);\n    const toolsNeedingConfigSync = getToolsNeedingProfileSync(\n      resolvedProjectPath,\n      desiredWorkflows,\n      delivery,\n      configuredTools\n    );\n    const toolsToUpdateSet = new Set<string>([\n      ...toolsNeedingVersionUpdate,\n      ...toolsNeedingConfigSync,\n    ]);\n    const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId));\n\n    if (!this.force && toolsToUpdateSet.size === 0) {\n      // All tools are up to date\n      this.displayUpToDateMessage(toolStatuses);\n\n      // Still check for new tool directories and extra workflows\n      this.detectNewTools(resolvedProjectPath, configuredTools);\n      this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);\n      return;\n    }\n\n    // 8. Display update plan\n    if (this.force) {\n      console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);\n    } else {\n      this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate);\n    }\n    console.log();\n\n    // 9. Determine what to generate based on delivery\n    const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : [];\n    const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : [];\n\n    // 10. Update tools (all if force, otherwise only those needing update)\n    const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet];\n    const updatedTools: string[] = [];\n    const failedTools: Array<{ name: string; error: string }> = [];\n    let removedCommandCount = 0;\n    let removedSkillCount = 0;\n    let removedDeselectedCommandCount = 0;\n    let removedDeselectedSkillCount = 0;\n\n    for (const toolId of toolsToUpdate) {\n      const tool = AI_TOOLS.find((t) => t.value === toolId);\n      if (!tool?.skillsDir) continue;\n\n      const spinner = ora(`Updating ${tool.name}...`).start();\n\n      try {\n        const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills');\n\n        // Generate skill files if delivery includes skills\n        if (shouldGenerateSkills) {\n          for (const { template, dirName } of skillTemplates) {\n            const skillDir = path.join(skillsDir, dirName);\n            const skillFile = path.join(skillDir, 'SKILL.md');\n\n            // Use hyphen-based command references for OpenCode\n            const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;\n            const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);\n            await FileSystemUtils.writeFile(skillFile, skillContent);\n          }\n\n          removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows);\n        }\n\n        // Delete skill directories if delivery is commands-only\n        if (!shouldGenerateSkills) {\n          removedSkillCount += await this.removeSkillDirs(skillsDir);\n        }\n\n        // Generate commands if delivery includes commands\n        if (shouldGenerateCommands) {\n          const adapter = CommandAdapterRegistry.get(tool.value);\n          if (adapter) {\n            const generatedCommands = generateCommands(commandContents, adapter);\n\n            for (const cmd of generatedCommands) {\n              const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);\n              await FileSystemUtils.writeFile(commandFile, cmd.fileContent);\n            }\n\n            removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(\n              resolvedProjectPath,\n              toolId,\n              desiredWorkflows\n            );\n          }\n        }\n\n        // Delete command files if delivery is skills-only\n        if (!shouldGenerateCommands) {\n          removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId);\n        }\n\n        spinner.succeed(`Updated ${tool.name}`);\n        updatedTools.push(tool.name);\n      } catch (error) {\n        spinner.fail(`Failed to update ${tool.name}`);\n        failedTools.push({\n          name: tool.name,\n          error: error instanceof Error ? error.message : String(error)\n        });\n      }\n    }\n\n    // 11. Summary\n    console.log();\n    if (updatedTools.length > 0) {\n      console.log(chalk.green(`✓ Updated: ${updatedTools.join(', ')} (v${OPENSPEC_VERSION})`));\n    }\n    if (failedTools.length > 0) {\n      console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`));\n    }\n    if (removedCommandCount > 0) {\n      console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`));\n    }\n    if (removedSkillCount > 0) {\n      console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`));\n    }\n    if (removedDeselectedCommandCount > 0) {\n      console.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`));\n    }\n    if (removedDeselectedSkillCount > 0) {\n      console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`));\n    }\n\n    // 12. Show onboarding message for newly configured tools from legacy upgrade\n    if (newlyConfiguredTools.length > 0) {\n      console.log();\n      console.log(chalk.bold('Getting started:'));\n      console.log('  /opsx:new       Start a new change');\n      console.log('  /opsx:continue  Create the next artifact');\n      console.log('  /opsx:apply     Implement tasks');\n      console.log();\n      console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);\n    }\n\n    const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];\n\n    // 13. Detect new tool directories not currently configured\n    this.detectNewTools(resolvedProjectPath, configuredAndNewTools);\n\n    // 14. Display note about extra workflows not in profile\n    this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);\n\n    // 15. List affected tools\n    if (updatedTools.length > 0) {\n      const toolDisplayNames = updatedTools;\n      console.log(chalk.dim(`Tools: ${toolDisplayNames.join(', ')}`));\n    }\n\n    console.log();\n    console.log(chalk.dim('Restart your IDE for changes to take effect.'));\n  }\n\n  /**\n   * Display message when all tools are up to date.\n   */\n  private displayUpToDateMessage(toolStatuses: ToolVersionStatus[]): void {\n    const toolNames = toolStatuses.map((s) => s.toolId);\n    console.log(chalk.green(`✓ All ${toolStatuses.length} tool(s) up to date (v${OPENSPEC_VERSION})`));\n    console.log(chalk.dim(`  Tools: ${toolNames.join(', ')}`));\n    console.log();\n    console.log(chalk.dim('Use --force to refresh files anyway.'));\n  }\n\n  /**\n   * Display the update plan showing which tools need updating.\n   */\n  private displayUpdatePlan(\n    toolsToUpdate: string[],\n    statusByTool: Map<string, ToolVersionStatus>,\n    upToDate: ToolVersionStatus[]\n  ): void {\n    const updates = toolsToUpdate.map((toolId) => {\n      const status = statusByTool.get(toolId);\n      if (status?.needsUpdate) {\n        const fromVersion = status.generatedByVersion ?? 'unknown';\n        return `${status.toolId} (${fromVersion} → ${OPENSPEC_VERSION})`;\n      }\n      return `${toolId} (config sync)`;\n    });\n\n    console.log(`Updating ${toolsToUpdate.length} tool(s): ${updates.join(', ')}`);\n\n    if (upToDate.length > 0) {\n      const upToDateNames = upToDate.map((s) => s.toolId);\n      console.log(chalk.dim(`Already up to date: ${upToDateNames.join(', ')}`));\n    }\n  }\n\n  /**\n   * Detects new tool directories that aren't currently configured and displays a hint.\n   */\n  private detectNewTools(projectPath: string, configuredTools: string[]): void {\n    const availableTools = getAvailableTools(projectPath);\n    const configuredSet = new Set(configuredTools);\n\n    const newTools = availableTools.filter((t) => !configuredSet.has(t.value));\n\n    if (newTools.length > 0) {\n      const newToolNames = newTools.map((tool) => tool.name);\n      const isSingleTool = newToolNames.length === 1;\n      const toolNoun = isSingleTool ? 'tool' : 'tools';\n      const pronoun = isSingleTool ? 'it' : 'them';\n      console.log();\n      console.log(\n        chalk.yellow(\n          `Detected new ${toolNoun}: ${newToolNames.join(', ')}. Run 'openspec init' to add ${pronoun}.`\n        )\n      );\n    }\n  }\n\n  /**\n   * Displays a note about extra workflows installed that aren't in the current profile.\n   */\n  private displayExtraWorkflowsNote(\n    projectPath: string,\n    configuredTools: string[],\n    profileWorkflows: readonly string[]\n  ): void {\n    const installedWorkflows = scanInstalledWorkflows(projectPath, configuredTools);\n    const profileSet = new Set(profileWorkflows);\n    const extraWorkflows = installedWorkflows.filter((w) => !profileSet.has(w));\n\n    if (extraWorkflows.length > 0) {\n      console.log(chalk.dim(`Note: ${extraWorkflows.length} extra workflows not in profile (use \\`openspec config profile\\` to manage)`));\n    }\n  }\n\n  /**\n   * Removes skill directories for workflows when delivery changed to commands-only.\n   * Returns the number of directories removed.\n   */\n  private async removeSkillDirs(skillsDir: string): Promise<number> {\n    let removed = 0;\n\n    for (const workflow of ALL_WORKFLOWS) {\n      const dirName = WORKFLOW_TO_SKILL_DIR[workflow];\n      if (!dirName) continue;\n\n      const skillDir = path.join(skillsDir, dirName);\n      try {\n        if (fs.existsSync(skillDir)) {\n          await fs.promises.rm(skillDir, { recursive: true, force: true });\n          removed++;\n        }\n      } catch {\n        // Ignore errors\n      }\n    }\n\n    return removed;\n  }\n\n  /**\n   * Removes skill directories for workflows that are no longer selected in the active profile.\n   * Returns the number of directories removed.\n   */\n  private async removeUnselectedSkillDirs(\n    skillsDir: string,\n    desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][]\n  ): Promise<number> {\n    const desiredSet = new Set(desiredWorkflows);\n    let removed = 0;\n\n    for (const workflow of ALL_WORKFLOWS) {\n      if (desiredSet.has(workflow)) continue;\n      const dirName = WORKFLOW_TO_SKILL_DIR[workflow];\n      if (!dirName) continue;\n\n      const skillDir = path.join(skillsDir, dirName);\n      try {\n        if (fs.existsSync(skillDir)) {\n          await fs.promises.rm(skillDir, { recursive: true, force: true });\n          removed++;\n        }\n      } catch {\n        // Ignore errors\n      }\n    }\n\n    return removed;\n  }\n\n  /**\n   * Removes command files for workflows when delivery changed to skills-only.\n   * Returns the number of files removed.\n   */\n  private async removeCommandFiles(\n    projectPath: string,\n    toolId: string,\n  ): Promise<number> {\n    let removed = 0;\n\n    const adapter = CommandAdapterRegistry.get(toolId);\n    if (!adapter) return 0;\n\n    for (const workflow of ALL_WORKFLOWS) {\n      const cmdPath = adapter.getFilePath(workflow);\n      const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);\n\n      try {\n        if (fs.existsSync(fullPath)) {\n          await fs.promises.unlink(fullPath);\n          removed++;\n        }\n      } catch {\n        // Ignore errors\n      }\n    }\n\n    return removed;\n  }\n\n  /**\n   * Removes command files for workflows that are no longer selected in the active profile.\n   * Returns the number of files removed.\n   */\n  private async removeUnselectedCommandFiles(\n    projectPath: string,\n    toolId: string,\n    desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][]\n  ): Promise<number> {\n    let removed = 0;\n\n    const adapter = CommandAdapterRegistry.get(toolId);\n    if (!adapter) return 0;\n\n    const desiredSet = new Set(desiredWorkflows);\n\n    for (const workflow of ALL_WORKFLOWS) {\n      if (desiredSet.has(workflow)) continue;\n      const cmdPath = adapter.getFilePath(workflow);\n      const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);\n\n      try {\n        if (fs.existsSync(fullPath)) {\n          await fs.promises.unlink(fullPath);\n          removed++;\n        }\n      } catch {\n        // Ignore errors\n      }\n    }\n\n    return removed;\n  }\n\n  /**\n   * Detect and handle legacy OpenSpec artifacts.\n   * Unlike init, update warns but continues if legacy files found in non-interactive mode.\n   * Returns array of tool IDs that were newly configured during legacy upgrade.\n   */\n  private async handleLegacyCleanup(\n    projectPath: string,\n    desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][],\n    delivery: Delivery\n  ): Promise<string[]> {\n    // Detect legacy artifacts\n    const detection = await detectLegacyArtifacts(projectPath);\n\n    if (!detection.hasLegacyArtifacts) {\n      return []; // No legacy artifacts found\n    }\n\n    // Show what was detected\n    console.log();\n    console.log(formatDetectionSummary(detection));\n    console.log();\n\n    const canPrompt = isInteractive();\n\n    if (this.force) {\n      // --force flag: proceed with cleanup automatically\n      await this.performLegacyCleanup(projectPath, detection);\n      // Then upgrade legacy tools to new skills\n      return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);\n    }\n\n    if (!canPrompt) {\n      // Non-interactive mode without --force: warn and continue\n      // (Unlike init, update doesn't abort - user may just want to update skills)\n      console.log(chalk.yellow('⚠ Run with --force to auto-cleanup legacy files, or run interactively.'));\n      console.log();\n      return [];\n    }\n\n    // Interactive mode: prompt for confirmation\n    const { confirm } = await import('@inquirer/prompts');\n    const shouldCleanup = await confirm({\n      message: 'Upgrade and clean up legacy files?',\n      default: true,\n    });\n\n    if (shouldCleanup) {\n      await this.performLegacyCleanup(projectPath, detection);\n      // Then upgrade legacy tools to new skills\n      return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);\n    } else {\n      console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...'));\n      console.log();\n      return [];\n    }\n  }\n\n  /**\n   * Perform cleanup of legacy artifacts.\n   */\n  private async performLegacyCleanup(projectPath: string, detection: LegacyDetectionResult): Promise<void> {\n    const spinner = ora('Cleaning up legacy files...').start();\n\n    const result = await cleanupLegacyArtifacts(projectPath, detection);\n\n    spinner.succeed('Legacy files cleaned up');\n\n    const summary = formatCleanupSummary(result);\n    if (summary) {\n      console.log();\n      console.log(summary);\n    }\n\n    console.log();\n  }\n\n  /**\n   * Upgrade legacy tools to new skills system.\n   * Returns array of tool IDs that were newly configured.\n   */\n  private async upgradeLegacyTools(\n    projectPath: string,\n    detection: LegacyDetectionResult,\n    canPrompt: boolean,\n    desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][],\n    delivery: Delivery\n  ): Promise<string[]> {\n    // Get tools that had legacy artifacts\n    const legacyTools = getToolsFromLegacyArtifacts(detection);\n\n    if (legacyTools.length === 0) {\n      return [];\n    }\n\n    // Get currently configured tools\n    const configuredTools = getConfiguredToolsForProfileSync(projectPath);\n    const configuredSet = new Set(configuredTools);\n\n    // Filter to tools that aren't already configured\n    const unconfiguredLegacyTools = legacyTools.filter((t) => !configuredSet.has(t));\n\n    if (unconfiguredLegacyTools.length === 0) {\n      return [];\n    }\n\n    // Get valid tools (those with skillsDir)\n    const validToolIds = new Set(getToolsWithSkillsDir());\n    const validUnconfiguredTools = unconfiguredLegacyTools.filter((t) => validToolIds.has(t));\n\n    if (validUnconfiguredTools.length === 0) {\n      return [];\n    }\n\n    // Show what tools were detected from legacy artifacts\n    console.log(chalk.bold('Tools detected from legacy artifacts:'));\n    for (const toolId of validUnconfiguredTools) {\n      const tool = AI_TOOLS.find((t) => t.value === toolId);\n      console.log(`  • ${tool?.name || toolId}`);\n    }\n    console.log();\n\n    let selectedTools: string[];\n\n    if (this.force || !canPrompt) {\n      // Non-interactive with --force: auto-select detected tools\n      selectedTools = validUnconfiguredTools;\n      console.log(`Setting up skills for: ${selectedTools.join(', ')}`);\n    } else {\n      // Interactive mode: prompt for tool selection with detected tools pre-selected\n      const { searchableMultiSelect } = await import('../prompts/searchable-multi-select.js');\n\n      const sortedChoices = validUnconfiguredTools.map((toolId) => {\n        const tool = AI_TOOLS.find((t) => t.value === toolId);\n        return {\n          name: tool?.name || toolId,\n          value: toolId,\n          configured: false,\n          preSelected: true, // Pre-select all detected legacy tools\n        };\n      });\n\n      selectedTools = await searchableMultiSelect({\n        message: 'Select tools to set up with the new skill system:',\n        pageSize: 15,\n        choices: sortedChoices,\n        validate: (_selected: string[]) => true, // Allow empty selection (user can skip)\n      });\n\n      if (selectedTools.length === 0) {\n        console.log(chalk.dim('Skipping tool setup.'));\n        console.log();\n        return [];\n      }\n    }\n\n    // Create skills/commands for selected tools using effective profile+delivery.\n    const newlyConfigured: string[] = [];\n    const shouldGenerateSkills = delivery !== 'commands';\n    const shouldGenerateCommands = delivery !== 'skills';\n    const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : [];\n    const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : [];\n\n    for (const toolId of selectedTools) {\n      const tool = AI_TOOLS.find((t) => t.value === toolId);\n      if (!tool?.skillsDir) continue;\n\n      const spinner = ora(`Setting up ${tool.name}...`).start();\n\n      try {\n        const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');\n\n        // Create skill files when delivery includes skills\n        if (shouldGenerateSkills) {\n          for (const { template, dirName } of skillTemplates) {\n            const skillDir = path.join(skillsDir, dirName);\n            const skillFile = path.join(skillDir, 'SKILL.md');\n\n            // Use hyphen-based command references for OpenCode\n            const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;\n            const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);\n            await FileSystemUtils.writeFile(skillFile, skillContent);\n          }\n        }\n\n        // Create commands when delivery includes commands\n        if (shouldGenerateCommands) {\n          const adapter = CommandAdapterRegistry.get(tool.value);\n          if (adapter) {\n            const generatedCommands = generateCommands(commandContents, adapter);\n\n            for (const cmd of generatedCommands) {\n              const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);\n              await FileSystemUtils.writeFile(commandFile, cmd.fileContent);\n            }\n          }\n        }\n\n        spinner.succeed(`Setup complete for ${tool.name}`);\n        newlyConfigured.push(toolId);\n      } catch (error) {\n        spinner.fail(`Failed to set up ${tool.name}`);\n        console.log(chalk.red(`  ${error instanceof Error ? error.message : String(error)}`));\n      }\n    }\n\n    if (newlyConfigured.length > 0) {\n      console.log();\n    }\n\n    return newlyConfigured;\n  }\n}\n"
  },
  {
    "path": "src/core/validation/constants.ts",
    "content": "/**\n * Validation threshold constants\n */\n\n// Minimum character lengths\nexport const MIN_WHY_SECTION_LENGTH = 50;\nexport const MIN_PURPOSE_LENGTH = 50;\n\n// Maximum character/item limits\nexport const MAX_WHY_SECTION_LENGTH = 1000;\nexport const MAX_REQUIREMENT_TEXT_LENGTH = 500;\nexport const MAX_DELTAS_PER_CHANGE = 10;\n\n// Validation messages\nexport const VALIDATION_MESSAGES = {\n  // Required content\n  SCENARIO_EMPTY: 'Scenario text cannot be empty',\n  REQUIREMENT_EMPTY: 'Requirement text cannot be empty',\n  REQUIREMENT_NO_SHALL: 'Requirement must contain SHALL or MUST keyword',\n  REQUIREMENT_NO_SCENARIOS: 'Requirement must have at least one scenario',\n  SPEC_NAME_EMPTY: 'Spec name cannot be empty',\n  SPEC_PURPOSE_EMPTY: 'Purpose section cannot be empty',\n  SPEC_NO_REQUIREMENTS: 'Spec must have at least one requirement',\n  CHANGE_NAME_EMPTY: 'Change name cannot be empty',\n  CHANGE_WHY_TOO_SHORT: `Why section must be at least ${MIN_WHY_SECTION_LENGTH} characters`,\n  CHANGE_WHY_TOO_LONG: `Why section should not exceed ${MAX_WHY_SECTION_LENGTH} characters`,\n  CHANGE_WHAT_EMPTY: 'What Changes section cannot be empty',\n  CHANGE_NO_DELTAS: 'Change must have at least one delta',\n  CHANGE_TOO_MANY_DELTAS: `Consider splitting changes with more than ${MAX_DELTAS_PER_CHANGE} deltas`,\n  DELTA_SPEC_EMPTY: 'Spec name cannot be empty',\n  DELTA_DESCRIPTION_EMPTY: 'Delta description cannot be empty',\n  \n  // Warnings\n  PURPOSE_TOO_BRIEF: `Purpose section is too brief (less than ${MIN_PURPOSE_LENGTH} characters)`,\n  REQUIREMENT_TOO_LONG: `Requirement text is very long (>${MAX_REQUIREMENT_TEXT_LENGTH} characters). Consider breaking it down.`,\n  DELTA_DESCRIPTION_TOO_BRIEF: 'Delta description is too brief',\n  DELTA_MISSING_REQUIREMENTS: 'Delta should include requirements',\n  \n  // Guidance snippets (appended to primary messages for remediation)\n  GUIDE_NO_DELTAS:\n    '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.',\n  GUIDE_MISSING_SPEC_SECTIONS:\n    '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** ...',\n  GUIDE_MISSING_CHANGE_SECTIONS:\n    'Missing required sections. Expected headers: \"## Why\" and \"## What Changes\". Ensure deltas are documented in specs/ using delta headers.',\n  GUIDE_SCENARIO_FORMAT:\n    'Scenarios must use level-4 headers. Convert bullet lists into:\\n#### Scenario: Short name\\n- **WHEN** ...\\n- **THEN** ...\\n- **AND** ...',\n} as const;\n"
  },
  {
    "path": "src/core/validation/types.ts",
    "content": "export type ValidationLevel = 'ERROR' | 'WARNING' | 'INFO';\n\nexport interface ValidationIssue {\n  level: ValidationLevel;\n  path: string;\n  message: string;\n  line?: number;\n  column?: number;\n}\n\nexport interface ValidationReport {\n  valid: boolean;\n  issues: ValidationIssue[];\n  summary: {\n    errors: number;\n    warnings: number;\n    info: number;\n  };\n}"
  },
  {
    "path": "src/core/validation/validator.ts",
    "content": "import { z, ZodError } from 'zod';\nimport { readFileSync, promises as fs } from 'fs';\nimport path from 'path';\nimport { SpecSchema, ChangeSchema, Spec, Change } from '../schemas/index.js';\nimport { MarkdownParser } from '../parsers/markdown-parser.js';\nimport { ChangeParser } from '../parsers/change-parser.js';\nimport { ValidationReport, ValidationIssue, ValidationLevel } from './types.js';\nimport {\n  MIN_PURPOSE_LENGTH,\n  MAX_REQUIREMENT_TEXT_LENGTH,\n  VALIDATION_MESSAGES\n} from './constants.js';\nimport { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';\nimport { FileSystemUtils } from '../../utils/file-system.js';\n\nexport class Validator {\n  private strictMode: boolean;\n\n  constructor(strictMode: boolean = false) {\n    this.strictMode = strictMode;\n  }\n\n  async validateSpec(filePath: string): Promise<ValidationReport> {\n    const issues: ValidationIssue[] = [];\n    const specName = this.extractNameFromPath(filePath);\n    try {\n      const content = readFileSync(filePath, 'utf-8');\n      const parser = new MarkdownParser(content);\n      \n      const spec = parser.parseSpec(specName);\n      \n      const result = SpecSchema.safeParse(spec);\n      \n      if (!result.success) {\n        issues.push(...this.convertZodErrors(result.error));\n      }\n      \n      issues.push(...this.applySpecRules(spec, content));\n      \n    } catch (error) {\n      const baseMessage = error instanceof Error ? error.message : 'Unknown error';\n      const enriched = this.enrichTopLevelError(specName, baseMessage);\n      issues.push({\n        level: 'ERROR',\n        path: 'file',\n        message: enriched,\n      });\n    }\n    \n    return this.createReport(issues);\n  }\n\n  /**\n   * Validate spec content from a string (used for pre-write validation of rebuilt specs)\n   */\n  async validateSpecContent(specName: string, content: string): Promise<ValidationReport> {\n    const issues: ValidationIssue[] = [];\n    try {\n      const parser = new MarkdownParser(content);\n      const spec = parser.parseSpec(specName);\n      const result = SpecSchema.safeParse(spec);\n      if (!result.success) {\n        issues.push(...this.convertZodErrors(result.error));\n      }\n      issues.push(...this.applySpecRules(spec, content));\n    } catch (error) {\n      const baseMessage = error instanceof Error ? error.message : 'Unknown error';\n      const enriched = this.enrichTopLevelError(specName, baseMessage);\n      issues.push({ level: 'ERROR', path: 'file', message: enriched });\n    }\n    return this.createReport(issues);\n  }\n\n  async validateChange(filePath: string): Promise<ValidationReport> {\n    const issues: ValidationIssue[] = [];\n    const changeName = this.extractNameFromPath(filePath);\n    try {\n      const content = readFileSync(filePath, 'utf-8');\n      const changeDir = path.dirname(filePath);\n      const parser = new ChangeParser(content, changeDir);\n      \n      const change = await parser.parseChangeWithDeltas(changeName);\n      \n      const result = ChangeSchema.safeParse(change);\n      \n      if (!result.success) {\n        issues.push(...this.convertZodErrors(result.error));\n      }\n      \n      issues.push(...this.applyChangeRules(change, content));\n      \n    } catch (error) {\n      const baseMessage = error instanceof Error ? error.message : 'Unknown error';\n      const enriched = this.enrichTopLevelError(changeName, baseMessage);\n      issues.push({\n        level: 'ERROR',\n        path: 'file',\n        message: enriched,\n      });\n    }\n    \n    return this.createReport(issues);\n  }\n\n  /**\n   * Validate delta-formatted spec files under a change directory.\n   * Enforces:\n   * - At least one delta across all files\n   * - ADDED/MODIFIED: each requirement has SHALL/MUST and at least one scenario\n   * - REMOVED: names only; no scenario/description required\n   * - RENAMED: pairs well-formed\n   * - No duplicates within sections; no cross-section conflicts per spec\n   */\n  async validateChangeDeltaSpecs(changeDir: string): Promise<ValidationReport> {\n    const issues: ValidationIssue[] = [];\n    const specsDir = path.join(changeDir, 'specs');\n    let totalDeltas = 0;\n    const missingHeaderSpecs: string[] = [];\n    const emptySectionSpecs: Array<{ path: string; sections: string[] }> = [];\n\n    try {\n      const entries = await fs.readdir(specsDir, { withFileTypes: true });\n      for (const entry of entries) {\n        if (!entry.isDirectory()) continue;\n        const specName = entry.name;\n        const specFile = path.join(specsDir, specName, 'spec.md');\n        let content: string | undefined;\n        try {\n          content = await fs.readFile(specFile, 'utf-8');\n        } catch {\n          continue;\n        }\n\n        const plan = parseDeltaSpec(content);\n        const entryPath = `${specName}/spec.md`;\n        const sectionNames: string[] = [];\n        if (plan.sectionPresence.added) sectionNames.push('## ADDED Requirements');\n        if (plan.sectionPresence.modified) sectionNames.push('## MODIFIED Requirements');\n        if (plan.sectionPresence.removed) sectionNames.push('## REMOVED Requirements');\n        if (plan.sectionPresence.renamed) sectionNames.push('## RENAMED Requirements');\n        const hasSections = sectionNames.length > 0;\n        const hasEntries = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;\n        if (!hasEntries) {\n          if (hasSections) emptySectionSpecs.push({ path: entryPath, sections: sectionNames });\n          else missingHeaderSpecs.push(entryPath);\n        }\n\n        const addedNames = new Set<string>();\n        const modifiedNames = new Set<string>();\n        const removedNames = new Set<string>();\n        const renamedFrom = new Set<string>();\n        const renamedTo = new Set<string>();\n\n        // Validate ADDED\n        for (const block of plan.added) {\n          const key = normalizeRequirementName(block.name);\n          totalDeltas++;\n          if (addedNames.has(key)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in ADDED: \"${block.name}\"` });\n          } else {\n            addedNames.add(key);\n          }\n          const requirementText = this.extractRequirementText(block.raw);\n          if (!requirementText) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `ADDED \"${block.name}\" is missing requirement text` });\n          } else if (!this.containsShallOrMust(requirementText)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `ADDED \"${block.name}\" must contain SHALL or MUST` });\n          }\n          const scenarioCount = this.countScenarios(block.raw);\n          if (scenarioCount < 1) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `ADDED \"${block.name}\" must include at least one scenario` });\n          }\n        }\n\n        // Validate MODIFIED\n        for (const block of plan.modified) {\n          const key = normalizeRequirementName(block.name);\n          totalDeltas++;\n          if (modifiedNames.has(key)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in MODIFIED: \"${block.name}\"` });\n          } else {\n            modifiedNames.add(key);\n          }\n          const requirementText = this.extractRequirementText(block.raw);\n          if (!requirementText) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED \"${block.name}\" is missing requirement text` });\n          } else if (!this.containsShallOrMust(requirementText)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED \"${block.name}\" must contain SHALL or MUST` });\n          }\n          const scenarioCount = this.countScenarios(block.raw);\n          if (scenarioCount < 1) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED \"${block.name}\" must include at least one scenario` });\n          }\n        }\n\n        // Validate REMOVED (names only)\n        for (const name of plan.removed) {\n          const key = normalizeRequirementName(name);\n          totalDeltas++;\n          if (removedNames.has(key)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in REMOVED: \"${name}\"` });\n          } else {\n            removedNames.add(key);\n          }\n        }\n\n        // Validate RENAMED pairs\n        for (const { from, to } of plan.renamed) {\n          const fromKey = normalizeRequirementName(from);\n          const toKey = normalizeRequirementName(to);\n          totalDeltas++;\n          if (renamedFrom.has(fromKey)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate FROM in RENAMED: \"${from}\"` });\n          } else {\n            renamedFrom.add(fromKey);\n          }\n          if (renamedTo.has(toKey)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate TO in RENAMED: \"${to}\"` });\n          } else {\n            renamedTo.add(toKey);\n          }\n        }\n\n        // Cross-section conflicts (within the same spec file)\n        for (const n of modifiedNames) {\n          if (removedNames.has(n)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `Requirement present in both MODIFIED and REMOVED: \"${n}\"` });\n          }\n          if (addedNames.has(n)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `Requirement present in both MODIFIED and ADDED: \"${n}\"` });\n          }\n        }\n        for (const n of addedNames) {\n          if (removedNames.has(n)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `Requirement present in both ADDED and REMOVED: \"${n}\"` });\n          }\n        }\n        for (const { from, to } of plan.renamed) {\n          const fromKey = normalizeRequirementName(from);\n          const toKey = normalizeRequirementName(to);\n          if (modifiedNames.has(fromKey)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED references old name from RENAMED. Use new header for \"${to}\"` });\n          }\n          if (addedNames.has(toKey)) {\n            issues.push({ level: 'ERROR', path: entryPath, message: `RENAMED TO collides with ADDED for \"${to}\"` });\n          }\n        }\n      }\n    } catch {\n      // If no specs dir, treat as no deltas\n    }\n\n    for (const { path: specPath, sections } of emptySectionSpecs) {\n      issues.push({\n        level: 'ERROR',\n        path: specPath,\n        message: `Delta sections ${this.formatSectionList(sections)} were found, but no requirement entries parsed. Ensure each section includes at least one \"### Requirement:\" block (REMOVED may use bullet list syntax).`,\n      });\n    }\n    for (const path of missingHeaderSpecs) {\n      issues.push({\n        level: 'ERROR',\n        path,\n        message: 'No delta sections found. Add headers such as \"## ADDED Requirements\" or move non-delta notes outside specs/.',\n      });\n    }\n\n    if (totalDeltas === 0) {\n      issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });\n    }\n\n    return this.createReport(issues);\n  }\n\n  private convertZodErrors(error: ZodError): ValidationIssue[] {\n    return error.issues.map(err => {\n      let message = err.message;\n      if (message === VALIDATION_MESSAGES.CHANGE_NO_DELTAS) {\n        message = `${message}. ${VALIDATION_MESSAGES.GUIDE_NO_DELTAS}`;\n      }\n      return {\n        level: 'ERROR' as ValidationLevel,\n        path: err.path.join('.'),\n        message,\n      };\n    });\n  }\n\n  private applySpecRules(spec: Spec, content: string): ValidationIssue[] {\n    const issues: ValidationIssue[] = [];\n    \n    if (spec.overview.length < MIN_PURPOSE_LENGTH) {\n      issues.push({\n        level: 'WARNING',\n        path: 'overview',\n        message: VALIDATION_MESSAGES.PURPOSE_TOO_BRIEF,\n      });\n    }\n    \n    spec.requirements.forEach((req, index) => {\n      if (req.text.length > MAX_REQUIREMENT_TEXT_LENGTH) {\n        issues.push({\n          level: 'INFO',\n          path: `requirements[${index}]`,\n          message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,\n        });\n      }\n      \n      if (req.scenarios.length === 0) {\n        issues.push({\n          level: 'WARNING',\n          path: `requirements[${index}].scenarios`,\n          message: `${VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS}. ${VALIDATION_MESSAGES.GUIDE_SCENARIO_FORMAT}`,\n        });\n      }\n    });\n    \n    return issues;\n  }\n\n  private applyChangeRules(change: Change, content: string): ValidationIssue[] {\n    const issues: ValidationIssue[] = [];\n    \n    const MIN_DELTA_DESCRIPTION_LENGTH = 10;\n    \n    change.deltas.forEach((delta, index) => {\n      if (!delta.description || delta.description.length < MIN_DELTA_DESCRIPTION_LENGTH) {\n        issues.push({\n          level: 'WARNING',\n          path: `deltas[${index}].description`,\n          message: VALIDATION_MESSAGES.DELTA_DESCRIPTION_TOO_BRIEF,\n        });\n      }\n      \n      if ((delta.operation === 'ADDED' || delta.operation === 'MODIFIED') && \n          (!delta.requirements || delta.requirements.length === 0)) {\n        issues.push({\n          level: 'WARNING',\n          path: `deltas[${index}].requirements`,\n          message: `${delta.operation} ${VALIDATION_MESSAGES.DELTA_MISSING_REQUIREMENTS}`,\n        });\n      }\n    });\n    \n    return issues;\n  }\n\n  private enrichTopLevelError(itemId: string, baseMessage: string): string {\n    const msg = baseMessage.trim();\n    if (msg === VALIDATION_MESSAGES.CHANGE_NO_DELTAS) {\n      return `${msg}. ${VALIDATION_MESSAGES.GUIDE_NO_DELTAS}`;\n    }\n    if (msg.includes('Spec must have a Purpose section') || msg.includes('Spec must have a Requirements section')) {\n      return `${msg}. ${VALIDATION_MESSAGES.GUIDE_MISSING_SPEC_SECTIONS}`;\n    }\n    if (msg.includes('Change must have a Why section') || msg.includes('Change must have a What Changes section')) {\n      return `${msg}. ${VALIDATION_MESSAGES.GUIDE_MISSING_CHANGE_SECTIONS}`;\n    }\n    return msg;\n  }\n\n  private extractNameFromPath(filePath: string): string {\n    const normalizedPath = FileSystemUtils.toPosixPath(filePath);\n    const parts = normalizedPath.split('/');\n    \n    // Look for the directory name after 'specs' or 'changes'\n    for (let i = parts.length - 1; i >= 0; i--) {\n      if (parts[i] === 'specs' || parts[i] === 'changes') {\n        if (i < parts.length - 1) {\n          return parts[i + 1];\n        }\n      }\n    }\n    \n    // Fallback to filename without extension if not in expected structure\n    const fileName = parts[parts.length - 1] ?? '';\n    const dotIndex = fileName.lastIndexOf('.');\n    return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;\n  }\n\n  private createReport(issues: ValidationIssue[]): ValidationReport {\n    const errors = issues.filter(i => i.level === 'ERROR').length;\n    const warnings = issues.filter(i => i.level === 'WARNING').length;\n    const info = issues.filter(i => i.level === 'INFO').length;\n    \n    const valid = this.strictMode \n      ? errors === 0 && warnings === 0\n      : errors === 0;\n    \n    return {\n      valid,\n      issues,\n      summary: {\n        errors,\n        warnings,\n        info,\n      },\n    };\n  }\n\n  isValid(report: ValidationReport): boolean {\n    return report.valid;\n  }\n\n  private extractRequirementText(blockRaw: string): string | undefined {\n    const lines = blockRaw.split('\\n');\n    // Skip header line (index 0)\n    let i = 1;\n\n    // Find the first substantial text line, skipping metadata and blank lines\n    for (; i < lines.length; i++) {\n      const line = lines[i];\n\n      // Stop at scenario headers\n      if (/^####\\s+/.test(line)) break;\n\n      const trimmed = line.trim();\n\n      // Skip blank lines\n      if (trimmed.length === 0) continue;\n\n      // Skip metadata lines (lines starting with ** like **ID**, **Priority**, etc.)\n      if (/^\\*\\*[^*]+\\*\\*:/.test(trimmed)) continue;\n\n      // Found first non-metadata, non-blank line - this is the requirement text\n      return trimmed;\n    }\n\n    // No requirement text found\n    return undefined;\n  }\n\n  private containsShallOrMust(text: string): boolean {\n    return /\\b(SHALL|MUST)\\b/.test(text);\n  }\n\n  private countScenarios(blockRaw: string): number {\n    const matches = blockRaw.match(/^####\\s+/gm);\n    return matches ? matches.length : 0;\n  }\n\n  private formatSectionList(sections: string[]): string {\n    if (sections.length === 0) return '';\n    if (sections.length === 1) return sections[0];\n    const head = sections.slice(0, -1);\n    const last = sections[sections.length - 1];\n    return `${head.join(', ')} and ${last}`;\n  }\n}\n"
  },
  {
    "path": "src/core/view.ts",
    "content": "import * as fs from 'fs';\nimport * as path from 'path';\nimport chalk from 'chalk';\nimport { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';\nimport { MarkdownParser } from './parsers/markdown-parser.js';\n\nexport class ViewCommand {\n  async execute(targetPath: string = '.'): Promise<void> {\n    const openspecDir = path.join(targetPath, 'openspec');\n    \n    if (!fs.existsSync(openspecDir)) {\n      console.error(chalk.red('No openspec directory found'));\n      process.exit(1);\n    }\n\n    console.log(chalk.bold('\\nOpenSpec Dashboard\\n'));\n    console.log('═'.repeat(60));\n\n    // Get changes and specs data\n    const changesData = await this.getChangesData(openspecDir);\n    const specsData = await this.getSpecsData(openspecDir);\n\n    // Display summary metrics\n    this.displaySummary(changesData, specsData);\n\n    // Display draft changes\n    if (changesData.draft.length > 0) {\n      console.log(chalk.bold.gray('\\nDraft Changes'));\n      console.log('─'.repeat(60));\n      changesData.draft.forEach((change) => {\n        console.log(`  ${chalk.gray('○')} ${change.name}`);\n      });\n    }\n\n    // Display active changes\n    if (changesData.active.length > 0) {\n      console.log(chalk.bold.cyan('\\nActive Changes'));\n      console.log('─'.repeat(60));\n      changesData.active.forEach((change) => {\n        const progressBar = this.createProgressBar(change.progress.completed, change.progress.total);\n        const percentage =\n          change.progress.total > 0\n            ? Math.round((change.progress.completed / change.progress.total) * 100)\n            : 0;\n\n        console.log(\n          `  ${chalk.yellow('◉')} ${chalk.bold(change.name.padEnd(30))} ${progressBar} ${chalk.dim(`${percentage}%`)}`\n        );\n      });\n    }\n\n    // Display completed changes\n    if (changesData.completed.length > 0) {\n      console.log(chalk.bold.green('\\nCompleted Changes'));\n      console.log('─'.repeat(60));\n      changesData.completed.forEach((change) => {\n        console.log(`  ${chalk.green('✓')} ${change.name}`);\n      });\n    }\n\n    // Display specifications\n    if (specsData.length > 0) {\n      console.log(chalk.bold.blue('\\nSpecifications'));\n      console.log('─'.repeat(60));\n      \n      // Sort specs by requirement count (descending)\n      specsData.sort((a, b) => b.requirementCount - a.requirementCount);\n      \n      specsData.forEach(spec => {\n        const reqLabel = spec.requirementCount === 1 ? 'requirement' : 'requirements';\n        console.log(\n          `  ${chalk.blue('▪')} ${chalk.bold(spec.name.padEnd(30))} ${chalk.dim(`${spec.requirementCount} ${reqLabel}`)}`\n        );\n      });\n    }\n\n    console.log('\\n' + '═'.repeat(60));\n    console.log(chalk.dim(`\\nUse ${chalk.white('openspec list --changes')} or ${chalk.white('openspec list --specs')} for detailed views`));\n  }\n\n  private async getChangesData(openspecDir: string): Promise<{\n    draft: Array<{ name: string }>;\n    active: Array<{ name: string; progress: { total: number; completed: number } }>;\n    completed: Array<{ name: string }>;\n  }> {\n    const changesDir = path.join(openspecDir, 'changes');\n\n    if (!fs.existsSync(changesDir)) {\n      return { draft: [], active: [], completed: [] };\n    }\n\n    const draft: Array<{ name: string }> = [];\n    const active: Array<{ name: string; progress: { total: number; completed: number } }> = [];\n    const completed: Array<{ name: string }> = [];\n\n    const entries = fs.readdirSync(changesDir, { withFileTypes: true });\n\n    for (const entry of entries) {\n      if (entry.isDirectory() && entry.name !== 'archive') {\n        const progress = await getTaskProgressForChange(changesDir, entry.name);\n\n        if (progress.total === 0) {\n          // No tasks defined yet - still in planning/draft phase\n          draft.push({ name: entry.name });\n        } else if (progress.completed === progress.total) {\n          // All tasks complete\n          completed.push({ name: entry.name });\n        } else {\n          // Has tasks but not all complete\n          active.push({ name: entry.name, progress });\n        }\n      }\n    }\n\n    // Sort all categories by name for deterministic ordering\n    draft.sort((a, b) => a.name.localeCompare(b.name));\n\n    // Sort active changes by completion percentage (ascending) and then by name\n    active.sort((a, b) => {\n      const percentageA = a.progress.total > 0 ? a.progress.completed / a.progress.total : 0;\n      const percentageB = b.progress.total > 0 ? b.progress.completed / b.progress.total : 0;\n\n      if (percentageA < percentageB) return -1;\n      if (percentageA > percentageB) return 1;\n      return a.name.localeCompare(b.name);\n    });\n    completed.sort((a, b) => a.name.localeCompare(b.name));\n\n    return { draft, active, completed };\n  }\n\n  private async getSpecsData(openspecDir: string): Promise<Array<{ name: string; requirementCount: number }>> {\n    const specsDir = path.join(openspecDir, 'specs');\n    \n    if (!fs.existsSync(specsDir)) {\n      return [];\n    }\n\n    const specs: Array<{ name: string; requirementCount: number }> = [];\n    const entries = fs.readdirSync(specsDir, { withFileTypes: true });\n    \n    for (const entry of entries) {\n      if (entry.isDirectory()) {\n        const specFile = path.join(specsDir, entry.name, 'spec.md');\n        \n        if (fs.existsSync(specFile)) {\n          try {\n            const content = fs.readFileSync(specFile, 'utf-8');\n            const parser = new MarkdownParser(content);\n            const spec = parser.parseSpec(entry.name);\n            const requirementCount = spec.requirements.length;\n            specs.push({ name: entry.name, requirementCount });\n          } catch (error) {\n            // If spec cannot be parsed, include with 0 count\n            specs.push({ name: entry.name, requirementCount: 0 });\n          }\n        }\n      }\n    }\n\n    return specs;\n  }\n\n  private displaySummary(\n    changesData: { draft: any[]; active: any[]; completed: any[] },\n    specsData: any[]\n  ): void {\n    const totalChanges =\n      changesData.draft.length + changesData.active.length + changesData.completed.length;\n    const totalSpecs = specsData.length;\n    const totalRequirements = specsData.reduce((sum, spec) => sum + spec.requirementCount, 0);\n\n    // Calculate total task progress\n    let totalTasks = 0;\n    let completedTasks = 0;\n\n    changesData.active.forEach((change) => {\n      totalTasks += change.progress.total;\n      completedTasks += change.progress.completed;\n    });\n\n    changesData.completed.forEach(() => {\n      // Completed changes count as 100% done (we don't know exact task count)\n      // This is a simplification\n    });\n\n    console.log(chalk.bold('Summary:'));\n    console.log(\n      `  ${chalk.cyan('●')} Specifications: ${chalk.bold(totalSpecs)} specs, ${chalk.bold(totalRequirements)} requirements`\n    );\n    if (changesData.draft.length > 0) {\n      console.log(`  ${chalk.gray('●')} Draft Changes: ${chalk.bold(changesData.draft.length)}`);\n    }\n    console.log(\n      `  ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`\n    );\n    console.log(`  ${chalk.green('●')} Completed Changes: ${chalk.bold(changesData.completed.length)}`);\n\n    if (totalTasks > 0) {\n      const overallProgress = Math.round((completedTasks / totalTasks) * 100);\n      console.log(\n        `  ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)`\n      );\n    }\n  }\n\n  private createProgressBar(completed: number, total: number, width: number = 20): string {\n    if (total === 0) return chalk.dim('─'.repeat(width));\n    \n    const percentage = completed / total;\n    const filled = Math.round(percentage * width);\n    const empty = width - filled;\n    \n    const filledBar = chalk.green('█'.repeat(filled));\n    const emptyBar = chalk.dim('░'.repeat(empty));\n    \n    return `[${filledBar}${emptyBar}]`;\n  }\n}"
  },
  {
    "path": "src/index.ts",
    "content": "export * from './cli/index.js';\nexport * from './core/index.js';"
  },
  {
    "path": "src/prompts/searchable-multi-select.ts",
    "content": "import chalk from 'chalk';\n\ninterface Choice {\n  name: string;\n  value: string;\n  description?: string;\n  configured?: boolean;\n  detected?: boolean;\n  configuredLabel?: string;\n  preSelected?: boolean;\n}\n\ninterface Config {\n  message: string;\n  choices: Choice[];\n  pageSize?: number;\n  validate?: (selected: string[]) => boolean | string;\n}\n\n/**\n * Create the searchable multi-select prompt.\n * Uses dynamic import to prevent pre-commit hook hangs (see #367).\n */\nasync function createSearchableMultiSelect(): Promise<\n  (config: Config) => Promise<string[]>\n> {\n  const {\n    createPrompt,\n    useState,\n    useKeypress,\n    useMemo,\n    usePrefix,\n    isEnterKey,\n    isBackspaceKey,\n    isUpKey,\n    isDownKey,\n  } = await import('@inquirer/core');\n\n  return createPrompt((config: Config, done: (value: string[]) => void): string => {\n    const { message, choices, pageSize = 15, validate } = config;\n\n    const [searchText, setSearchText] = useState('');\n    const [selectedValues, setSelectedValues] = useState<string[]>(\n      () => choices.filter(c => c.preSelected).map(c => c.value)\n    );\n    const [cursor, setCursor] = useState(0);\n    const [status, setStatus] = useState<'idle' | 'done'>('idle');\n    const [error, setError] = useState<string | null>(null);\n\n    const prefix = usePrefix({ status });\n\n    // Filter choices by search\n    const filteredChoices = useMemo(() => {\n      if (!searchText.trim()) return choices;\n      const term = searchText.toLowerCase();\n      return choices.filter(\n        (c) =>\n          c.name.toLowerCase().includes(term) ||\n          c.value.toLowerCase().includes(term)\n      );\n    }, [searchText, choices]);\n\n    const selectedSet = useMemo(() => new Set(selectedValues), [selectedValues]);\n    const choiceMap = useMemo(\n      () => new Map(choices.map((c) => [c.value, c])),\n      [choices]\n    );\n\n    useKeypress((key) => {\n      if (status === 'done') return;\n\n      // Enter to confirm/submit\n      if (isEnterKey(key)) {\n        if (validate) {\n          const result = validate(selectedValues);\n          if (result !== true) {\n            setError(typeof result === 'string' ? result : 'Invalid');\n            return;\n          }\n        }\n        setStatus('done');\n        done(selectedValues);\n        return;\n      }\n\n      // Space to toggle selection\n      if (key.name === 'space') {\n        const choice = filteredChoices[cursor];\n        if (choice) {\n          if (selectedSet.has(choice.value)) {\n            setSelectedValues(selectedValues.filter(v => v !== choice.value));\n          } else {\n            setSelectedValues([...selectedValues, choice.value]);\n          }\n        }\n        return;\n      }\n\n      // Backspace to remove or delete search char\n      if (isBackspaceKey(key)) {\n        if (searchText === '' && selectedValues.length > 0) {\n          setSelectedValues(selectedValues.slice(0, -1));\n        } else {\n          setSearchText(searchText.slice(0, -1));\n          setCursor(0);\n        }\n        return;\n      }\n\n      // Navigation\n      if (isUpKey(key)) {\n        setCursor(Math.max(0, cursor - 1));\n        return;\n      }\n      if (isDownKey(key)) {\n        setCursor(Math.min(filteredChoices.length - 1, cursor + 1));\n        return;\n      }\n\n      // Character input - handle printable characters\n      if (key.name && key.name.length === 1 && !key.ctrl) {\n        setSearchText(searchText + key.name);\n        setCursor(0);\n      }\n    });\n\n    // Render done state\n    if (status === 'done') {\n      const names = selectedValues\n        .map((v) => choiceMap.get(v)?.name ?? v)\n        .join(', ');\n      return `${prefix} ${chalk.bold(message)} ${chalk.cyan(names || '(none)')}`;\n    }\n\n    // Render active state\n    const lines: string[] = [];\n    lines.push(`${prefix} ${chalk.bold(message)}`);\n\n    // Selected chips\n    const chips =\n      selectedValues.length > 0\n        ? selectedValues\n            .map((v) => chalk.bgCyan.black(` ${choiceMap.get(v)?.name} `))\n            .join(' ')\n        : chalk.dim('(none selected)');\n    lines.push(`  Selected: ${chips}`);\n\n    // Search box\n    lines.push(\n      `  Search: ${chalk.yellow('[')}${searchText || chalk.dim('type to filter')}${chalk.yellow(']')}`\n    );\n\n    // Instructions\n    lines.push(\n      `  ${chalk.cyan('↑↓')} navigate • ${chalk.cyan('Space')} toggle • ${chalk.cyan('Backspace')} remove • ${chalk.cyan('Enter')} confirm`\n    );\n\n    // List\n    if (filteredChoices.length === 0) {\n      lines.push(chalk.yellow('  No matches'));\n    } else {\n      // Calculate pagination\n      const startIndex = Math.max(\n        0,\n        Math.min(cursor - Math.floor(pageSize / 2), filteredChoices.length - pageSize)\n      );\n      const endIndex = Math.min(startIndex + pageSize, filteredChoices.length);\n      const visibleChoices = filteredChoices.slice(startIndex, endIndex);\n\n      for (let i = 0; i < visibleChoices.length; i++) {\n        const item = visibleChoices[i];\n        const actualIndex = startIndex + i;\n        const isActive = actualIndex === cursor;\n        const selected = selectedSet.has(item.value);\n        const icon = selected ? chalk.green('◉') : chalk.dim('○');\n        const arrow = isActive ? chalk.cyan('›') : ' ';\n        const name = isActive ? chalk.cyan(item.name) : item.name;\n        const isRefresh = selected && item.configured;\n        const statusLabel = !selected\n          ? item.configured\n            ? ' (configured)'\n            : item.detected\n              ? ' (detected)'\n              : ''\n          : '';\n        const suffix = selected\n          ? chalk.dim(isRefresh ? ' (refresh)' : ' (selected)')\n          : chalk.dim(statusLabel);\n        lines.push(`  ${arrow} ${icon} ${name}${suffix}`);\n      }\n\n      // Show pagination indicator if needed\n      if (filteredChoices.length > pageSize) {\n        const currentPage = Math.floor(cursor / pageSize) + 1;\n        const totalPages = Math.ceil(filteredChoices.length / pageSize);\n        lines.push(chalk.dim(`  (${currentPage}/${totalPages})`));\n      }\n    }\n\n    if (error) lines.push(chalk.red(`  ${error}`));\n    return lines.join('\\n');\n  });\n}\n\n/**\n * A searchable multi-select prompt with visible search box,\n * selected items display, and intuitive keyboard navigation.\n *\n * - Type to filter choices\n * - ↑↓ to navigate\n * - Space to toggle highlighted item selection\n * - Backspace to remove last selected item (or delete search char)\n * - Enter to confirm selections\n */\nexport async function searchableMultiSelect(config: Config): Promise<string[]> {\n  const prompt = await createSearchableMultiSelect();\n  return prompt(config);\n}\n\nexport default searchableMultiSelect;\n"
  },
  {
    "path": "src/telemetry/config.ts",
    "content": "/**\n * Global configuration for telemetry state.\n * Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json\n */\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\n\nexport interface TelemetryConfig {\n  anonymousId?: string;\n  noticeSeen?: boolean;\n}\n\nexport interface GlobalConfig {\n  telemetry?: TelemetryConfig;\n  [key: string]: unknown; // Preserve other fields\n}\n\n/**\n * Get the path to the global config file.\n * Uses ~/.config/openspec/config.json on all platforms.\n */\nexport function getConfigPath(): string {\n  const configDir = path.join(os.homedir(), '.config', 'openspec');\n  return path.join(configDir, 'config.json');\n}\n\n/**\n * Read the global config file.\n * Returns an empty object if the file doesn't exist.\n */\nexport async function readConfig(): Promise<GlobalConfig> {\n  const configPath = getConfigPath();\n  try {\n    const content = await fs.readFile(configPath, 'utf-8');\n    return JSON.parse(content) as GlobalConfig;\n  } catch (error: unknown) {\n    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {\n      return {};\n    }\n    // If parse fails or other error, return empty config\n    return {};\n  }\n}\n\n/**\n * Write to the global config file.\n * Preserves existing fields and merges in new values.\n */\nexport async function writeConfig(updates: Partial<GlobalConfig>): Promise<void> {\n  const configPath = getConfigPath();\n  const configDir = path.dirname(configPath);\n\n  // Ensure directory exists\n  await fs.mkdir(configDir, { recursive: true });\n\n  // Read existing config and merge\n  const existing = await readConfig();\n  const merged = { ...existing, ...updates };\n\n  // Deep merge for telemetry object\n  if (updates.telemetry && existing.telemetry) {\n    merged.telemetry = { ...existing.telemetry, ...updates.telemetry };\n  }\n\n  await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\\n');\n}\n\n/**\n * Get the telemetry config section.\n */\nexport async function getTelemetryConfig(): Promise<TelemetryConfig> {\n  const config = await readConfig();\n  return config.telemetry ?? {};\n}\n\n/**\n * Update the telemetry config section.\n */\nexport async function updateTelemetryConfig(updates: Partial<TelemetryConfig>): Promise<void> {\n  const existing = await getTelemetryConfig();\n  await writeConfig({\n    telemetry: { ...existing, ...updates },\n  });\n}\n"
  },
  {
    "path": "src/telemetry/index.ts",
    "content": "/**\n * Telemetry module for anonymous usage analytics.\n *\n * Privacy-first design:\n * - Only tracks command name and version\n * - No arguments, file paths, or content\n * - Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1\n * - Auto-disabled in CI environments\n * - Anonymous ID is a random UUID with no relation to the user\n */\nimport { PostHog } from 'posthog-node';\nimport { randomUUID } from 'crypto';\nimport { getTelemetryConfig, updateTelemetryConfig } from './config.js';\n\n// PostHog API key - public key for client-side analytics\n// This is safe to embed as it only allows sending events, not reading data\nconst POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';\n// Using reverse proxy to avoid ad blockers and keep traffic on our domain\nconst POSTHOG_HOST = 'https://edge.openspec.dev';\n\nlet posthogClient: PostHog | null = null;\nlet anonymousId: string | null = null;\n\n/**\n * Check if telemetry is enabled.\n *\n * Disabled when:\n * - OPENSPEC_TELEMETRY=0\n * - DO_NOT_TRACK=1\n * - CI=true (any CI environment)\n */\nexport function isTelemetryEnabled(): boolean {\n  // Check explicit opt-out\n  if (process.env.OPENSPEC_TELEMETRY === '0') {\n    return false;\n  }\n\n  // Respect DO_NOT_TRACK standard\n  if (process.env.DO_NOT_TRACK === '1') {\n    return false;\n  }\n\n  // Auto-disable in CI environments\n  if (process.env.CI === 'true') {\n    return false;\n  }\n\n  return true;\n}\n\n/**\n * Get or create the anonymous user ID.\n * Lazily generates a UUID on first call and persists it.\n */\nexport async function getOrCreateAnonymousId(): Promise<string> {\n  // Return cached value if available\n  if (anonymousId) {\n    return anonymousId;\n  }\n\n  // Try to load from config\n  const config = await getTelemetryConfig();\n  if (config.anonymousId) {\n    anonymousId = config.anonymousId;\n    return anonymousId;\n  }\n\n  // Generate new UUID and persist\n  anonymousId = randomUUID();\n  await updateTelemetryConfig({ anonymousId });\n  return anonymousId;\n}\n\n/**\n * Get the PostHog client instance.\n * Creates it on first call with CLI-optimized settings.\n */\nfunction getClient(): PostHog {\n  if (!posthogClient) {\n    posthogClient = new PostHog(POSTHOG_API_KEY, {\n      host: POSTHOG_HOST,\n      flushAt: 1, // Send immediately, don't batch\n      flushInterval: 0, // No timer-based flushing\n    });\n  }\n  return posthogClient;\n}\n\n/**\n * Track a command execution.\n *\n * @param commandName - The command name (e.g., 'init', 'change:apply')\n * @param version - The OpenSpec version\n */\nexport async function trackCommand(commandName: string, version: string): Promise<void> {\n  if (!isTelemetryEnabled()) {\n    return;\n  }\n\n  try {\n    const userId = await getOrCreateAnonymousId();\n    const client = getClient();\n\n    client.capture({\n      distinctId: userId,\n      event: 'command_executed',\n      properties: {\n        command: commandName,\n        version: version,\n        surface: 'cli',\n        $ip: null, // Explicitly disable IP tracking\n      },\n    });\n  } catch {\n    // Silent failure - telemetry should never break CLI\n  }\n}\n\n/**\n * Show first-run telemetry notice if not already seen.\n */\nexport async function maybeShowTelemetryNotice(): Promise<void> {\n  if (!isTelemetryEnabled()) {\n    return;\n  }\n\n  try {\n    const config = await getTelemetryConfig();\n    if (config.noticeSeen) {\n      return;\n    }\n\n    // Display notice\n    console.log(\n      'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0'\n    );\n\n    // Mark as seen\n    await updateTelemetryConfig({ noticeSeen: true });\n  } catch {\n    // Silent failure - telemetry should never break CLI\n  }\n}\n\n/**\n * Shutdown the PostHog client and flush pending events.\n * Call this before CLI exit.\n */\nexport async function shutdown(): Promise<void> {\n  if (!posthogClient) {\n    return;\n  }\n\n  try {\n    await posthogClient.shutdown();\n  } catch {\n    // Silent failure - telemetry should never break CLI exit\n  } finally {\n    posthogClient = null;\n  }\n}\n"
  },
  {
    "path": "src/ui/ascii-patterns.ts",
    "content": "/**\n * ASCII art animation patterns for the welcome screen.\n * OpenSpec logo animation - diamond/rhombus shape with hollow center \"O\".\n */\n\n// Detect if full Unicode is supported\nconst supportsUnicode =\n  process.platform !== 'win32' ||\n  !!process.env.WT_SESSION || // Windows Terminal\n  !!process.env.TERM_PROGRAM; // Modern terminal\n\n// Character set based on Unicode support\n// Block characters for pixel-art aesthetic\nconst CHARS = supportsUnicode\n  ? { full: '██', dim: '░░', empty: '  ' }\n  : { full: '##', dim: '++', empty: '  ' };\n\nconst _ = CHARS.empty;\nconst F = CHARS.full;\nconst D = CHARS.dim;\n\n/**\n * Welcome animation frames - OpenSpec logo building from center\n * 7 rows × 6 columns diamond with hollow center \"O\"\n * Center bar is 2 cols × 3 rows (rows 3,4,5 cols 3,4)\n * Each frame is an array of strings (lines of ASCII art)\n * Grid: 6 cols × 2 chars = 12 chars wide\n */\nexport const WELCOME_ANIMATION = {\n  interval: 120,\n  frames: [\n    // Frame 1: Empty\n    [\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n    ],\n    // Frame 2: Center blocks appear (dim) - 2x3 center bar\n    [\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${D}${D}${_}${_}`,\n      `${_}${_}${_}${_}${D}${D}${_}${_}`,\n      `${_}${_}${_}${_}${D}${D}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n    ],\n    // Frame 3: Center blocks solidify\n    [\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n    ],\n    // Frame 4: Top and bottom points appear\n    [\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3\n      `${_}${_}${_}${_}${D}${D}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${_}${_}${_}${_}${_}`,\n      `${_}${_}${_}${_}${D}${D}${_}${_}`,\n    ],\n    // Frame 5: Inner ring forming\n    [\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${D}${_}${_}${D}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${D}${_}${_}${D}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n    ],\n    // Frame 6: Outer ring appearing\n    [\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${F}${_}${_}${F}${_}`,\n      `${_}${_}${D}${_}${F}${F}${_}${D}`,\n      `${_}${_}${D}${_}${F}${F}${_}${D}`,\n      `${_}${_}${D}${_}${F}${F}${_}${D}`,\n      `${_}${_}${_}${F}${_}${_}${F}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n    ],\n    // Frame 7: Full logo\n    [\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${F}${_}${_}${F}${_}`,\n      `${_}${_}${F}${_}${F}${F}${_}${F}`,\n      `${_}${_}${F}${_}${F}${F}${_}${F}`,\n      `${_}${_}${F}${_}${F}${F}${_}${F}`,\n      `${_}${_}${_}${F}${_}${_}${F}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n    ],\n    // Frame 8: Hold complete logo\n    [\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2\n      `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n      `${_}${_}${_}${F}${_}${_}${F}${_}`,\n      `${_}${_}${F}${_}${F}${F}${_}${F}`,\n      `${_}${_}${F}${_}${F}${F}${_}${F}`,\n      `${_}${_}${F}${_}${F}${F}${_}${F}`,\n      `${_}${_}${_}${F}${_}${_}${F}${_}`,\n      `${_}${_}${_}${_}${F}${F}${_}${_}`,\n    ],\n  ],\n};\n"
  },
  {
    "path": "src/ui/welcome-screen.ts",
    "content": "/**\n * Animated welcome screen for the experimental artifact workflow setup.\n * Shows side-by-side layout with animated ASCII art on left and welcome text on right.\n */\n\nimport chalk from 'chalk';\nimport { WELCOME_ANIMATION } from './ascii-patterns.js';\n\n// Minimum terminal width for side-by-side layout\nconst MIN_WIDTH = 60;\n\n// Width of the ASCII art column (with padding)\nconst ART_COLUMN_WIDTH = 24;\n\n/**\n * Welcome text content (right column)\n */\nfunction getWelcomeText(): string[] {\n  return [\n    chalk.white.bold('Welcome to OpenSpec'),\n    chalk.dim('A lightweight spec-driven framework'),\n    '',\n    chalk.white('This setup will configure:'),\n    chalk.dim('  • Agent Skills for AI tools'),\n    chalk.dim('  • /opsx:* slash commands'),\n    '',\n    chalk.white('Quick start after setup:'),\n    `  ${chalk.yellow('/opsx:new')}      ${chalk.dim('Create a change')}`,\n    `  ${chalk.yellow('/opsx:continue')} ${chalk.dim('Next artifact')}`,\n    `  ${chalk.yellow('/opsx:apply')}    ${chalk.dim('Implement tasks')}`,\n    '',\n    chalk.cyan('Press Enter to select tools...'),\n  ];\n}\n\n/**\n * Renders a single frame with side-by-side layout\n */\nfunction renderFrame(artLines: string[], textLines: string[]): string {\n  const maxLines = Math.max(artLines.length, textLines.length);\n  const lines: string[] = [];\n\n  for (let i = 0; i < maxLines; i++) {\n    const artLine = artLines[i] || '';\n    const textLine = textLines[i] || '';\n\n    // Pad the art column to fixed width\n    const paddedArt = artLine.padEnd(ART_COLUMN_WIDTH);\n\n    // Color the ASCII art with cyan for visual appeal\n    const coloredArt = chalk.cyan(paddedArt);\n\n    // Clear line before writing to prevent residual characters\n    lines.push(`\\x1b[2K${coloredArt}${textLine}`);\n  }\n\n  return lines.join('\\n');\n}\n\n/**\n * Checks if the terminal supports animation\n */\nfunction canAnimate(): boolean {\n  // Must be TTY\n  if (!process.stdout.isTTY) return false;\n\n  // Respect NO_COLOR\n  if (process.env.NO_COLOR) return false;\n\n  // Check terminal width\n  const columns = process.stdout.columns || 80;\n  if (columns < MIN_WIDTH) return false;\n\n  return true;\n}\n\n/**\n * Wait for Enter key press\n */\nfunction waitForEnter(): Promise<void> {\n  return new Promise((resolve) => {\n    const { stdin } = process;\n\n    // Handle non-TTY gracefully\n    if (!stdin.isTTY) {\n      resolve();\n      return;\n    }\n\n    const wasRaw = stdin.isRaw;\n    stdin.setRawMode(true);\n    stdin.resume();\n\n    const onData = (data: Buffer): void => {\n      const char = data.toString();\n\n      // Enter key or Ctrl+C\n      if (char === '\\r' || char === '\\n' || char === '\\u0003') {\n        stdin.removeListener('data', onData);\n        stdin.setRawMode(wasRaw);\n        stdin.pause();\n\n        // Handle Ctrl+C\n        if (char === '\\u0003') {\n          process.stdout.write('\\n');\n          process.exit(0);\n        }\n\n        resolve();\n      }\n    };\n\n    stdin.on('data', onData);\n  });\n}\n\n/**\n * Shows the animated welcome screen.\n * Returns when user presses Enter.\n */\nexport async function showWelcomeScreen(): Promise<void> {\n  const textLines = getWelcomeText();\n\n  if (!canAnimate()) {\n    // Fallback: show static welcome\n    const frame = WELCOME_ANIMATION.frames[3]; // Peak frame\n    process.stdout.write('\\n' + renderFrame(frame, textLines) + '\\n\\n');\n    return;\n  }\n\n  let frameIndex = 0;\n  let running = true;\n  let isFirstRender = true;\n\n  // Content height for cursor movement between frames\n  const numContentLines = Math.max(WELCOME_ANIMATION.frames[0].length, textLines.length);\n  const frameHeight = numContentLines + 1; // internal newlines (11) + trailing newlines (2) = 13\n\n  // Total height including initial newline (for cleanup)\n  const totalHeight = frameHeight + 1; // 14\n\n  // Initial render\n  process.stdout.write('\\n');\n\n  // Animation loop\n  const interval = setInterval(() => {\n    if (!running) return;\n\n    const frame = WELCOME_ANIMATION.frames[frameIndex];\n\n    // Move cursor up to overwrite previous frame (always after first render)\n    if (!isFirstRender) {\n      process.stdout.write(`\\x1b[${frameHeight}A`);\n    }\n    isFirstRender = false;\n\n    // Render current frame\n    process.stdout.write(renderFrame(frame, textLines) + '\\n\\n');\n\n    // Advance to next frame\n    frameIndex = (frameIndex + 1) % WELCOME_ANIMATION.frames.length;\n  }, WELCOME_ANIMATION.interval);\n\n  // Wait for Enter\n  await waitForEnter();\n\n  // Stop animation\n  running = false;\n  clearInterval(interval);\n\n  // Clear the welcome screen and move on\n  process.stdout.write(`\\x1b[${totalHeight}A`);\n  for (let i = 0; i < totalHeight; i++) {\n    process.stdout.write('\\x1b[2K\\n'); // Clear line\n  }\n  process.stdout.write(`\\x1b[${totalHeight}A`); // Move back up\n}\n"
  },
  {
    "path": "src/utils/change-metadata.ts",
    "content": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as yaml from 'yaml';\nimport { ChangeMetadataSchema, type ChangeMetadata } from '../core/artifact-graph/types.js';\nimport { listSchemas } from '../core/artifact-graph/resolver.js';\nimport { readProjectConfig } from '../core/project-config.js';\n\nconst METADATA_FILENAME = '.openspec.yaml';\n\n/**\n * Error thrown when change metadata validation fails.\n */\nexport class ChangeMetadataError extends Error {\n  constructor(\n    message: string,\n    public readonly metadataPath: string,\n    public readonly cause?: Error\n  ) {\n    super(message);\n    this.name = 'ChangeMetadataError';\n  }\n}\n\n/**\n * Validates that a schema name is valid (exists in available schemas).\n *\n * @param schemaName - The schema name to validate\n * @param projectRoot - Optional project root for project-local schema resolution\n * @returns The validated schema name\n * @throws Error if schema is not found\n */\nexport function validateSchemaName(\n  schemaName: string,\n  projectRoot?: string\n): string {\n  const availableSchemas = listSchemas(projectRoot);\n  if (!availableSchemas.includes(schemaName)) {\n    throw new Error(\n      `Unknown schema '${schemaName}'. Available: ${availableSchemas.join(', ')}`\n    );\n  }\n  return schemaName;\n}\n\n/**\n * Writes change metadata to .openspec.yaml in the change directory.\n *\n * @param changeDir - The path to the change directory\n * @param metadata - The metadata to write\n * @param projectRoot - Optional project root for project-local schema resolution\n * @throws ChangeMetadataError if validation fails or write fails\n */\nexport function writeChangeMetadata(\n  changeDir: string,\n  metadata: ChangeMetadata,\n  projectRoot?: string\n): void {\n  const metaPath = path.join(changeDir, METADATA_FILENAME);\n\n  // Validate schema exists\n  validateSchemaName(metadata.schema, projectRoot);\n\n  // Validate with Zod\n  const parseResult = ChangeMetadataSchema.safeParse(metadata);\n  if (!parseResult.success) {\n    throw new ChangeMetadataError(\n      `Invalid metadata: ${parseResult.error.message}`,\n      metaPath\n    );\n  }\n\n  // Write YAML file\n  const content = yaml.stringify(parseResult.data);\n  try {\n    fs.writeFileSync(metaPath, content, 'utf-8');\n  } catch (err) {\n    const ioError = err instanceof Error ? err : new Error(String(err));\n    throw new ChangeMetadataError(\n      `Failed to write metadata: ${ioError.message}`,\n      metaPath,\n      ioError\n    );\n  }\n}\n\n/**\n * Reads change metadata from .openspec.yaml in the change directory.\n *\n * @param changeDir - The path to the change directory\n * @param projectRoot - Optional project root for project-local schema resolution\n * @returns The validated metadata, or null if no metadata file exists\n * @throws ChangeMetadataError if the file exists but is invalid\n */\nexport function readChangeMetadata(\n  changeDir: string,\n  projectRoot?: string\n): ChangeMetadata | null {\n  const metaPath = path.join(changeDir, METADATA_FILENAME);\n\n  if (!fs.existsSync(metaPath)) {\n    return null;\n  }\n\n  let content: string;\n  try {\n    content = fs.readFileSync(metaPath, 'utf-8');\n  } catch (err) {\n    const ioError = err instanceof Error ? err : new Error(String(err));\n    throw new ChangeMetadataError(\n      `Failed to read metadata: ${ioError.message}`,\n      metaPath,\n      ioError\n    );\n  }\n\n  let parsed: unknown;\n  try {\n    parsed = yaml.parse(content);\n  } catch (err) {\n    const parseError = err instanceof Error ? err : new Error(String(err));\n    throw new ChangeMetadataError(\n      `Invalid YAML in metadata file: ${parseError.message}`,\n      metaPath,\n      parseError\n    );\n  }\n\n  // Validate with Zod\n  const parseResult = ChangeMetadataSchema.safeParse(parsed);\n  if (!parseResult.success) {\n    throw new ChangeMetadataError(\n      `Invalid metadata: ${parseResult.error.message}`,\n      metaPath\n    );\n  }\n\n  // Validate that the schema exists\n  const availableSchemas = listSchemas(projectRoot);\n  if (!availableSchemas.includes(parseResult.data.schema)) {\n    throw new ChangeMetadataError(\n      `Unknown schema '${parseResult.data.schema}'. Available: ${availableSchemas.join(', ')}`,\n      metaPath\n    );\n  }\n\n  return parseResult.data;\n}\n\n/**\n * Resolves the schema for a change, with explicit override taking precedence.\n *\n * Resolution order:\n * 1. Explicit schema (if provided)\n * 2. Schema from .openspec.yaml metadata (if exists)\n * 3. Schema from openspec/config.yaml (if exists)\n * 4. Default 'spec-driven'\n *\n * @param changeDir - The path to the change directory\n * @param explicitSchema - Optional explicit schema override\n * @returns The resolved schema name\n */\nexport function resolveSchemaForChange(\n  changeDir: string,\n  explicitSchema?: string\n): string {\n  // Derive project root from changeDir (changeDir is typically projectRoot/openspec/changes/change-name)\n  const projectRoot = path.resolve(changeDir, '../../..');\n\n  // 1. Explicit override wins\n  if (explicitSchema) {\n    return explicitSchema;\n  }\n\n  // 2. Try reading from metadata\n  try {\n    const metadata = readChangeMetadata(changeDir, projectRoot);\n    if (metadata?.schema) {\n      return metadata.schema;\n    }\n  } catch {\n    // If metadata read fails, continue to next option\n  }\n\n  // 3. Try reading from project config\n  try {\n    const config = readProjectConfig(projectRoot);\n    if (config?.schema) {\n      return config.schema;\n    }\n  } catch {\n    // If config read fails, fall back to default\n  }\n\n  // 4. Default\n  return 'spec-driven';\n}\n"
  },
  {
    "path": "src/utils/change-utils.ts",
    "content": "import path from 'path';\nimport { FileSystemUtils } from './file-system.js';\nimport { writeChangeMetadata, validateSchemaName } from './change-metadata.js';\nimport { readProjectConfig } from '../core/project-config.js';\n\nconst DEFAULT_SCHEMA = 'spec-driven';\n\n/**\n * Options for creating a change.\n */\nexport interface CreateChangeOptions {\n  /** The workflow schema to use (default: 'spec-driven') */\n  schema?: string;\n}\n\n/**\n * Result of creating a change.\n */\nexport interface CreateChangeResult {\n  /** The schema that was actually used (resolved from options, config, or default) */\n  schema: string;\n}\n\n/**\n * Result of validating a change name.\n */\nexport interface ValidationResult {\n  valid: boolean;\n  error?: string;\n}\n\n/**\n * Validates that a change name follows kebab-case conventions.\n *\n * Valid names:\n * - Start with a lowercase letter\n * - Contain only lowercase letters, numbers, and hyphens\n * - Do not start or end with a hyphen\n * - Do not contain consecutive hyphens\n *\n * @param name - The change name to validate\n * @returns Validation result with `valid: true` or `valid: false` with an error message\n *\n * @example\n * validateChangeName('add-auth') // { valid: true }\n * validateChangeName('Add-Auth') // { valid: false, error: '...' }\n */\nexport function validateChangeName(name: string): ValidationResult {\n  // Pattern: starts with lowercase letter, followed by lowercase letters/numbers,\n  // optionally followed by hyphen + lowercase letters/numbers (repeatable)\n  const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;\n\n  if (!name) {\n    return { valid: false, error: 'Change name cannot be empty' };\n  }\n\n  if (!kebabCasePattern.test(name)) {\n    // Provide specific error messages for common mistakes\n    if (/[A-Z]/.test(name)) {\n      return { valid: false, error: 'Change name must be lowercase (use kebab-case)' };\n    }\n    if (/\\s/.test(name)) {\n      return { valid: false, error: 'Change name cannot contain spaces (use hyphens instead)' };\n    }\n    if (/_/.test(name)) {\n      return { valid: false, error: 'Change name cannot contain underscores (use hyphens instead)' };\n    }\n    if (name.startsWith('-')) {\n      return { valid: false, error: 'Change name cannot start with a hyphen' };\n    }\n    if (name.endsWith('-')) {\n      return { valid: false, error: 'Change name cannot end with a hyphen' };\n    }\n    if (/--/.test(name)) {\n      return { valid: false, error: 'Change name cannot contain consecutive hyphens' };\n    }\n    if (/[^a-z0-9-]/.test(name)) {\n      return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' };\n    }\n    if (/^[0-9]/.test(name)) {\n      return { valid: false, error: 'Change name must start with a letter' };\n    }\n\n    return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' };\n  }\n\n  return { valid: true };\n}\n\n/**\n * Creates a new change directory with metadata file.\n *\n * @param projectRoot - The root directory of the project (where `openspec/` lives)\n * @param name - The change name (must be valid kebab-case)\n * @param options - Optional settings for the change\n * @throws Error if the change name is invalid\n * @throws Error if the schema name is invalid\n * @throws Error if the change directory already exists\n *\n * @returns Result containing the resolved schema name\n *\n * @example\n * // Creates openspec/changes/add-auth/ with default schema\n * const result = await createChange('/path/to/project', 'add-auth')\n * console.log(result.schema) // 'spec-driven' or value from config\n *\n * @example\n * // Creates openspec/changes/add-auth/ with custom schema\n * const result = await createChange('/path/to/project', 'add-auth', { schema: 'my-workflow' })\n * console.log(result.schema) // 'my-workflow'\n */\nexport async function createChange(\n  projectRoot: string,\n  name: string,\n  options: CreateChangeOptions = {}\n): Promise<CreateChangeResult> {\n  // Validate the name first\n  const validation = validateChangeName(name);\n  if (!validation.valid) {\n    throw new Error(validation.error);\n  }\n\n  // Determine schema: explicit option → project config → hardcoded default\n  let schemaName: string;\n  if (options.schema) {\n    schemaName = options.schema;\n  } else {\n    // Try to read from project config\n    try {\n      const config = readProjectConfig(projectRoot);\n      schemaName = config?.schema ?? DEFAULT_SCHEMA;\n    } catch {\n      // If config read fails, use default\n      schemaName = DEFAULT_SCHEMA;\n    }\n  }\n\n  // Validate the resolved schema\n  validateSchemaName(schemaName, projectRoot);\n\n  // Build the change directory path\n  const changeDir = path.join(projectRoot, 'openspec', 'changes', name);\n\n  // Check if change already exists\n  if (await FileSystemUtils.directoryExists(changeDir)) {\n    throw new Error(`Change '${name}' already exists at ${changeDir}`);\n  }\n\n  // Create the directory (including parent directories if needed)\n  await FileSystemUtils.createDirectory(changeDir);\n\n  // Write metadata file with schema and creation date\n  const today = new Date().toISOString().split('T')[0];\n  writeChangeMetadata(changeDir, {\n    schema: schemaName,\n    created: today,\n  }, projectRoot);\n\n  return { schema: schemaName };\n}\n"
  },
  {
    "path": "src/utils/command-references.ts",
    "content": "/**\n * Command Reference Utilities\n *\n * Utilities for transforming command references to tool-specific formats.\n */\n\n/**\n * Transforms colon-based command references to hyphen-based format.\n * Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax.\n *\n * @param text - The text containing command references\n * @returns Text with command references transformed to hyphen format\n *\n * @example\n * transformToHyphenCommands('/opsx:new') // returns '/opsx-new'\n * transformToHyphenCommands('Use /opsx:apply to implement') // returns 'Use /opsx-apply to implement'\n */\nexport function transformToHyphenCommands(text: string): string {\n  return text.replace(/\\/opsx:/g, '/opsx-');\n}\n"
  },
  {
    "path": "src/utils/file-system.ts",
    "content": "import { promises as fs, constants as fsConstants } from 'fs';\nimport path from 'path';\n\nfunction isMarkerOnOwnLine(content: string, markerIndex: number, markerLength: number): boolean {\n  let leftIndex = markerIndex - 1;\n  while (leftIndex >= 0 && content[leftIndex] !== '\\n') {\n    const char = content[leftIndex];\n    if (char !== ' ' && char !== '\\t' && char !== '\\r') {\n      return false;\n    }\n    leftIndex--;\n  }\n\n  let rightIndex = markerIndex + markerLength;\n  while (rightIndex < content.length && content[rightIndex] !== '\\n') {\n    const char = content[rightIndex];\n    if (char !== ' ' && char !== '\\t' && char !== '\\r') {\n      return false;\n    }\n    rightIndex++;\n  }\n\n  return true;\n}\n\nfunction findMarkerIndex(\n  content: string,\n  marker: string,\n  fromIndex = 0\n): number {\n  let currentIndex = content.indexOf(marker, fromIndex);\n\n  while (currentIndex !== -1) {\n    if (isMarkerOnOwnLine(content, currentIndex, marker.length)) {\n      return currentIndex;\n    }\n\n    currentIndex = content.indexOf(marker, currentIndex + marker.length);\n  }\n\n  return -1;\n}\n\nexport class FileSystemUtils {\n  /**\n   * Converts a path to use forward slashes (POSIX style).\n   * Essential for cross-platform compatibility with glob libraries like fast-glob.\n   */\n  static toPosixPath(p: string): string {\n    return p.replace(/\\\\/g, '/');\n  }\n\n  private static isWindowsBasePath(basePath: string): boolean {\n    return /^[A-Za-z]:[\\\\/]/.test(basePath) || basePath.startsWith('\\\\');\n  }\n\n  private static normalizeSegments(segments: string[]): string[] {\n    return segments\n      .flatMap((segment) => segment.split(/[\\\\/]+/u))\n      .filter((part) => part.length > 0);\n  }\n\n  static joinPath(basePath: string, ...segments: string[]): string {\n    const normalizedSegments = this.normalizeSegments(segments);\n\n    if (this.isWindowsBasePath(basePath)) {\n      const normalizedBasePath = path.win32.normalize(basePath);\n      return normalizedSegments.length\n        ? path.win32.join(normalizedBasePath, ...normalizedSegments)\n        : normalizedBasePath;\n    }\n\n    const posixBasePath = basePath.replace(/\\\\/g, '/');\n\n    return normalizedSegments.length\n      ? path.posix.join(posixBasePath, ...normalizedSegments)\n      : path.posix.normalize(posixBasePath);\n  }\n\n  static async createDirectory(dirPath: string): Promise<void> {\n    await fs.mkdir(dirPath, { recursive: true });\n  }\n\n  static async fileExists(filePath: string): Promise<boolean> {\n    try {\n      await fs.access(filePath);\n      return true;\n    } catch (error: any) {\n      if (error.code !== 'ENOENT') {\n        console.debug(`Unable to check if file exists at ${filePath}: ${error.message}`);\n      }\n      return false;\n    }\n  }\n\n  /**\n   * Finds the first existing parent directory by walking up the directory tree.\n   * @param dirPath Starting directory path\n   * @returns The first existing directory path, or null if root is reached without finding one\n   */\n  private static async findFirstExistingDirectory(dirPath: string): Promise<string | null> {\n    let currentDir = dirPath;\n\n    while (true) {\n      try {\n        const stats = await fs.stat(currentDir);\n        if (stats.isDirectory()) {\n          return currentDir;\n        }\n        // Path component exists but is not a directory (edge case)\n        console.debug(`Path component ${currentDir} exists but is not a directory`);\n        return null;\n      } catch (error: any) {\n        if (error.code === 'ENOENT') {\n          // Directory doesn't exist, move up one level\n          const parentDir = path.dirname(currentDir);\n          if (parentDir === currentDir) {\n            // Reached filesystem root without finding existing directory\n            return null;\n          }\n          currentDir = parentDir;\n        } else {\n          // Unexpected error (permissions, I/O error, etc.)\n          console.debug(`Error checking directory ${currentDir}: ${error.message}`);\n          return null;\n        }\n      }\n    }\n  }\n\n  static async canWriteFile(filePath: string): Promise<boolean> {\n    try {\n      const stats = await fs.stat(filePath);\n\n      if (!stats.isFile()) {\n        return true;\n      }\n\n      // On Windows, stats.mode doesn't reliably indicate write permissions.\n      // Use fs.access with W_OK to check actual write permissions cross-platform.\n      try {\n        await fs.access(filePath, fsConstants.W_OK);\n        return true;\n      } catch {\n        return false;\n      }\n    } catch (error: any) {\n      if (error.code === 'ENOENT') {\n        // File doesn't exist - find first existing parent directory and check its permissions\n        const parentDir = path.dirname(filePath);\n        const existingDir = await this.findFirstExistingDirectory(parentDir);\n\n        if (existingDir === null) {\n          // No existing parent directory found (edge case)\n          return false;\n        }\n\n        // Check if the existing parent directory is writable\n        try {\n          await fs.access(existingDir, fsConstants.W_OK);\n          return true;\n        } catch {\n          return false;\n        }\n      }\n\n      console.debug(`Unable to determine write permissions for ${filePath}: ${error.message}`);\n      return false;\n    }\n  }\n\n  static async directoryExists(dirPath: string): Promise<boolean> {\n    try {\n      const stats = await fs.stat(dirPath);\n      return stats.isDirectory();\n    } catch (error: any) {\n      if (error.code !== 'ENOENT') {\n        console.debug(`Unable to check if directory exists at ${dirPath}: ${error.message}`);\n      }\n      return false;\n    }\n  }\n\n  static async writeFile(filePath: string, content: string): Promise<void> {\n    const dir = path.dirname(filePath);\n    await this.createDirectory(dir);\n    await fs.writeFile(filePath, content, 'utf-8');\n  }\n\n  static async readFile(filePath: string): Promise<string> {\n    return await fs.readFile(filePath, 'utf-8');\n  }\n\n  static async updateFileWithMarkers(\n    filePath: string,\n    content: string,\n    startMarker: string,\n    endMarker: string\n  ): Promise<void> {\n    let existingContent = '';\n    \n    if (await this.fileExists(filePath)) {\n      existingContent = await this.readFile(filePath);\n      \n      const startIndex = findMarkerIndex(existingContent, startMarker);\n      const endIndex = startIndex !== -1\n        ? findMarkerIndex(existingContent, endMarker, startIndex + startMarker.length)\n        : findMarkerIndex(existingContent, endMarker);\n\n      if (startIndex !== -1 && endIndex !== -1) {\n        if (endIndex < startIndex) {\n          throw new Error(\n            `Invalid marker state in ${filePath}. End marker appears before start marker.`\n          );\n        }\n\n        const before = existingContent.substring(0, startIndex);\n        const after = existingContent.substring(endIndex + endMarker.length);\n        existingContent = before + startMarker + '\\n' + content + '\\n' + endMarker + after;\n      } else if (startIndex === -1 && endIndex === -1) {\n        existingContent = startMarker + '\\n' + content + '\\n' + endMarker + '\\n\\n' + existingContent;\n      } else {\n        throw new Error(`Invalid marker state in ${filePath}. Found start: ${startIndex !== -1}, Found end: ${endIndex !== -1}`);\n      }\n    } else {\n      existingContent = startMarker + '\\n' + content + '\\n' + endMarker;\n    }\n    \n    await this.writeFile(filePath, existingContent);\n  }\n\n  static async ensureWritePermissions(dirPath: string): Promise<boolean> {\n    try {\n      // If directory doesn't exist, check parent directory permissions\n      if (!await this.directoryExists(dirPath)) {\n        const parentDir = path.dirname(dirPath);\n        if (!await this.directoryExists(parentDir)) {\n          await this.createDirectory(parentDir);\n        }\n        return await this.ensureWritePermissions(parentDir);\n      }\n\n      const testFile = path.join(dirPath, '.openspec-test-' + Date.now() + '-' + Math.random().toString(36).slice(2));\n      await fs.writeFile(testFile, '');\n\n      // On Windows, file may be temporarily locked by antivirus or indexing services.\n      // Retry unlink with a small delay if it fails.\n      const maxRetries = 3;\n      for (let attempt = 0; attempt < maxRetries; attempt++) {\n        try {\n          await fs.unlink(testFile);\n          break;\n        } catch (unlinkError: any) {\n          if (attempt === maxRetries - 1) {\n            // Last attempt failed, but we successfully wrote the file, so permissions are OK\n            // Just log and continue - the temp file will be cleaned up eventually\n            console.debug(`Could not clean up test file ${testFile}: ${unlinkError.message}`);\n          } else {\n            // Wait briefly before retrying (Windows file lock release)\n            await new Promise((resolve) => setTimeout(resolve, 50));\n          }\n        }\n      }\n      return true;\n    } catch (error: any) {\n      console.debug(`Insufficient permissions to write to ${dirPath}: ${error.message}`);\n      return false;\n    }\n  }\n}\n\n/**\n * Removes a marker block from file content.\n * Only removes markers that are on their own lines (ignores inline mentions).\n * Cleans up double blank lines that may result from removal.\n *\n * @param content - File content with markers\n * @param startMarker - The start marker string\n * @param endMarker - The end marker string\n * @returns Content with marker block removed, or original content if markers not found/invalid\n */\nexport function removeMarkerBlock(\n  content: string,\n  startMarker: string,\n  endMarker: string\n): string {\n  const startIndex = findMarkerIndex(content, startMarker);\n  const endIndex = startIndex !== -1\n    ? findMarkerIndex(content, endMarker, startIndex + startMarker.length)\n    : findMarkerIndex(content, endMarker);\n\n  if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {\n    return content;\n  }\n\n  // Find the start of the line containing the start marker\n  let lineStart = startIndex;\n  while (lineStart > 0 && content[lineStart - 1] !== '\\n') {\n    lineStart--;\n  }\n\n  // Find the end of the line containing the end marker\n  let lineEnd = endIndex + endMarker.length;\n  while (lineEnd < content.length && content[lineEnd] !== '\\n') {\n    lineEnd++;\n  }\n  // Include the trailing newline if present\n  if (lineEnd < content.length && content[lineEnd] === '\\n') {\n    lineEnd++;\n  }\n\n  const before = content.substring(0, lineStart);\n  const after = content.substring(lineEnd);\n\n  // Clean up double blank lines (handle both Unix \\n and Windows \\r\\n)\n  let result = before + after;\n  result = result.replace(/(\\r?\\n){3,}/g, '\\n\\n');\n\n  // Trim trailing whitespace but preserve leading whitespace and original newline style\n  if (result.trimEnd() === '') {\n    return '';\n  }\n  const newline = content.includes('\\r\\n') ? '\\r\\n' : '\\n';\n  return result.trimEnd() + newline;\n}\n"
  },
  {
    "path": "src/utils/index.ts",
    "content": "// Shared utilities\nexport { validateChangeName, createChange } from './change-utils.js';\nexport type { ValidationResult, CreateChangeOptions } from './change-utils.js';\n\n// Change metadata utilities\nexport {\n  readChangeMetadata,\n  writeChangeMetadata,\n  resolveSchemaForChange,\n  validateSchemaName,\n  ChangeMetadataError,\n} from './change-metadata.js';\n\n// File system utilities\nexport { FileSystemUtils, removeMarkerBlock } from './file-system.js';\n\n// Command reference utilities\nexport { transformToHyphenCommands } from './command-references.js';"
  },
  {
    "path": "src/utils/interactive.ts",
    "content": "export type InteractiveOptions = {\n  /**\n   * Explicit \"disable prompts\" flag passed by internal callers.\n   */\n  noInteractive?: boolean;\n  /**\n   * Commander-style negated option: `--no-interactive` sets this to false.\n   */\n  interactive?: boolean;\n};\n\n/**\n * Resolves whether non-interactive mode is requested.\n * Handles both explicit `noInteractive: true` and Commander.js style `interactive: false`.\n * Use this helper instead of manually checking options.noInteractive to avoid bugs.\n */\nexport function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {\n  if (typeof value === 'boolean') return value;\n  return value?.noInteractive === true || value?.interactive === false;\n}\n\nexport function isInteractive(value?: boolean | InteractiveOptions): boolean {\n  if (resolveNoInteractive(value)) return false;\n  if (process.env.OPEN_SPEC_INTERACTIVE === '0') return false;\n  // Respect the standard CI environment variable (set by GitHub Actions, GitLab CI, Travis, etc.)\n  if ('CI' in process.env) return false;\n  return !!process.stdin.isTTY;\n}\n\n"
  },
  {
    "path": "src/utils/item-discovery.ts",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\n\nexport async function getActiveChangeIds(root: string = process.cwd()): Promise<string[]> {\n  const changesPath = path.join(root, 'openspec', 'changes');\n  try {\n    const entries = await fs.readdir(changesPath, { withFileTypes: true });\n    const result: string[] = [];\n    for (const entry of entries) {\n      if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive') continue;\n      const proposalPath = path.join(changesPath, entry.name, 'proposal.md');\n      try {\n        await fs.access(proposalPath);\n        result.push(entry.name);\n      } catch {\n        // skip directories without proposal.md\n      }\n    }\n    return result.sort();\n  } catch {\n    return [];\n  }\n}\n\nexport async function getSpecIds(root: string = process.cwd()): Promise<string[]> {\n  const specsPath = path.join(root, 'openspec', 'specs');\n  const result: string[] = [];\n  try {\n    const entries = await fs.readdir(specsPath, { withFileTypes: true });\n    for (const entry of entries) {\n      if (!entry.isDirectory() || entry.name.startsWith('.')) continue;\n      const specFile = path.join(specsPath, entry.name, 'spec.md');\n      try {\n        await fs.access(specFile);\n        result.push(entry.name);\n      } catch {\n        // ignore\n      }\n    }\n  } catch {\n    // ignore\n  }\n  return result.sort();\n}\n\nexport async function getArchivedChangeIds(root: string = process.cwd()): Promise<string[]> {\n  const archivePath = path.join(root, 'openspec', 'changes', 'archive');\n  try {\n    const entries = await fs.readdir(archivePath, { withFileTypes: true });\n    const result: string[] = [];\n    for (const entry of entries) {\n      if (!entry.isDirectory() || entry.name.startsWith('.')) continue;\n      const proposalPath = path.join(archivePath, entry.name, 'proposal.md');\n      try {\n        await fs.access(proposalPath);\n        result.push(entry.name);\n      } catch {\n        // skip directories without proposal.md\n      }\n    }\n    return result.sort();\n  } catch {\n    return [];\n  }\n}\n\n"
  },
  {
    "path": "src/utils/match.ts",
    "content": "export function nearestMatches(input: string, candidates: string[], max: number = 5): string[] {\n  const scored = candidates.map(candidate => ({ candidate, distance: levenshtein(input, candidate) }));\n  scored.sort((a, b) => a.distance - b.distance);\n  return scored.slice(0, max).map(s => s.candidate);\n}\n\nexport function levenshtein(a: string, b: string): number {\n  const m = a.length;\n  const n = b.length;\n  const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));\n  for (let i = 0; i <= m; i++) dp[i][0] = i;\n  for (let j = 0; j <= n; j++) dp[0][j] = j;\n  for (let i = 1; i <= m; i++) {\n    for (let j = 1; j <= n; j++) {\n      const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n      dp[i][j] = Math.min(\n        dp[i - 1][j] + 1,\n        dp[i][j - 1] + 1,\n        dp[i - 1][j - 1] + cost\n      );\n    }\n  }\n  return dp[m][n];\n}\n\n\n"
  },
  {
    "path": "src/utils/shell-detection.ts",
    "content": "/**\n * Supported shell types for completion generation\n */\nexport type SupportedShell = 'zsh' | 'bash' | 'fish' | 'powershell';\n\n/**\n * Result of shell detection\n */\nexport interface ShellDetectionResult {\n  /** The detected shell if supported, otherwise undefined */\n  shell: SupportedShell | undefined;\n  /** The raw shell name detected (even if unsupported), or undefined if nothing detected */\n  detected: string | undefined;\n}\n\n/**\n * Detects the current user's shell based on environment variables\n *\n * @returns Detection result with supported shell and raw detected name\n */\nexport function detectShell(): ShellDetectionResult {\n  // Try SHELL environment variable first (Unix-like systems)\n  const shellPath = process.env.SHELL;\n\n  if (shellPath) {\n    const shellName = shellPath.toLowerCase();\n\n    if (shellName.includes('zsh')) {\n      return { shell: 'zsh', detected: 'zsh' };\n    }\n    if (shellName.includes('bash')) {\n      return { shell: 'bash', detected: 'bash' };\n    }\n    if (shellName.includes('fish')) {\n      return { shell: 'fish', detected: 'fish' };\n    }\n\n    // Shell detected but not supported\n    // Extract shell name from path (e.g., /bin/tcsh -> tcsh)\n    const match = shellPath.match(/\\/([^/]+)$/);\n    const detectedName = match ? match[1] : shellPath;\n    return { shell: undefined, detected: detectedName };\n  }\n\n  // Check for PowerShell on Windows\n  // PSModulePath is a reliable PowerShell-specific environment variable\n  if (process.env.PSModulePath || process.platform === 'win32') {\n    const comspec = process.env.COMSPEC?.toLowerCase();\n\n    // If PSModulePath exists, we're definitely in PowerShell\n    if (process.env.PSModulePath) {\n      return { shell: 'powershell', detected: 'powershell' };\n    }\n\n    // On Windows without PSModulePath, we might be in cmd.exe\n    if (comspec?.includes('cmd.exe')) {\n      return { shell: undefined, detected: 'cmd.exe' };\n    }\n  }\n\n  return { shell: undefined, detected: undefined };\n}\n"
  },
  {
    "path": "src/utils/task-progress.ts",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\n\nconst TASK_PATTERN = /^[-*]\\s+\\[[\\sx]\\]/i;\nconst COMPLETED_TASK_PATTERN = /^[-*]\\s+\\[x\\]/i;\n\nexport interface TaskProgress {\n  total: number;\n  completed: number;\n}\n\nexport function countTasksFromContent(content: string): TaskProgress {\n  const lines = content.split('\\n');\n  let total = 0;\n  let completed = 0;\n  for (const line of lines) {\n    if (line.match(TASK_PATTERN)) {\n      total++;\n      if (line.match(COMPLETED_TASK_PATTERN)) {\n        completed++;\n      }\n    }\n  }\n  return { total, completed };\n}\n\nexport async function getTaskProgressForChange(changesDir: string, changeName: string): Promise<TaskProgress> {\n  const tasksPath = path.join(changesDir, changeName, 'tasks.md');\n  try {\n    const content = await fs.readFile(tasksPath, 'utf-8');\n    return countTasksFromContent(content);\n  } catch {\n    return { total: 0, completed: 0 };\n  }\n}\n\nexport function formatTaskStatus(progress: TaskProgress): string {\n  if (progress.total === 0) return 'No tasks';\n  if (progress.completed === progress.total) return '✓ Complete';\n  return `${progress.completed}/${progress.total} tasks`;\n}\n\n\n"
  },
  {
    "path": "test/cli-e2e/basic.test.ts",
    "content": "import { afterAll, describe, it, expect } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { tmpdir } from 'os';\nimport { runCLI, cliProjectRoot } from '../helpers/run-cli.js';\nimport { AI_TOOLS } from '../../src/core/config.js';\n\nasync function fileExists(filePath: string): Promise<boolean> {\n  try {\n    await fs.access(filePath);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nconst tempRoots: string[] = [];\n\nasync function prepareFixture(fixtureName: string): Promise<string> {\n  const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-cli-e2e-'));\n  tempRoots.push(base);\n  const projectDir = path.join(base, 'project');\n  await fs.mkdir(projectDir, { recursive: true });\n  const fixtureDir = path.join(cliProjectRoot, 'test', 'fixtures', fixtureName);\n  await fs.cp(fixtureDir, projectDir, { recursive: true });\n  return projectDir;\n}\n\nafterAll(async () => {\n  await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true })));\n});\n\ndescribe('openspec CLI e2e basics', () => {\n  it('shows help output', async () => {\n    const result = await runCLI(['--help']);\n    expect(result.exitCode).toBe(0);\n    expect(result.stdout).toContain('Usage: openspec');\n    expect(result.stderr).toBe('');\n\n  });\n\n  it('shows dynamic tool ids in init help', async () => {\n    const result = await runCLI(['init', '--help']);\n    expect(result.exitCode).toBe(0);\n\n    const expectedTools = AI_TOOLS.filter((tool) => tool.available)\n      .map((tool) => tool.value)\n      .join(', ');\n    const normalizedOutput = result.stdout.replace(/\\s+/g, ' ').trim();\n    expect(normalizedOutput).toContain(\n      `Use \"all\", \"none\", or a comma-separated list of: ${expectedTools}`\n    );\n  });\n\n  it('reports the package version', async () => {\n    const pkgRaw = await fs.readFile(path.join(cliProjectRoot, 'package.json'), 'utf-8');\n    const pkg = JSON.parse(pkgRaw);\n    const result = await runCLI(['--version']);\n    expect(result.exitCode).toBe(0);\n    expect(result.stdout.trim()).toBe(pkg.version);\n  });\n\n  it('validates the tmp-init fixture with --all --json', async () => {\n    const projectDir = await prepareFixture('tmp-init');\n    const result = await runCLI(['validate', '--all', '--json'], { cwd: projectDir });\n    expect(result.exitCode).toBe(0);\n    const output = result.stdout.trim();\n    expect(output).not.toBe('');\n    const json = JSON.parse(output);\n    expect(json.summary?.totals?.failed).toBe(0);\n    expect(json.items.some((item: any) => item.id === 'c1' && item.type === 'change')).toBe(true);\n  });\n\n  it('returns an error for unknown items in the fixture', async () => {\n    const projectDir = await prepareFixture('tmp-init');\n    const result = await runCLI(['validate', 'does-not-exist'], { cwd: projectDir });\n    expect(result.exitCode).toBe(1);\n    expect(result.stderr).toContain(\"Unknown item 'does-not-exist'\");\n  });\n\n  describe('init command non-interactive options', () => {\n    it('initializes with --tools all option', async () => {\n      const projectDir = await prepareFixture('tmp-init');\n      const emptyProjectDir = path.join(projectDir, '..', 'empty-project');\n      await fs.mkdir(emptyProjectDir, { recursive: true });\n\n      const codexHome = path.join(emptyProjectDir, '.codex');\n      const result = await runCLI(['init', '--tools', 'all'], {\n        cwd: emptyProjectDir,\n        env: { CODEX_HOME: codexHome },\n      });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('OpenSpec Setup Complete');\n\n      // Check that skills were created for multiple tools\n      const claudeSkillPath = path.join(emptyProjectDir, '.claude/skills/openspec-explore/SKILL.md');\n      const cursorSkillPath = path.join(emptyProjectDir, '.cursor/skills/openspec-explore/SKILL.md');\n      expect(await fileExists(claudeSkillPath)).toBe(true);\n      expect(await fileExists(cursorSkillPath)).toBe(true);\n    });\n\n    it('initializes with --tools list option', async () => {\n      const projectDir = await prepareFixture('tmp-init');\n      const emptyProjectDir = path.join(projectDir, '..', 'empty-project');\n      await fs.mkdir(emptyProjectDir, { recursive: true });\n\n      const result = await runCLI(['init', '--tools', 'claude'], { cwd: emptyProjectDir });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('OpenSpec Setup Complete');\n      expect(result.stdout).toContain('Claude Code');\n\n      // New init creates skills, not CLAUDE.md\n      const claudeSkillPath = path.join(emptyProjectDir, '.claude/skills/openspec-explore/SKILL.md');\n      const cursorSkillPath = path.join(emptyProjectDir, '.cursor/skills/openspec-explore/SKILL.md');\n      expect(await fileExists(claudeSkillPath)).toBe(true);\n      expect(await fileExists(cursorSkillPath)).toBe(false); // Not selected\n    });\n\n    it('initializes with --tools none option', async () => {\n      const projectDir = await prepareFixture('tmp-init');\n      const emptyProjectDir = path.join(projectDir, '..', 'empty-project');\n      await fs.mkdir(emptyProjectDir, { recursive: true });\n\n      const result = await runCLI(['init', '--tools', 'none'], { cwd: emptyProjectDir });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('OpenSpec Setup Complete');\n\n      // With --tools none, no tool skills should be created\n      const claudeSkillPath = path.join(emptyProjectDir, '.claude/skills/openspec-explore/SKILL.md');\n      const cursorSkillPath = path.join(emptyProjectDir, '.cursor/skills/openspec-explore/SKILL.md');\n\n      expect(await fileExists(claudeSkillPath)).toBe(false);\n      expect(await fileExists(cursorSkillPath)).toBe(false);\n    });\n\n    it('returns error for invalid tool names', async () => {\n      const projectDir = await prepareFixture('tmp-init');\n      const emptyProjectDir = path.join(projectDir, '..', 'empty-project');\n      await fs.mkdir(emptyProjectDir, { recursive: true });\n\n      const result = await runCLI(['init', '--tools', 'invalid-tool'], { cwd: emptyProjectDir });\n      expect(result.exitCode).toBe(1);\n      expect(result.stderr).toContain('Invalid tool(s): invalid-tool');\n      expect(result.stderr).toContain('Available values:');\n    });\n\n    it('returns error when combining reserved keywords with explicit ids', async () => {\n      const projectDir = await prepareFixture('tmp-init');\n      const emptyProjectDir = path.join(projectDir, '..', 'empty-project');\n      await fs.mkdir(emptyProjectDir, { recursive: true });\n\n      const result = await runCLI(['init', '--tools', 'all,claude'], { cwd: emptyProjectDir });\n      expect(result.exitCode).toBe(1);\n      expect(result.stderr).toContain('Cannot combine reserved values \"all\" or \"none\" with specific tool IDs');\n    });\n  });\n});\n"
  },
  {
    "path": "test/commands/artifact-workflow.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { runCLI } from '../helpers/run-cli.js';\n\ndescribe('artifact-workflow CLI commands', () => {\n  let tempDir: string;\n  let changesDir: string;\n\n  beforeEach(async () => {\n    tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-artifact-workflow-'));\n    changesDir = path.join(tempDir, 'openspec', 'changes');\n    await fs.mkdir(changesDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    if (tempDir) {\n      await fs.rm(tempDir, { recursive: true, force: true });\n    }\n  });\n\n  /**\n   * Gets combined output from CLI result (ora outputs to stdout).\n   */\n  function getOutput(result: { stdout: string; stderr: string }): string {\n    return result.stdout + result.stderr;\n  }\n\n  /**\n   * Normalizes path separators to forward slashes for cross-platform assertions.\n   */\n  function normalizePaths(str: string): string {\n    return str.replace(/\\\\/g, '/');\n  }\n\n  /**\n   * Creates a test change with the specified artifacts completed.\n   * Note: An \"active\" change requires at least a proposal.md file to be detected.\n   * If no artifacts are specified, we create an empty proposal to make it detectable.\n   */\n  async function createTestChange(\n    changeName: string,\n    artifacts: ('proposal' | 'design' | 'specs' | 'tasks')[] = []\n  ): Promise<string> {\n    const changeDir = path.join(changesDir, changeName);\n    await fs.mkdir(changeDir, { recursive: true });\n\n    // Always create proposal.md for the change to be detected as active\n    // Content varies based on whether 'proposal' is in artifacts list\n    const proposalContent = artifacts.includes('proposal')\n      ? '## Why\\nTest proposal content that is long enough.\\n\\n## What Changes\\n- **test:** Something'\n      : '## Why\\nMinimal proposal.\\n\\n## What Changes\\n- **test:** Placeholder';\n    await fs.writeFile(path.join(changeDir, 'proposal.md'), proposalContent);\n\n    if (artifacts.includes('design')) {\n      await fs.writeFile(path.join(changeDir, 'design.md'), '# Design\\n\\nTechnical design.');\n    }\n\n    if (artifacts.includes('specs')) {\n      // specs artifact uses glob pattern \"specs/*.md\" - files directly in specs/ directory\n      const specsDir = path.join(changeDir, 'specs');\n      await fs.mkdir(specsDir, { recursive: true });\n      await fs.writeFile(path.join(specsDir, 'test-spec.md'), '## Purpose\\nTest spec.');\n    }\n\n    if (artifacts.includes('tasks')) {\n      await fs.writeFile(path.join(changeDir, 'tasks.md'), '## Tasks\\n- [ ] Task 1');\n    }\n\n    return changeDir;\n  }\n\n  describe('status command', () => {\n    it('shows status for scaffolded change without proposal.md', async () => {\n      // Create empty change directory (no proposal.md)\n      const changeDir = path.join(changesDir, 'scaffolded-change');\n      await fs.mkdir(changeDir, { recursive: true });\n\n      const result = await runCLI(['status', '--change', 'scaffolded-change'], { cwd: tempDir });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('scaffolded-change');\n      expect(result.stdout).toContain('0/4 artifacts complete');\n    });\n\n    it('shows status for a change with proposal only', async () => {\n      // createTestChange always creates proposal.md, so this has 1 artifact complete\n      await createTestChange('minimal-change');\n\n      const result = await runCLI(['status', '--change', 'minimal-change'], { cwd: tempDir });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('minimal-change');\n      expect(result.stdout).toContain('spec-driven');\n      expect(result.stdout).toContain('1/4 artifacts complete');\n    });\n\n    it('shows status for a change with proposal and design', async () => {\n      await createTestChange('partial-change', ['proposal', 'design']);\n\n      const result = await runCLI(['status', '--change', 'partial-change'], { cwd: tempDir });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('2/4 artifacts complete');\n      expect(result.stdout).toContain('[x]');\n    });\n\n    it('outputs JSON when --json flag is used', async () => {\n      await createTestChange('json-change', ['proposal', 'design']);\n\n      const result = await runCLI(['status', '--change', 'json-change', '--json'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n\n      const json = JSON.parse(result.stdout);\n      expect(json.changeName).toBe('json-change');\n      expect(json.schemaName).toBe('spec-driven');\n      expect(json.isComplete).toBe(false);\n      expect(Array.isArray(json.artifacts)).toBe(true);\n      expect(json.artifacts).toHaveLength(4);\n\n      const proposalArtifact = json.artifacts.find((a: any) => a.id === 'proposal');\n      expect(proposalArtifact.status).toBe('done');\n    });\n\n    it('shows complete status when all artifacts are done', async () => {\n      await createTestChange('complete-change', ['proposal', 'design', 'specs', 'tasks']);\n\n      const result = await runCLI(['status', '--change', 'complete-change'], { cwd: tempDir });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('4/4 artifacts complete');\n      expect(result.stdout).toContain('All artifacts complete!');\n    });\n\n    it('exits gracefully when no changes exist', async () => {\n      const result = await runCLI(['status'], { cwd: tempDir });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('No active changes');\n      expect(result.stdout).toContain('openspec new change');\n    });\n\n    it('exits gracefully with JSON when no changes exist', async () => {\n      const result = await runCLI(['status', '--json'], { cwd: tempDir });\n      expect(result.exitCode).toBe(0);\n\n      const json = JSON.parse(result.stdout);\n      expect(json.changes).toEqual([]);\n      expect(json.message).toBe('No active changes.');\n    });\n\n    it('errors when --change is missing and lists available changes', async () => {\n      await createTestChange('some-change');\n\n      const result = await runCLI(['status'], { cwd: tempDir });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain('Missing required option --change');\n      expect(output).toContain('some-change');\n    });\n\n    it('errors for unknown change name and lists available changes', async () => {\n      await createTestChange('existing-change');\n\n      const result = await runCLI(['status', '--change', 'nonexistent'], { cwd: tempDir });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain(\"Change 'nonexistent' not found\");\n      expect(output).toContain('existing-change');\n    });\n\n    it('supports --schema option', async () => {\n      await createTestChange('schema-change');\n\n      const result = await runCLI(['status', '--change', 'schema-change', '--schema', 'spec-driven'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('spec-driven');\n    });\n\n    it('errors for unknown schema', async () => {\n      await createTestChange('test-change');\n\n      const result = await runCLI(['status', '--change', 'test-change', '--schema', 'unknown'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain(\"Schema 'unknown' not found\");\n    });\n\n    it('rejects path traversal in change name', async () => {\n      const result = await runCLI(['status', '--change', '../foo'], { cwd: tempDir });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain('Invalid change name');\n    });\n\n    it('rejects absolute path in change name', async () => {\n      const result = await runCLI(['status', '--change', '/etc/passwd'], { cwd: tempDir });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain('Invalid change name');\n    });\n\n    it('rejects slashes in change name', async () => {\n      const result = await runCLI(['status', '--change', 'foo/bar'], { cwd: tempDir });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain('Invalid change name');\n    });\n  });\n\n  describe('instructions command', () => {\n    it('shows instructions for proposal on scaffolded change', async () => {\n      // Create empty change directory (no proposal.md)\n      const changeDir = path.join(changesDir, 'scaffolded-change');\n      await fs.mkdir(changeDir, { recursive: true });\n\n      const result = await runCLI(['instructions', 'proposal', '--change', 'scaffolded-change'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('<artifact id=\"proposal\"');\n      expect(result.stdout).toContain('proposal.md');\n      expect(result.stdout).toContain('<template>');\n    });\n\n    it('shows instructions for design artifact', async () => {\n      await createTestChange('instr-change');\n\n      const result = await runCLI(['instructions', 'design', '--change', 'instr-change'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('<artifact id=\"design\"');\n      expect(result.stdout).toContain('design.md');\n      expect(result.stdout).toContain('<template>');\n    });\n\n    it('shows blocked warning for artifact with unmet dependencies', async () => {\n      // tasks depends on design and specs, which are not done yet\n      await createTestChange('blocked-change');\n\n      const result = await runCLI(['instructions', 'tasks', '--change', 'blocked-change'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('<warning>');\n      expect(result.stdout).toContain('status=\"missing\"');\n    });\n\n    it('outputs JSON for instructions', async () => {\n      await createTestChange('json-instr', ['proposal']);\n\n      const result = await runCLI(['instructions', 'design', '--change', 'json-instr', '--json'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n\n      const json = JSON.parse(result.stdout);\n      expect(json.artifactId).toBe('design');\n      expect(json.outputPath).toContain('design.md');\n      expect(typeof json.template).toBe('string');\n      expect(Array.isArray(json.dependencies)).toBe(true);\n    });\n\n    it('errors when artifact argument is missing', async () => {\n      await createTestChange('test-change');\n\n      const result = await runCLI(['instructions', '--change', 'test-change'], { cwd: tempDir });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain('Missing required argument <artifact>');\n      expect(output).toContain('Valid artifacts');\n    });\n\n    it('errors for unknown artifact', async () => {\n      await createTestChange('test-change');\n\n      const result = await runCLI(['instructions', 'unknown-artifact', '--change', 'test-change'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain(\"Artifact 'unknown-artifact' not found\");\n      expect(output).toContain('Valid artifacts');\n    });\n  });\n\n  describe('templates command', () => {\n    it('shows template paths for default schema', async () => {\n      const result = await runCLI(['templates'], { cwd: tempDir });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('Schema: spec-driven');\n      expect(result.stdout).toContain('proposal:');\n      expect(result.stdout).toContain('design:');\n      expect(result.stdout).toContain('specs:');\n      expect(result.stdout).toContain('tasks:');\n    });\n\n    it('shows template paths for specified schema', async () => {\n      const result = await runCLI(['templates', '--schema', 'spec-driven'], { cwd: tempDir });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('Schema: spec-driven');\n      expect(result.stdout).toContain('proposal:');\n      expect(result.stdout).toContain('design:');\n    });\n\n    it('outputs JSON mapping of templates', async () => {\n      const result = await runCLI(['templates', '--json'], { cwd: tempDir });\n      expect(result.exitCode).toBe(0);\n\n      const json = JSON.parse(result.stdout);\n      expect(json.proposal).toBeDefined();\n      expect(json.proposal.path).toContain('proposal.md');\n      expect(json.proposal.source).toBe('package');\n    });\n\n    it('errors for unknown schema', async () => {\n      const result = await runCLI(['templates', '--schema', 'nonexistent'], { cwd: tempDir });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain(\"Schema 'nonexistent' not found\");\n    });\n  });\n\n  describe('new change command', () => {\n    it('creates a new change directory', async () => {\n      const result = await runCLI(['new', 'change', 'my-new-feature'], { cwd: tempDir });\n      expect(result.exitCode).toBe(0);\n      const output = getOutput(result);\n      expect(output).toContain(\"Created change 'my-new-feature'\");\n\n      const changeDir = path.join(changesDir, 'my-new-feature');\n      const stat = await fs.stat(changeDir);\n      expect(stat.isDirectory()).toBe(true);\n    });\n\n    it('creates README.md when --description is provided', async () => {\n      const result = await runCLI(\n        ['new', 'change', 'described-feature', '--description', 'This is a test feature'],\n        { cwd: tempDir }\n      );\n      expect(result.exitCode).toBe(0);\n\n      const readmePath = path.join(changesDir, 'described-feature', 'README.md');\n      const content = await fs.readFile(readmePath, 'utf-8');\n      expect(content).toContain('described-feature');\n      expect(content).toContain('This is a test feature');\n    });\n\n    it('errors for invalid change name with spaces', async () => {\n      const result = await runCLI(['new', 'change', 'invalid name'], { cwd: tempDir });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain('Error');\n    });\n\n    it('errors for duplicate change name', async () => {\n      await createTestChange('existing-change');\n\n      const result = await runCLI(['new', 'change', 'existing-change'], { cwd: tempDir });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain('exists');\n    });\n\n    it('errors when name argument is missing', async () => {\n      const result = await runCLI(['new', 'change'], { cwd: tempDir });\n      expect(result.exitCode).toBe(1);\n    });\n  });\n\n  describe('instructions apply command', () => {\n    it('shows apply instructions for spec-driven schema with tasks', async () => {\n      await createTestChange('apply-change', ['proposal', 'design', 'specs', 'tasks']);\n\n      const result = await runCLI(['instructions', 'apply', '--change', 'apply-change'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('## Apply: apply-change');\n      expect(result.stdout).toContain('Schema: spec-driven');\n      expect(result.stdout).toContain('### Context Files');\n      expect(result.stdout).toContain('### Instruction');\n    });\n\n    it('shows blocked state when required artifacts are missing', async () => {\n      // Only create proposal - missing tasks (required by spec-driven apply block)\n      await createTestChange('blocked-apply', ['proposal']);\n\n      const result = await runCLI(['instructions', 'apply', '--change', 'blocked-apply'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('Blocked');\n      expect(result.stdout).toContain('Missing artifacts: tasks');\n    });\n\n    it('outputs JSON for apply instructions', async () => {\n      await createTestChange('json-apply', ['proposal', 'design', 'specs', 'tasks']);\n\n      const result = await runCLI(\n        ['instructions', 'apply', '--change', 'json-apply', '--json'],\n        { cwd: tempDir }\n      );\n      expect(result.exitCode).toBe(0);\n\n      const json = JSON.parse(result.stdout);\n      expect(json.changeName).toBe('json-apply');\n      expect(json.schemaName).toBe('spec-driven');\n      expect(json.state).toBe('ready');\n      expect(json.contextFiles).toBeDefined();\n      expect(typeof json.contextFiles).toBe('object');\n    });\n\n    it('shows schema instruction from apply block', async () => {\n      await createTestChange('instr-apply', ['proposal', 'design', 'specs', 'tasks']);\n\n      const result = await runCLI(['instructions', 'apply', '--change', 'instr-apply'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n      // Should show the instruction from spec-driven schema apply block\n      expect(result.stdout).toContain('work through pending tasks');\n    });\n\n    it('shows all_done state when all tasks are complete', async () => {\n      const changeDir = await createTestChange('done-apply', [\n        'proposal',\n        'design',\n        'specs',\n        'tasks',\n      ]);\n      // Overwrite tasks with all completed\n      await fs.writeFile(\n        path.join(changeDir, 'tasks.md'),\n        '## Tasks\\n- [x] Task 1\\n- [x] Task 2'\n      );\n\n      const result = await runCLI(['instructions', 'apply', '--change', 'done-apply'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('complete ✓');\n      expect(result.stdout).toContain('ready to be archived');\n    });\n\n    it('uses spec-driven schema apply configuration', async () => {\n      // Create a spec-driven style change with all artifacts\n      await createTestChange('apply-schema-test', ['proposal', 'design', 'specs', 'tasks']);\n\n      const result = await runCLI(\n        ['instructions', 'apply', '--change', 'apply-schema-test', '--schema', 'spec-driven'],\n        { cwd: tempDir }\n      );\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('Schema: spec-driven');\n    });\n\n    it('spec-driven schema uses apply block configuration', async () => {\n      // Verify that spec-driven schema uses its apply block (requires: [tasks])\n      await createTestChange('apply-config-test', ['proposal', 'design', 'specs', 'tasks']);\n\n      const result = await runCLI(\n        ['instructions', 'apply', '--change', 'apply-config-test', '--json'],\n        { cwd: tempDir }\n      );\n      expect(result.exitCode).toBe(0);\n\n      const json = JSON.parse(result.stdout);\n      // spec-driven schema has apply block with requires: [tasks], so should be ready\n      expect(json.schemaName).toBe('spec-driven');\n      expect(json.state).toBe('ready');\n    });\n\n    it('fallback: requires all artifacts when schema has no apply block', async () => {\n      // Create a minimal schema without an apply block in user schemas dir\n      const userDataDir = path.join(tempDir, 'user-data');\n      const noApplySchemaDir = path.join(userDataDir, 'openspec', 'schemas', 'no-apply');\n      const templatesDir = path.join(noApplySchemaDir, 'templates');\n      await fs.mkdir(templatesDir, { recursive: true });\n\n      // Minimal schema with 2 artifacts, no apply block\n      const schemaContent = `\nname: no-apply\nversion: 1\ndescription: Test schema without apply block\nartifacts:\n  - id: first\n    generates: first.md\n    description: First artifact\n    template: first.md\n    requires: []\n  - id: second\n    generates: second.md\n    description: Second artifact\n    template: second.md\n    requires: [first]\n`;\n      await fs.writeFile(path.join(noApplySchemaDir, 'schema.yaml'), schemaContent);\n      await fs.writeFile(path.join(templatesDir, 'first.md'), '# First\\n');\n      await fs.writeFile(path.join(templatesDir, 'second.md'), '# Second\\n');\n\n      // Create a change with only the first artifact (missing second)\n      const changeDir = path.join(changesDir, 'no-apply-test');\n      await fs.mkdir(changeDir, { recursive: true });\n      await fs.writeFile(path.join(changeDir, 'first.md'), '# First artifact content');\n\n      // Run with XDG_DATA_HOME pointing to our temp user data dir\n      const result = await runCLI(\n        ['instructions', 'apply', '--change', 'no-apply-test', '--schema', 'no-apply', '--json'],\n        {\n          cwd: tempDir,\n          env: { XDG_DATA_HOME: userDataDir },\n        }\n      );\n      expect(result.exitCode).toBe(0);\n\n      const json = JSON.parse(result.stdout);\n      // Without apply block, fallback requires ALL artifacts - second is missing\n      expect(json.schemaName).toBe('no-apply');\n      expect(json.state).toBe('blocked');\n      expect(json.missingArtifacts).toContain('second');\n    });\n\n    it('fallback: ready when all artifacts exist for schema without apply block', async () => {\n      // Create a minimal schema without an apply block\n      const userDataDir = path.join(tempDir, 'user-data-2');\n      const noApplySchemaDir = path.join(userDataDir, 'openspec', 'schemas', 'no-apply-full');\n      const templatesDir = path.join(noApplySchemaDir, 'templates');\n      await fs.mkdir(templatesDir, { recursive: true });\n\n      const schemaContent = `\nname: no-apply-full\nversion: 1\ndescription: Test schema without apply block\nartifacts:\n  - id: only\n    generates: only.md\n    description: Only artifact\n    template: only.md\n    requires: []\n`;\n      await fs.writeFile(path.join(noApplySchemaDir, 'schema.yaml'), schemaContent);\n      await fs.writeFile(path.join(templatesDir, 'only.md'), '# Only\\n');\n\n      // Create a change with the artifact present\n      const changeDir = path.join(changesDir, 'no-apply-full-test');\n      await fs.mkdir(changeDir, { recursive: true });\n      await fs.writeFile(path.join(changeDir, 'only.md'), '# Content');\n\n      const result = await runCLI(\n        ['instructions', 'apply', '--change', 'no-apply-full-test', '--schema', 'no-apply-full', '--json'],\n        {\n          cwd: tempDir,\n          env: { XDG_DATA_HOME: userDataDir },\n        }\n      );\n      expect(result.exitCode).toBe(0);\n\n      const json = JSON.parse(result.stdout);\n      // All artifacts exist, should be ready with default instruction\n      expect(json.schemaName).toBe('no-apply-full');\n      expect(json.state).toBe('ready');\n      expect(json.instruction).toContain('All required artifacts complete');\n    });\n  });\n\n  describe('help text', () => {\n    it('status command help shows description', async () => {\n      const result = await runCLI(['status', '--help']);\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('Display artifact completion status');\n    });\n\n    it('instructions command help shows description', async () => {\n      const result = await runCLI(['instructions', '--help']);\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('Output enriched instructions');\n    });\n\n    it('templates command help shows description', async () => {\n      const result = await runCLI(['templates', '--help']);\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('Show resolved template paths');\n    });\n\n    it('new command help shows description', async () => {\n      const result = await runCLI(['new', '--help']);\n      expect(result.exitCode).toBe(0);\n      expect(result.stdout).toContain('Create new items');\n    });\n  });\n\n  describe('experimental command (deprecated alias for init)', () => {\n    it('shows deprecation notice', async () => {\n      const result = await runCLI(['experimental', '--tool', 'claude'], { cwd: tempDir });\n      // May succeed or fail depending on setup, but should show deprecation notice\n      const output = getOutput(result);\n      expect(output).toContain('deprecated');\n    });\n\n    it('errors for unknown tool', async () => {\n      const result = await runCLI(['experimental', '--tool', 'unknown-tool'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain('Invalid tool(s): unknown-tool');\n    });\n\n    it('errors for tool without skillsDir', async () => {\n      // Using 'agents' which doesn't have skillsDir configured\n      const result = await runCLI(['experimental', '--tool', 'agents'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(1);\n      const output = getOutput(result);\n      expect(output).toContain('Invalid tool(s): agents');\n    });\n\n    it('creates skills for Claude tool', async () => {\n      const result = await runCLI(['experimental', '--tool', 'claude'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n      const output = normalizePaths(getOutput(result));\n      expect(output).toContain('Claude Code');\n      expect(output).toContain('.claude/');\n\n      // Verify skill files were created\n      const skillFile = path.join(tempDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const stat = await fs.stat(skillFile);\n      expect(stat.isFile()).toBe(true);\n    });\n\n    it('creates skills for Cursor tool', async () => {\n      const result = await runCLI(['experimental', '--tool', 'cursor'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n      const output = normalizePaths(getOutput(result));\n      expect(output).toContain('Cursor');\n      expect(output).toContain('.cursor/');\n\n      // Verify skill files were created\n      const skillFile = path.join(tempDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');\n      const stat = await fs.stat(skillFile);\n      expect(stat.isFile()).toBe(true);\n\n      // Verify commands were created with Cursor format\n      const commandFile = path.join(tempDir, '.cursor', 'commands', 'opsx-explore.md');\n      const content = await fs.readFile(commandFile, 'utf-8');\n      expect(content).toContain('name: /opsx-explore');\n    });\n\n    it('creates skills for Windsurf tool', async () => {\n      const result = await runCLI(['experimental', '--tool', 'windsurf'], {\n        cwd: tempDir,\n      });\n      expect(result.exitCode).toBe(0);\n      const output = normalizePaths(getOutput(result));\n      expect(output).toContain('Windsurf');\n      expect(output).toContain('.windsurf/');\n\n      // Verify skill files were created\n      const skillFile = path.join(tempDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md');\n      const stat = await fs.stat(skillFile);\n      expect(stat.isFile()).toBe(true);\n    });\n  });\n\n  describe('project config integration', () => {\n    describe('new change uses config schema', () => {\n      it('creates change with schema from project config', async () => {\n        // Create project config with spec-driven schema\n        // Note: changesDir is already at tempDir/openspec/changes (created in beforeEach)\n        await fs.writeFile(\n          path.join(tempDir, 'openspec', 'config.yaml'),\n          'schema: spec-driven\\n'\n        );\n\n        // Create a new change without specifying schema\n        const result = await runCLI(['new', 'change', 'test-change'], { cwd: tempDir, timeoutMs: 30000 });\n        expect(result.exitCode).toBe(0);\n\n        // Verify the change was created with spec-driven schema\n        const metadataPath = path.join(changesDir, 'test-change', '.openspec.yaml');\n        const metadata = await fs.readFile(metadataPath, 'utf-8');\n        expect(metadata).toContain('schema: spec-driven');\n      }, 60000);\n\n      it('CLI schema overrides config schema', async () => {\n        // Create project config with spec-driven schema\n        // Note: openspec directory already exists (from changesDir creation in beforeEach)\n        await fs.writeFile(\n          path.join(tempDir, 'openspec', 'config.yaml'),\n          'schema: spec-driven\\n'\n        );\n\n        // Create change with explicit schema\n        const result = await runCLI(\n          ['new', 'change', 'override-test', '--schema', 'spec-driven'],\n          { cwd: tempDir, timeoutMs: 30000 }\n        );\n        expect(result.exitCode).toBe(0);\n\n        // Verify the change uses the CLI-specified schema\n        const metadataPath = path.join(changesDir, 'override-test', '.openspec.yaml');\n        const metadata = await fs.readFile(metadataPath, 'utf-8');\n        expect(metadata).toContain('schema: spec-driven');\n      }, 60000);\n    });\n\n    describe('instructions command with config', () => {\n      it('injects context and rules from config into instructions', async () => {\n        // Create project config with context and rules\n        // Note: openspec directory already exists (from changesDir creation in beforeEach)\n        await fs.writeFile(\n          path.join(tempDir, 'openspec', 'config.yaml'),\n          `schema: spec-driven\ncontext: |\n  Tech stack: TypeScript, React\n  API style: RESTful\nrules:\n  proposal:\n    - Include rollback plan\n    - Identify affected teams\n`\n        );\n\n        // Create a test change\n        await createTestChange('config-test');\n\n        // Get instructions for proposal\n        const result = await runCLI(\n          ['instructions', 'proposal', '--change', 'config-test'],\n          { cwd: tempDir, timeoutMs: 30000 }\n        );\n        expect(result.exitCode).toBe(0);\n\n        // Verify context is injected\n        expect(result.stdout).toContain('Tech stack: TypeScript, React');\n        expect(result.stdout).toContain('API style: RESTful');\n\n        // Verify rules are injected for proposal\n        expect(result.stdout).toContain('Include rollback plan');\n        expect(result.stdout).toContain('Identify affected teams');\n      }, 60000);\n\n      it('does not inject rules for non-matching artifact', async () => {\n        // Create project config with rules only for proposal\n        // Note: openspec directory already exists (from changesDir creation in beforeEach)\n        await fs.writeFile(\n          path.join(tempDir, 'openspec', 'config.yaml'),\n          `schema: spec-driven\nrules:\n  proposal:\n    - Include rollback plan\n`\n        );\n\n        // Create a test change\n        await createTestChange('non-matching-test');\n\n        // Get instructions for design (not proposal)\n        const result = await runCLI(\n          ['instructions', 'design', '--change', 'non-matching-test'],\n          { cwd: tempDir, timeoutMs: 30000 }\n        );\n        expect(result.exitCode).toBe(0);\n\n        // Verify rules are NOT injected for design\n        expect(result.stdout).not.toContain('Include rollback plan');\n      }, 60000);\n    });\n\n    describe('backwards compatibility', () => {\n      it('existing changes work without config file', async () => {\n        // Create change without any config file\n        await createTestChange('no-config-change', ['proposal']);\n\n        // Status command should work\n        const statusResult = await runCLI(\n          ['status', '--change', 'no-config-change'],\n          { cwd: tempDir, timeoutMs: 30000 }\n        );\n        expect(statusResult.exitCode).toBe(0);\n        expect(statusResult.stdout).toContain('no-config-change');\n        expect(statusResult.stdout).toContain('spec-driven'); // Default schema\n\n        // Instructions command should work\n        const instrResult = await runCLI(\n          ['instructions', 'design', '--change', 'no-config-change'],\n          { cwd: tempDir, timeoutMs: 30000 }\n        );\n        expect(instrResult.exitCode).toBe(0);\n        expect(instrResult.stdout).toContain('<artifact');\n      }, 60000);\n\n      it('changes with metadata work without config file', async () => {\n        // Create change with explicit schema in metadata\n        const changeDir = await createTestChange('metadata-only-change');\n        await fs.writeFile(\n          path.join(changeDir, '.openspec.yaml'),\n          'schema: spec-driven\\ncreated: \"2025-01-05\"\\n'\n        );\n\n        // Status should use schema from metadata\n        const result = await runCLI(\n          ['status', '--change', 'metadata-only-change'],\n          { cwd: tempDir, timeoutMs: 30000 }\n        );\n        expect(result.exitCode).toBe(0);\n        expect(result.stdout).toContain('spec-driven');\n      }, 60000);\n    });\n\n    describe('config changes reflected immediately', () => {\n      it('config changes are reflected without restart', async () => {\n        // Create initial config\n        // Note: openspec directory already exists (from changesDir creation in beforeEach)\n        await fs.writeFile(\n          path.join(tempDir, 'openspec', 'config.yaml'),\n          `schema: spec-driven\ncontext: Initial context\n`\n        );\n\n        // Create a test change\n        await createTestChange('immediate-test');\n\n        // Get instructions - should have initial context\n        const result1 = await runCLI(\n          ['instructions', 'proposal', '--change', 'immediate-test'],\n          { cwd: tempDir, timeoutMs: 30000 }\n        );\n        expect(result1.exitCode).toBe(0);\n        expect(result1.stdout).toContain('Initial context');\n\n        // Update config\n        await fs.writeFile(\n          path.join(tempDir, 'openspec', 'config.yaml'),\n          `schema: spec-driven\ncontext: Updated context\n`\n        );\n\n        // Get instructions again - should have updated context\n        const result2 = await runCLI(\n          ['instructions', 'proposal', '--change', 'immediate-test'],\n          { cwd: tempDir, timeoutMs: 30000 }\n        );\n        expect(result2.exitCode).toBe(0);\n        expect(result2.stdout).toContain('Updated context');\n        expect(result2.stdout).not.toContain('Initial context');\n      }, 60000);\n    });\n  });\n});\n"
  },
  {
    "path": "test/commands/change.interactive-show.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { execSync } from 'child_process';\n\ndescribe('change show (interactive behavior)', () => {\n  const projectRoot = process.cwd();\n  const testDir = path.join(projectRoot, 'test-change-show-tmp');\n  const changesDir = path.join(testDir, 'openspec', 'changes');\n  const bin = path.join(projectRoot, 'bin', 'openspec.js');\n\n\n  beforeEach(async () => {\n    await fs.mkdir(changesDir, { recursive: true });\n    const content = `# Change: Demo\\n\\n## Why\\n\\n## What Changes\\n- x`;\n    await fs.mkdir(path.join(changesDir, 'demo'), { recursive: true });\n    await fs.writeFile(path.join(changesDir, 'demo', 'proposal.md'), content, 'utf-8');\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  it('prints list hint and exits non-zero when no arg and non-interactive', () => {\n    const originalCwd = process.cwd();\n    const originalEnv = { ...process.env };\n    try {\n      process.chdir(testDir);\n      process.env.OPEN_SPEC_INTERACTIVE = '0';\n      let err: any;\n      try {\n        execSync(`node ${bin} change show`, { encoding: 'utf-8' });\n      } catch (e) { err = e; }\n      expect(err).toBeDefined();\n      expect(err.status).not.toBe(0);\n      expect(err.stderr.toString()).toContain('Available IDs:');\n      expect(err.stderr.toString()).toContain('openspec change list');\n    } finally {\n      process.chdir(originalCwd);\n      process.env = originalEnv;\n    }\n  });\n});\n\n\n"
  },
  {
    "path": "test/commands/change.interactive-validate.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { execSync } from 'child_process';\n\n// Note: We cannot truly simulate TTY prompts in this test runner easily.\n// Instead, we verify non-interactive fallback behavior and basic invocation.\n\ndescribe('change validate (interactive behavior)', () => {\n  const projectRoot = process.cwd();\n  const testDir = path.join(projectRoot, 'test-change-validate-tmp');\n  const changesDir = path.join(testDir, 'openspec', 'changes');\n  const bin = path.join(projectRoot, 'bin', 'openspec.js');\n\n\n  beforeEach(async () => {\n    await fs.mkdir(changesDir, { recursive: true });\n    const content = `# Change: Demo\\n\\n## Why\\nBecause reasons that are sufficiently long.\\n\\n## What Changes\\n- **spec-x:** Add something`;\n    await fs.mkdir(path.join(changesDir, 'demo'), { recursive: true });\n    await fs.writeFile(path.join(changesDir, 'demo', 'proposal.md'), content, 'utf-8');\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  it('prints list hint and exits non-zero when no arg and non-interactive', () => {\n    const originalCwd = process.cwd();\n    const originalEnv = { ...process.env };\n    try {\n      process.chdir(testDir);\n      process.env.OPEN_SPEC_INTERACTIVE = '0';\n      let err: any;\n      try {\n        execSync(`node ${bin} change validate`, { encoding: 'utf-8' });\n      } catch (e) { err = e; }\n      expect(err).toBeDefined();\n      expect(err.status).not.toBe(0);\n      expect(err.stderr.toString()).toContain('Available IDs:');\n      expect(err.stderr.toString()).toContain('openspec change list');\n    } finally {\n      process.chdir(originalCwd);\n      process.env = originalEnv;\n    }\n  });\n});\n\n\n"
  },
  {
    "path": "test/commands/completion.test.ts",
    "content": "import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';\nimport { CompletionCommand } from '../../src/commands/completion.js';\nimport * as shellDetection from '../../src/utils/shell-detection.js';\n\n// Mock the shell detection module\nvi.mock('../../src/utils/shell-detection.js', () => ({\n  detectShell: vi.fn(),\n}));\n\n// Mock the ZshInstaller\nvi.mock('../../src/core/completions/installers/zsh-installer.js', () => ({\n  ZshInstaller: vi.fn().mockImplementation(() => ({\n    install: vi.fn().mockResolvedValue({\n      success: true,\n      installedPath: '/home/user/.oh-my-zsh/completions/_openspec',\n      isOhMyZsh: true,\n      message: 'Completion script installed successfully for Oh My Zsh',\n      instructions: [\n        'Completion script installed to Oh My Zsh completions directory.',\n        'Restart your shell or run: exec zsh',\n        'Completions should activate automatically.',\n      ],\n    }),\n    uninstall: vi.fn().mockResolvedValue({\n      success: true,\n      message: 'Completion script removed from /home/user/.oh-my-zsh/completions/_openspec',\n    }),\n  })),\n}));\n\ndescribe('CompletionCommand', () => {\n  let command: CompletionCommand;\n  let consoleLogSpy: any;\n  let consoleErrorSpy: any;\n\n  beforeEach(() => {\n    command = new CompletionCommand();\n    consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});\n    consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n    process.exitCode = 0;\n  });\n\n  afterEach(() => {\n    consoleLogSpy.mockRestore();\n    consoleErrorSpy.mockRestore();\n    vi.clearAllMocks();\n  });\n\n  describe('generate subcommand', () => {\n    it('should generate Zsh completion script to stdout', async () => {\n      await command.generate({ shell: 'zsh' });\n\n      expect(consoleLogSpy).toHaveBeenCalled();\n      const output = consoleLogSpy.mock.calls[0][0];\n      expect(output).toContain('#compdef openspec');\n      expect(output).toContain('_openspec() {');\n    });\n\n    it('should auto-detect Zsh shell when no shell specified', async () => {\n      vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: 'zsh', detected: 'zsh' });\n\n      await command.generate({});\n\n      expect(consoleLogSpy).toHaveBeenCalled();\n      const output = consoleLogSpy.mock.calls[0][0];\n      expect(output).toContain('#compdef openspec');\n    });\n\n    it('should show error when shell cannot be auto-detected', async () => {\n      vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: undefined });\n\n      await command.generate({});\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        'Error: Could not auto-detect shell. Please specify shell explicitly.'\n      );\n      expect(process.exitCode).toBe(1);\n    });\n\n    it('should show error for unsupported shell', async () => {\n      await command.generate({ shell: 'tcsh' });\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        \"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell\"\n      );\n      expect(process.exitCode).toBe(1);\n    });\n\n    it('should handle shell parameter case-insensitively', async () => {\n      await command.generate({ shell: 'ZSH' });\n\n      expect(consoleLogSpy).toHaveBeenCalled();\n      const output = consoleLogSpy.mock.calls[0][0];\n      expect(output).toContain('#compdef openspec');\n    });\n  });\n\n  describe('install subcommand', () => {\n    it('should install Zsh completion script', async () => {\n      await command.install({ shell: 'zsh' });\n\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Completion script installed successfully')\n      );\n      expect(process.exitCode).toBe(0);\n    });\n\n    it('should show verbose output when --verbose flag is provided', async () => {\n      await command.install({ shell: 'zsh', verbose: true });\n\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Installed to:')\n      );\n    });\n\n    it('should auto-detect Zsh shell when no shell specified', async () => {\n      vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: 'zsh', detected: 'zsh' });\n\n      await command.install({});\n\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Completion script installed successfully')\n      );\n    });\n\n    it('should show error when shell cannot be auto-detected', async () => {\n      vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: undefined });\n\n      await command.install({});\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        'Error: Could not auto-detect shell. Please specify shell explicitly.'\n      );\n      expect(process.exitCode).toBe(1);\n    });\n\n    it('should show error for unsupported shell', async () => {\n      await command.install({ shell: 'tcsh' });\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        \"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell\"\n      );\n      expect(process.exitCode).toBe(1);\n    });\n\n    it('should display installation instructions', async () => {\n      await command.install({ shell: 'zsh' });\n\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Restart your shell or run: exec zsh')\n      );\n    });\n  });\n\n  describe('uninstall subcommand', () => {\n    it('should uninstall Zsh completion script', async () => {\n      await command.uninstall({ shell: 'zsh', yes: true });\n\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Completion script removed')\n      );\n      expect(process.exitCode).toBe(0);\n    });\n\n    it('should auto-detect Zsh shell when no shell specified', async () => {\n      vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: 'zsh', detected: 'zsh' });\n\n      await command.uninstall({ yes: true });\n\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Completion script removed')\n      );\n    });\n\n    it('should show error when shell cannot be auto-detected', async () => {\n      vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: undefined });\n\n      await command.uninstall({ yes: true });\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        'Error: Could not auto-detect shell. Please specify shell explicitly.'\n      );\n      expect(process.exitCode).toBe(1);\n    });\n\n    it('should show error for unsupported shell', async () => {\n      await command.uninstall({ shell: 'tcsh', yes: true });\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        \"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell\"\n      );\n      expect(process.exitCode).toBe(1);\n    });\n  });\n\n  describe('error handling', () => {\n    it('should handle installation failures gracefully', async () => {\n      const { ZshInstaller } = await import('../../src/core/completions/installers/zsh-installer.js');\n      vi.mocked(ZshInstaller).mockImplementationOnce(() => ({\n        install: vi.fn().mockResolvedValue({\n          success: false,\n          isOhMyZsh: false,\n          message: 'Permission denied',\n        }),\n        uninstall: vi.fn(),\n        isInstalled: vi.fn(),\n        getInstallationInfo: vi.fn(),\n        isOhMyZshInstalled: vi.fn(),\n        getInstallationPath: vi.fn(),\n        backupExistingFile: vi.fn(),\n      } as any));\n\n      const cmd = new CompletionCommand();\n      await cmd.install({ shell: 'zsh' });\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Permission denied')\n      );\n      expect(process.exitCode).toBe(1);\n    });\n\n    it('should handle uninstallation failures gracefully', async () => {\n      const { ZshInstaller } = await import('../../src/core/completions/installers/zsh-installer.js');\n      vi.mocked(ZshInstaller).mockImplementationOnce(() => ({\n        install: vi.fn(),\n        uninstall: vi.fn().mockResolvedValue({\n          success: false,\n          message: 'Completion script is not installed',\n        }),\n        isInstalled: vi.fn(),\n        getInstallationInfo: vi.fn(),\n        isOhMyZshInstalled: vi.fn(),\n        getInstallationPath: vi.fn(),\n        backupExistingFile: vi.fn(),\n      } as any));\n\n      const cmd = new CompletionCommand();\n      await cmd.uninstall({ shell: 'zsh', yes: true });\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Completion script is not installed')\n      );\n      expect(process.exitCode).toBe(1);\n    });\n  });\n\n  describe('shell detection integration', () => {\n    it('should show appropriate error when detected shell is unsupported', async () => {\n      vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: 'tcsh' });\n\n      await command.generate({});\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        \"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell\"\n      );\n      expect(process.exitCode).toBe(1);\n    });\n\n    it('should respect explicit shell parameter over auto-detection', async () => {\n      vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: 'bash' });\n\n      await command.generate({ shell: 'zsh' });\n\n      expect(consoleLogSpy).toHaveBeenCalled();\n      const output = consoleLogSpy.mock.calls[0][0];\n      expect(output).toContain('#compdef openspec');\n    });\n  });\n});\n"
  },
  {
    "path": "test/commands/config-profile.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { Command } from 'commander';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\n\nvi.mock('@inquirer/prompts', () => ({\n  select: vi.fn(),\n  checkbox: vi.fn(),\n  confirm: vi.fn(),\n}));\n\nasync function runConfigCommand(args: string[]): Promise<void> {\n  const { registerConfigCommand } = await import('../../src/commands/config.js');\n  const program = new Command();\n  registerConfigCommand(program);\n  await program.parseAsync(['node', 'openspec', 'config', ...args]);\n}\n\nasync function getPromptMocks(): Promise<{\n  select: ReturnType<typeof vi.fn>;\n  checkbox: ReturnType<typeof vi.fn>;\n  confirm: ReturnType<typeof vi.fn>;\n}> {\n  const prompts = await import('@inquirer/prompts');\n  return {\n    select: prompts.select as unknown as ReturnType<typeof vi.fn>,\n    checkbox: prompts.checkbox as unknown as ReturnType<typeof vi.fn>,\n    confirm: prompts.confirm as unknown as ReturnType<typeof vi.fn>,\n  };\n}\n\ndescribe('diffProfileState workflow formatting', () => {\n  it('uses explicit \"removed\" wording when workflows are deleted', async () => {\n    const { diffProfileState } = await import('../../src/commands/config.js');\n\n    const diff = diffProfileState(\n      { profile: 'custom', delivery: 'both', workflows: ['propose', 'sync'] },\n      { profile: 'custom', delivery: 'both', workflows: ['propose'] },\n    );\n\n    expect(diff.hasChanges).toBe(true);\n    expect(diff.lines).toEqual(['workflows: removed sync']);\n  });\n\n  it('uses explicit labels when workflows are added and removed', async () => {\n    const { diffProfileState } = await import('../../src/commands/config.js');\n\n    const diff = diffProfileState(\n      { profile: 'custom', delivery: 'both', workflows: ['propose', 'sync'] },\n      { profile: 'custom', delivery: 'both', workflows: ['propose', 'verify'] },\n    );\n\n    expect(diff.hasChanges).toBe(true);\n    expect(diff.lines).toEqual(['workflows: added verify; removed sync']);\n  });\n});\n\ndescribe('deriveProfileFromWorkflowSelection', () => {\n  it('returns custom for an empty workflow selection', async () => {\n    const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js');\n    expect(deriveProfileFromWorkflowSelection([])).toBe('custom');\n  });\n\n  it('returns custom when selection is a superset of core workflows', async () => {\n    const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js');\n    expect(deriveProfileFromWorkflowSelection(['propose', 'explore', 'apply', 'archive', 'new'])).toBe('custom');\n  });\n\n  it('returns core when selection has exactly core workflows in different order', async () => {\n    const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js');\n    expect(deriveProfileFromWorkflowSelection(['archive', 'apply', 'explore', 'propose'])).toBe('core');\n  });\n});\n\ndescribe('config profile interactive flow', () => {\n  let tempDir: string;\n  let originalEnv: NodeJS.ProcessEnv;\n  let originalCwd: string;\n  let originalTTY: boolean | undefined;\n  let originalExitCode: number | undefined;\n  let consoleLogSpy: ReturnType<typeof vi.spyOn>;\n  let consoleErrorSpy: ReturnType<typeof vi.spyOn>;\n\n  function setupDriftedProjectArtifacts(projectDir: string): void {\n    fs.mkdirSync(path.join(projectDir, 'openspec'), { recursive: true });\n    const exploreSkillPath = path.join(projectDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n    fs.mkdirSync(path.dirname(exploreSkillPath), { recursive: true });\n    fs.writeFileSync(exploreSkillPath, 'name: openspec-explore\\n', 'utf-8');\n  }\n\n  function setupSyncedCoreBothArtifacts(projectDir: string): void {\n    fs.mkdirSync(path.join(projectDir, 'openspec'), { recursive: true });\n    const coreSkillDirs = [\n      'openspec-propose',\n      'openspec-explore',\n      'openspec-apply-change',\n      'openspec-archive-change',\n    ];\n    for (const dirName of coreSkillDirs) {\n      const skillPath = path.join(projectDir, '.claude', 'skills', dirName, 'SKILL.md');\n      fs.mkdirSync(path.dirname(skillPath), { recursive: true });\n      fs.writeFileSync(skillPath, `name: ${dirName}\\n`, 'utf-8');\n    }\n\n    const coreCommands = ['propose', 'explore', 'apply', 'archive'];\n    for (const commandId of coreCommands) {\n      const commandPath = path.join(projectDir, '.claude', 'commands', 'opsx', `${commandId}.md`);\n      fs.mkdirSync(path.dirname(commandPath), { recursive: true });\n      fs.writeFileSync(commandPath, `# ${commandId}\\n`, 'utf-8');\n    }\n  }\n\n  function addExtraSyncWorkflowArtifacts(projectDir: string): void {\n    const syncSkillPath = path.join(projectDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md');\n    fs.mkdirSync(path.dirname(syncSkillPath), { recursive: true });\n    fs.writeFileSync(syncSkillPath, 'name: openspec-sync-specs\\n', 'utf-8');\n\n    const syncCommandPath = path.join(projectDir, '.claude', 'commands', 'opsx', 'sync.md');\n    fs.mkdirSync(path.dirname(syncCommandPath), { recursive: true });\n    fs.writeFileSync(syncCommandPath, '# sync\\n', 'utf-8');\n  }\n\n  beforeEach(() => {\n    vi.resetModules();\n\n    tempDir = path.join(os.tmpdir(), `openspec-config-profile-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);\n    fs.mkdirSync(tempDir, { recursive: true });\n\n    originalEnv = { ...process.env };\n    originalCwd = process.cwd();\n    originalTTY = (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY;\n    originalExitCode = process.exitCode;\n\n    process.env.XDG_CONFIG_HOME = tempDir;\n    process.chdir(tempDir);\n    (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY = true;\n    process.exitCode = undefined;\n\n    consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});\n    consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n  });\n\n  afterEach(() => {\n    process.env = originalEnv;\n    process.chdir(originalCwd);\n    (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY = originalTTY;\n    process.exitCode = originalExitCode;\n    fs.rmSync(tempDir, { recursive: true, force: true });\n\n    consoleLogSpy.mockRestore();\n    consoleErrorSpy.mockRestore();\n    vi.clearAllMocks();\n  });\n\n  it('delivery-only action should not invoke workflow checkbox prompt', async () => {\n    const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js');\n    const { select, checkbox } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] });\n    select.mockResolvedValueOnce('delivery');\n    select.mockResolvedValueOnce('skills');\n\n    await runConfigCommand(['profile']);\n\n    expect(checkbox).not.toHaveBeenCalled();\n    expect(select).toHaveBeenCalledTimes(2);\n    expect(getGlobalConfig().delivery).toBe('skills');\n  });\n\n  it('action picker should use configure wording and describe each path', async () => {\n    const { saveGlobalConfig } = await import('../../src/core/global-config.js');\n    const { select } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] });\n    select.mockResolvedValueOnce('keep');\n\n    await runConfigCommand(['profile']);\n\n    const firstCall = select.mock.calls[0][0];\n    expect(firstCall.message).toBe('What do you want to configure?');\n    expect(firstCall.choices).toEqual(expect.arrayContaining([\n      expect.objectContaining({\n        value: 'delivery',\n        description: 'Change where workflows are installed',\n      }),\n      expect.objectContaining({\n        value: 'workflows',\n        description: 'Change which workflow actions are available',\n      }),\n      expect.objectContaining({\n        value: 'keep',\n        name: 'Keep current settings (exit)',\n      }),\n    ]));\n  });\n\n  it('workflows-only action should not invoke delivery prompt', async () => {\n    const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js');\n    const { ALL_WORKFLOWS } = await import('../../src/core/profiles.js');\n    const { select, checkbox } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] });\n    select.mockResolvedValueOnce('workflows');\n    checkbox.mockResolvedValueOnce(['propose', 'explore']);\n\n    await runConfigCommand(['profile']);\n\n    expect(select).toHaveBeenCalledTimes(1);\n    expect(checkbox).toHaveBeenCalledTimes(1);\n    const checkboxCall = checkbox.mock.calls[0][0];\n    expect(checkboxCall.pageSize).toBe(ALL_WORKFLOWS.length);\n    expect(checkboxCall.theme).toEqual({\n      icon: {\n        checked: '[x]',\n        unchecked: '[ ]',\n      },\n    });\n    const proposeChoice = checkboxCall.choices.find((choice: { value: string }) => choice.value === 'propose');\n    const onboardChoice = checkboxCall.choices.find((choice: { value: string }) => choice.value === 'onboard');\n    expect(proposeChoice.checked).toBe(true);\n    expect(onboardChoice.checked).toBe(false);\n    expect(getGlobalConfig().workflows).toEqual(['propose', 'explore']);\n  });\n\n  it('delivery picker should mark current option inline', async () => {\n    const { saveGlobalConfig } = await import('../../src/core/global-config.js');\n    const { select } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'custom', delivery: 'commands', workflows: ['explore'] });\n    select.mockResolvedValueOnce('delivery');\n    select.mockResolvedValueOnce('commands');\n\n    await runConfigCommand(['profile']);\n\n    expect(select).toHaveBeenCalledTimes(2);\n    const secondCall = select.mock.calls[1][0];\n    expect(secondCall.choices).toEqual(expect.arrayContaining([\n      expect.objectContaining({ value: 'commands', name: 'Commands only [current]' }),\n    ]));\n  });\n\n  it('workflow picker should use friendly names with descriptions', async () => {\n    const { saveGlobalConfig } = await import('../../src/core/global-config.js');\n    const { select, checkbox } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] });\n    select.mockResolvedValueOnce('workflows');\n    checkbox.mockResolvedValueOnce(['propose', 'explore', 'apply', 'archive']);\n\n    await runConfigCommand(['profile']);\n\n    const checkboxCall = checkbox.mock.calls[0][0];\n    expect(checkboxCall.message).toBe('Select workflows to make available:');\n    expect(checkboxCall.choices).toEqual(expect.arrayContaining([\n      expect.objectContaining({\n        value: 'propose',\n        name: 'Propose change',\n        description: 'Create proposal, design, and tasks from a request',\n      }),\n      expect.objectContaining({\n        value: 'verify',\n        name: 'Verify change',\n        description: 'Run verification checks against a change',\n      }),\n    ]));\n  });\n\n  it('selecting current values only should be a no-op and should not ask apply', async () => {\n    const { saveGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js');\n    const { select, confirm } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] });\n    const configPath = getGlobalConfigPath();\n    const beforeContent = fs.readFileSync(configPath, 'utf-8');\n\n    fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true });\n    select.mockResolvedValueOnce('delivery');\n    select.mockResolvedValueOnce('both');\n\n    await runConfigCommand(['profile']);\n\n    const afterContent = fs.readFileSync(configPath, 'utf-8');\n    expect(afterContent).toBe(beforeContent);\n    expect(confirm).not.toHaveBeenCalled();\n    expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.');\n  });\n\n  it('keep action should warn when project files drift from global config', async () => {\n    const { saveGlobalConfig } = await import('../../src/core/global-config.js');\n    const { select } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] });\n    setupDriftedProjectArtifacts(tempDir);\n    select.mockResolvedValueOnce('keep');\n\n    await runConfigCommand(['profile']);\n\n    expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.');\n    expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: Global config is not applied to this project.'));\n  });\n\n  it('keep action should not warn when project files are already synced', async () => {\n    const { saveGlobalConfig } = await import('../../src/core/global-config.js');\n    const { select } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] });\n    setupSyncedCoreBothArtifacts(tempDir);\n    select.mockResolvedValueOnce('keep');\n\n    await runConfigCommand(['profile']);\n\n    const allLogs = consoleLogSpy.mock.calls.map((args) => args.map(String).join(' '));\n    expect(allLogs.some((line) => line.includes('Warning: Global config is not applied to this project.'))).toBe(false);\n  });\n\n  it('effective no-op after prompts should warn when project files drift', async () => {\n    const { saveGlobalConfig } = await import('../../src/core/global-config.js');\n    const { select, confirm } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] });\n    setupDriftedProjectArtifacts(tempDir);\n    select.mockResolvedValueOnce('delivery');\n    select.mockResolvedValueOnce('both');\n\n    await runConfigCommand(['profile']);\n\n    expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.');\n    expect(confirm).not.toHaveBeenCalled();\n    expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: Global config is not applied to this project.'));\n  });\n\n  it('keep action should warn when project has extra workflows beyond global config', async () => {\n    const { saveGlobalConfig } = await import('../../src/core/global-config.js');\n    const { select } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] });\n    setupSyncedCoreBothArtifacts(tempDir);\n    addExtraSyncWorkflowArtifacts(tempDir);\n    select.mockResolvedValueOnce('keep');\n\n    await runConfigCommand(['profile']);\n\n    expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.');\n    expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: Global config is not applied to this project.'));\n  });\n\n  it('changed config should save and ask apply when inside project', async () => {\n    const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js');\n    const { select, confirm } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] });\n    fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true });\n\n    select.mockResolvedValueOnce('delivery');\n    select.mockResolvedValueOnce('skills');\n    confirm.mockResolvedValueOnce(false);\n\n    await runConfigCommand(['profile']);\n\n    expect(getGlobalConfig().delivery).toBe('skills');\n    expect(confirm).toHaveBeenCalledWith({\n      message: 'Apply changes to this project now?',\n      default: true,\n    });\n  });\n\n  it('core preset should preserve delivery setting', async () => {\n    const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js');\n    const { select, checkbox, confirm } = await getPromptMocks();\n\n    saveGlobalConfig({ featureFlags: {}, profile: 'custom', delivery: 'skills', workflows: ['explore'] });\n\n    await runConfigCommand(['profile', 'core']);\n\n    const config = getGlobalConfig();\n    expect(config.profile).toBe('core');\n    expect(config.delivery).toBe('skills');\n    expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'archive']);\n    expect(select).not.toHaveBeenCalled();\n    expect(checkbox).not.toHaveBeenCalled();\n    expect(confirm).not.toHaveBeenCalled();\n  });\n\n  it('Ctrl+C should cancel without stack trace and set interrupted exit code', async () => {\n    const { select, checkbox, confirm } = await getPromptMocks();\n    const cancellationError = new Error('User force closed the prompt with SIGINT');\n    cancellationError.name = 'ExitPromptError';\n\n    select.mockRejectedValueOnce(cancellationError);\n\n    await expect(runConfigCommand(['profile'])).resolves.toBeUndefined();\n\n    expect(consoleLogSpy).toHaveBeenCalledWith('Config profile cancelled.');\n    expect(process.exitCode).toBe(130);\n    expect(checkbox).not.toHaveBeenCalled();\n    expect(confirm).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "test/commands/config.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\n\ndescribe('config command integration', () => {\n  // These tests use real file system operations with XDG_CONFIG_HOME override\n  let tempDir: string;\n  let originalEnv: NodeJS.ProcessEnv;\n  let consoleErrorSpy: ReturnType<typeof vi.spyOn>;\n\n  beforeEach(() => {\n    // Create unique temp directory for each test\n    tempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);\n    fs.mkdirSync(tempDir, { recursive: true });\n\n    // Save original env and set XDG_CONFIG_HOME\n    originalEnv = { ...process.env };\n    process.env.XDG_CONFIG_HOME = tempDir;\n\n    // Spy on console.error\n    consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n  });\n\n  afterEach(() => {\n    // Restore original env\n    process.env = originalEnv;\n\n    // Clean up temp directory\n    fs.rmSync(tempDir, { recursive: true, force: true });\n\n    // Restore spies\n    consoleErrorSpy.mockRestore();\n\n    // Reset module cache to pick up new XDG_CONFIG_HOME\n    vi.resetModules();\n  });\n\n  it('should use XDG_CONFIG_HOME for config path', async () => {\n    const { getGlobalConfigPath } = await import('../../src/core/global-config.js');\n    const configPath = getGlobalConfigPath();\n    expect(configPath).toBe(path.join(tempDir, 'openspec', 'config.json'));\n  });\n\n  it('should save and load config correctly', async () => {\n    const { getGlobalConfig, saveGlobalConfig } = await import('../../src/core/global-config.js');\n\n    saveGlobalConfig({ featureFlags: { test: true } });\n    const config = getGlobalConfig();\n    expect(config.featureFlags).toEqual({ test: true });\n  });\n\n  it('should return defaults when config file does not exist', async () => {\n    const { getGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js');\n\n    const configPath = getGlobalConfigPath();\n    // Make sure config doesn't exist\n    if (fs.existsSync(configPath)) {\n      fs.unlinkSync(configPath);\n    }\n\n    const config = getGlobalConfig();\n    expect(config.featureFlags).toEqual({});\n  });\n\n  it('should preserve unknown fields', async () => {\n    const { getGlobalConfig, getGlobalConfigDir } = await import('../../src/core/global-config.js');\n\n    const configDir = getGlobalConfigDir();\n    fs.mkdirSync(configDir, { recursive: true });\n    fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({\n      featureFlags: {},\n      customField: 'preserved',\n    }));\n\n    const config = getGlobalConfig();\n    expect((config as Record<string, unknown>).customField).toBe('preserved');\n  });\n\n  it('should handle invalid JSON gracefully', async () => {\n    const { getGlobalConfig, getGlobalConfigDir } = await import('../../src/core/global-config.js');\n\n    const configDir = getGlobalConfigDir();\n    fs.mkdirSync(configDir, { recursive: true });\n    fs.writeFileSync(path.join(configDir, 'config.json'), '{ invalid json }');\n\n    const config = getGlobalConfig();\n    // Should return defaults\n    expect(config.featureFlags).toEqual({});\n    expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON'));\n  });\n});\n\ndescribe('config command shell completion registry', () => {\n  it('should have config command in registry', async () => {\n    const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');\n\n    const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');\n    expect(configCmd).toBeDefined();\n    expect(configCmd?.description).toBe('View and modify global OpenSpec configuration');\n  });\n\n  it('should have all config subcommands in registry', async () => {\n    const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');\n\n    const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');\n    const subcommandNames = configCmd?.subcommands?.map((s) => s.name) ?? [];\n\n    expect(subcommandNames).toContain('path');\n    expect(subcommandNames).toContain('list');\n    expect(subcommandNames).toContain('get');\n    expect(subcommandNames).toContain('set');\n    expect(subcommandNames).toContain('unset');\n    expect(subcommandNames).toContain('reset');\n    expect(subcommandNames).toContain('edit');\n  });\n\n  it('should have --json flag on list subcommand', async () => {\n    const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');\n\n    const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');\n    const listCmd = configCmd?.subcommands?.find((s) => s.name === 'list');\n    const flagNames = listCmd?.flags?.map((f) => f.name) ?? [];\n\n    expect(flagNames).toContain('json');\n  });\n\n  it('should have --string flag on set subcommand', async () => {\n    const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');\n\n    const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');\n    const setCmd = configCmd?.subcommands?.find((s) => s.name === 'set');\n    const flagNames = setCmd?.flags?.map((f) => f.name) ?? [];\n\n    expect(flagNames).toContain('string');\n    expect(flagNames).toContain('allow-unknown');\n  });\n\n  it('should have --all and -y flags on reset subcommand', async () => {\n    const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');\n\n    const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');\n    const resetCmd = configCmd?.subcommands?.find((s) => s.name === 'reset');\n    const flagNames = resetCmd?.flags?.map((f) => f.name) ?? [];\n\n    expect(flagNames).toContain('all');\n    expect(flagNames).toContain('yes');\n  });\n\n  it('should have --scope flag on config command', async () => {\n    const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js');\n\n    const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config');\n    const flagNames = configCmd?.flags?.map((f) => f.name) ?? [];\n\n    expect(flagNames).toContain('scope');\n  });\n});\n\ndescribe('config key validation', () => {\n  it('rejects unknown top-level keys', async () => {\n    const { validateConfigKeyPath } = await import('../../src/core/config-schema.js');\n    expect(validateConfigKeyPath('unknownKey').valid).toBe(false);\n  });\n\n  it('allows feature flag keys', async () => {\n    const { validateConfigKeyPath } = await import('../../src/core/config-schema.js');\n    expect(validateConfigKeyPath('featureFlags.someFlag').valid).toBe(true);\n  });\n\n  it('rejects deeply nested feature flag keys', async () => {\n    const { validateConfigKeyPath } = await import('../../src/core/config-schema.js');\n    expect(validateConfigKeyPath('featureFlags.someFlag.extra').valid).toBe(false);\n  });\n\n  it('allows profile key', async () => {\n    const { validateConfigKeyPath } = await import('../../src/core/config-schema.js');\n    expect(validateConfigKeyPath('profile').valid).toBe(true);\n  });\n\n  it('allows delivery key', async () => {\n    const { validateConfigKeyPath } = await import('../../src/core/config-schema.js');\n    expect(validateConfigKeyPath('delivery').valid).toBe(true);\n  });\n\n  it('allows workflows key', async () => {\n    const { validateConfigKeyPath } = await import('../../src/core/config-schema.js');\n    expect(validateConfigKeyPath('workflows').valid).toBe(true);\n  });\n});\n\ndescribe('config profile command', () => {\n  let tempDir: string;\n  let originalEnv: NodeJS.ProcessEnv;\n\n  beforeEach(() => {\n    tempDir = path.join(os.tmpdir(), `openspec-profile-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);\n    fs.mkdirSync(tempDir, { recursive: true });\n    originalEnv = { ...process.env };\n    process.env.XDG_CONFIG_HOME = tempDir;\n  });\n\n  afterEach(() => {\n    process.env = originalEnv;\n    fs.rmSync(tempDir, { recursive: true, force: true });\n    vi.resetModules();\n  });\n\n  it('core preset should set profile to core and preserve delivery', async () => {\n    const { getGlobalConfig, saveGlobalConfig } = await import('../../src/core/global-config.js');\n\n    // Set initial config with custom delivery\n    saveGlobalConfig({ featureFlags: {}, profile: 'custom', delivery: 'skills', workflows: ['explore'] });\n\n    // Simulate the core preset logic\n    const config = getGlobalConfig();\n    const { CORE_WORKFLOWS } = await import('../../src/core/profiles.js');\n    config.profile = 'core';\n    config.workflows = [...CORE_WORKFLOWS];\n    // Delivery should be preserved\n    saveGlobalConfig(config);\n\n    const result = getGlobalConfig();\n    expect(result.profile).toBe('core');\n    expect(result.delivery).toBe('skills'); // preserved\n    expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'archive']);\n  });\n\n  it('custom workflow selection should set profile to custom', async () => {\n    const { getGlobalConfig, saveGlobalConfig } = await import('../../src/core/global-config.js');\n    const { CORE_WORKFLOWS } = await import('../../src/core/profiles.js');\n\n    // Simulate custom selection that differs from core\n    const selectedWorkflows = ['explore', 'new', 'apply', 'ff', 'verify'];\n    const isCoreMatch =\n      selectedWorkflows.length === CORE_WORKFLOWS.length &&\n      CORE_WORKFLOWS.every((w: string) => selectedWorkflows.includes(w));\n\n    expect(isCoreMatch).toBe(false);\n\n    saveGlobalConfig({\n      featureFlags: {},\n      profile: isCoreMatch ? 'core' : 'custom',\n      delivery: 'both',\n      workflows: selectedWorkflows,\n    });\n\n    const result = getGlobalConfig();\n    expect(result.profile).toBe('custom');\n    expect(result.workflows).toEqual(selectedWorkflows);\n  });\n\n  it('selecting exactly core workflows should set profile to core', async () => {\n    const { CORE_WORKFLOWS } = await import('../../src/core/profiles.js');\n\n    const selectedWorkflows = [...CORE_WORKFLOWS];\n    const isCoreMatch =\n      selectedWorkflows.length === CORE_WORKFLOWS.length &&\n      CORE_WORKFLOWS.every((w: string) => selectedWorkflows.includes(w));\n\n    expect(isCoreMatch).toBe(true);\n  });\n\n  it('config schema should validate profile and delivery values', async () => {\n    const { validateConfig } = await import('../../src/core/config-schema.js');\n\n    expect(validateConfig({ featureFlags: {}, profile: 'core', delivery: 'both' }).success).toBe(true);\n    expect(validateConfig({ featureFlags: {}, profile: 'custom', delivery: 'skills' }).success).toBe(true);\n    expect(validateConfig({ featureFlags: {}, profile: 'custom', delivery: 'commands', workflows: ['explore'] }).success).toBe(true);\n  });\n\n  it('config schema should reject invalid profile values', async () => {\n    const { validateConfig } = await import('../../src/core/config-schema.js');\n\n    const result = validateConfig({ featureFlags: {}, profile: 'invalid' });\n    expect(result.success).toBe(false);\n  });\n\n  it('config schema should reject invalid delivery values', async () => {\n    const { validateConfig } = await import('../../src/core/config-schema.js');\n\n    const result = validateConfig({ featureFlags: {}, delivery: 'invalid' });\n    expect(result.success).toBe(false);\n  });\n});\n"
  },
  {
    "path": "test/commands/feedback.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { FeedbackCommand } from '../../src/commands/feedback.js';\nimport { execSync, execFileSync } from 'child_process';\n\n// Mock child_process functions\nvi.mock('child_process', () => ({\n  execSync: vi.fn(),\n  execFileSync: vi.fn(),\n}));\n\ndescribe('FeedbackCommand', () => {\n  let feedbackCommand: FeedbackCommand;\n  let consoleLogSpy: any;\n  let consoleErrorSpy: any;\n  let processExitSpy: any;\n  const mockExecSync = execSync as unknown as ReturnType<typeof vi.fn>;\n  const mockExecFileSync = execFileSync as unknown as ReturnType<typeof vi.fn>;\n\n  beforeEach(() => {\n    feedbackCommand = new FeedbackCommand();\n    consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});\n    consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n    processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {\n      throw new Error(`process.exit(${code})`);\n    });\n    vi.clearAllMocks();\n  });\n\n  afterEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  describe('gh CLI availability check', () => {\n    it('should use which command on Unix/macOS platforms', async () => {\n      // Mock platform as darwin\n      const originalPlatform = process.platform;\n      Object.defineProperty(process, 'platform', { value: 'darwin' });\n\n      mockExecSync.mockImplementation((cmd: string) => {\n        if (cmd === 'which gh') {\n          return Buffer.from('/usr/local/bin/gh');\n        }\n        if (cmd === 'gh auth status') {\n          return Buffer.from('Logged in');\n        }\n        return '';\n      });\n\n      mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/123\\n');\n\n      await feedbackCommand.execute('Test');\n\n      // Verify 'which gh' was called\n      expect(mockExecSync).toHaveBeenCalledWith('which gh', expect.any(Object));\n\n      // Restore original platform\n      Object.defineProperty(process, 'platform', { value: originalPlatform });\n    });\n\n    it('should use where command on Windows platform', async () => {\n      // Mock platform as win32\n      const originalPlatform = process.platform;\n      Object.defineProperty(process, 'platform', { value: 'win32' });\n\n      mockExecSync.mockImplementation((cmd: string) => {\n        if (cmd === 'where gh') {\n          return Buffer.from('C:\\\\Program Files\\\\GitHub CLI\\\\gh.exe');\n        }\n        if (cmd === 'gh auth status') {\n          return Buffer.from('Logged in');\n        }\n        return '';\n      });\n\n      mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/123\\n');\n\n      await feedbackCommand.execute('Test');\n\n      // Verify 'where gh' was called\n      expect(mockExecSync).toHaveBeenCalledWith('where gh', expect.any(Object));\n\n      // Restore original platform\n      Object.defineProperty(process, 'platform', { value: originalPlatform });\n    });\n\n    it('should handle missing gh CLI with fallback', async () => {\n      // Simulate gh not installed\n      mockExecSync.mockImplementation((cmd: string) => {\n        if (cmd === 'which gh' || cmd === 'where gh') {\n          throw new Error('Command not found');\n        }\n      });\n\n      try {\n        await feedbackCommand.execute('Test feedback');\n      } catch (error: any) {\n        // Should exit with code 0 (successful fallback)\n        expect(error.message).toBe('process.exit(0)');\n      }\n\n      // Should display warning\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('GitHub CLI not found')\n      );\n\n      // Should show formatted feedback\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('--- FORMATTED FEEDBACK ---')\n      );\n\n      // Should show manual submission URL\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('https://github.com/Fission-AI/OpenSpec/issues/new')\n      );\n    });\n\n    it('should handle unauthenticated gh CLI with fallback', async () => {\n      // Simulate gh installed but not authenticated\n      mockExecSync.mockImplementation((cmd: string) => {\n        if (cmd === 'which gh' || cmd === 'where gh') {\n          return Buffer.from('/usr/local/bin/gh');\n        }\n        if (cmd === 'gh auth status') {\n          throw new Error('Not authenticated');\n        }\n      });\n\n      try {\n        await feedbackCommand.execute('Test feedback');\n      } catch (error: any) {\n        // Should exit with code 0 (successful fallback)\n        expect(error.message).toBe('process.exit(0)');\n      }\n\n      // Should display warning\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('GitHub authentication required')\n      );\n\n      // Should show auth instructions\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('To auto-submit in the future: gh auth login')\n      );\n\n      // Should show formatted feedback\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('--- FORMATTED FEEDBACK ---')\n      );\n    });\n  });\n\n  describe('successful feedback submission', () => {\n    it('should submit feedback via gh CLI when authenticated', async () => {\n      const issueUrl = 'https://github.com/Fission-AI/OpenSpec/issues/123';\n\n      // Simulate gh installed and authenticated\n      mockExecSync.mockImplementation((cmd: string, options?: any) => {\n        if (cmd === 'which gh' || cmd === 'where gh') {\n          return Buffer.from('/usr/local/bin/gh');\n        }\n        if (cmd === 'gh auth status') {\n          return Buffer.from('Logged in');\n        }\n        return '';\n      });\n\n      mockExecFileSync.mockReturnValue(`${issueUrl}\\n`);\n\n      await feedbackCommand.execute('Great tool!');\n\n      // Should call gh with correct arguments using execFileSync\n      expect(mockExecFileSync).toHaveBeenCalledWith(\n        'gh',\n        [\n          'issue',\n          'create',\n          '--repo',\n          'Fission-AI/OpenSpec',\n          '--title',\n          'Feedback: Great tool!',\n          '--body',\n          expect.stringContaining('Submitted via OpenSpec CLI'),\n          '--label',\n          'feedback',\n        ],\n        expect.objectContaining({\n          encoding: 'utf-8',\n          stdio: 'pipe',\n        })\n      );\n\n      // Should display success message\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Feedback submitted successfully')\n      );\n\n      // Should display issue URL\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining(issueUrl)\n      );\n    });\n\n    it('should include --body flag when body is provided', async () => {\n      const issueUrl = 'https://github.com/Fission-AI/OpenSpec/issues/124';\n\n      mockExecSync.mockImplementation((cmd: string, options?: any) => {\n        if (cmd === 'which gh' || cmd === 'where gh') {\n          return Buffer.from('/usr/local/bin/gh');\n        }\n        if (cmd === 'gh auth status') {\n          return Buffer.from('Logged in');\n        }\n        return '';\n      });\n\n      mockExecFileSync.mockReturnValue(`${issueUrl}\\n`);\n\n      await feedbackCommand.execute('Title here', { body: 'Detailed description' });\n\n      // Verify body is included in the arguments\n      expect(mockExecFileSync).toHaveBeenCalledWith(\n        'gh',\n        expect.arrayContaining([\n          '--body',\n          expect.stringContaining('Detailed description'),\n        ]),\n        expect.any(Object)\n      );\n    });\n\n    it('should format title with \"Feedback:\" prefix', async () => {\n      mockExecSync.mockImplementation((cmd: string, options?: any) => {\n        if (cmd === 'which gh' || cmd === 'where gh') {\n          return Buffer.from('/usr/local/bin/gh');\n        }\n        if (cmd === 'gh auth status') {\n          return Buffer.from('Logged in');\n        }\n        return '';\n      });\n\n      mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/125\\n');\n\n      await feedbackCommand.execute('Test message');\n\n      // Verify title has \"Feedback:\" prefix\n      expect(mockExecFileSync).toHaveBeenCalledWith(\n        'gh',\n        expect.arrayContaining([\n          '--title',\n          'Feedback: Test message',\n        ]),\n        expect.any(Object)\n      );\n    });\n\n    it('should include metadata in issue body', async () => {\n      mockExecSync.mockImplementation((cmd: string, options?: any) => {\n        if (cmd === 'which gh' || cmd === 'where gh') {\n          return Buffer.from('/usr/local/bin/gh');\n        }\n        if (cmd === 'gh auth status') {\n          return Buffer.from('Logged in');\n        }\n        return '';\n      });\n\n      mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/126\\n');\n\n      await feedbackCommand.execute('Test', { body: 'Body text' });\n\n      // Verify metadata is included in body\n      expect(mockExecFileSync).toHaveBeenCalledWith(\n        'gh',\n        expect.arrayContaining([\n          '--body',\n          expect.stringMatching(/Submitted via OpenSpec CLI[\\s\\S]*Version:[\\s\\S]*Platform:[\\s\\S]*Timestamp:/),\n        ]),\n        expect.any(Object)\n      );\n    });\n\n    it('should add feedback label to the issue', async () => {\n      mockExecSync.mockImplementation((cmd: string, options?: any) => {\n        if (cmd === 'which gh' || cmd === 'where gh') {\n          return Buffer.from('/usr/local/bin/gh');\n        }\n        if (cmd === 'gh auth status') {\n          return Buffer.from('Logged in');\n        }\n        return '';\n      });\n\n      mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/127\\n');\n\n      await feedbackCommand.execute('Test');\n\n      // Verify feedback label is added\n      expect(mockExecFileSync).toHaveBeenCalledWith(\n        'gh',\n        expect.arrayContaining([\n          '--label',\n          'feedback',\n        ]),\n        expect.any(Object)\n      );\n    });\n  });\n\n  describe('error handling', () => {\n    it('should handle gh CLI execution failure', async () => {\n      mockExecSync.mockImplementation((cmd: string, options?: any) => {\n        if (cmd === 'which gh' || cmd === 'where gh') {\n          return Buffer.from('/usr/local/bin/gh');\n        }\n        if (cmd === 'gh auth status') {\n          return Buffer.from('Logged in');\n        }\n        return '';\n      });\n\n      // Mock execFileSync to throw error\n      mockExecFileSync.mockImplementation(() => {\n        const error: any = new Error('Network error');\n        error.status = 1;\n        error.stderr = Buffer.from('Error: Network connectivity issue');\n        throw error;\n      });\n\n      try {\n        await feedbackCommand.execute('Test');\n      } catch (error: any) {\n        // Should exit with the same code as gh CLI\n        expect(error.message).toBe('process.exit(1)');\n      }\n\n      // Should display the error from gh CLI\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Network connectivity issue')\n      );\n    });\n\n    it('should handle quotes in title and body without escaping (no shell injection)', async () => {\n      mockExecSync.mockImplementation((cmd: string, options?: any) => {\n        if (cmd === 'which gh' || cmd === 'where gh') {\n          return Buffer.from('/usr/local/bin/gh');\n        }\n        if (cmd === 'gh auth status') {\n          return Buffer.from('Logged in');\n        }\n        return '';\n      });\n\n      mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/128\\n');\n\n      await feedbackCommand.execute('Test with \"quotes\"', {\n        body: 'Body with \"quotes\"',\n      });\n\n      // Verify quotes are passed as-is (no escaping needed with execFileSync)\n      expect(mockExecFileSync).toHaveBeenCalledWith(\n        'gh',\n        expect.arrayContaining([\n          '--title',\n          'Feedback: Test with \"quotes\"',\n          '--body',\n          expect.stringContaining('Body with \"quotes\"'),\n        ]),\n        expect.any(Object)\n      );\n    });\n  });\n\n  describe('formatted feedback output', () => {\n    it('should display formatted feedback with proper structure', async () => {\n      mockExecSync.mockImplementation((cmd: string) => {\n        if (cmd === 'which gh' || cmd === 'where gh') {\n          throw new Error('Command not found');\n        }\n      });\n\n      try {\n        await feedbackCommand.execute('Test message', { body: 'Test body' });\n      } catch (error: any) {\n        // Expected to exit\n      }\n\n      // Verify formatted output structure\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('--- FORMATTED FEEDBACK ---')\n      );\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Title: Feedback: Test message')\n      );\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Labels: feedback')\n      );\n      expect(consoleLogSpy).toHaveBeenCalledWith(\n        expect.stringContaining('--- END FEEDBACK ---')\n      );\n    });\n\n    it('should generate correct manual submission URL', async () => {\n      mockExecSync.mockImplementation((cmd: string) => {\n        if (cmd === 'which gh' || cmd === 'where gh') {\n          throw new Error('Command not found');\n        }\n      });\n\n      try {\n        await feedbackCommand.execute('Test');\n      } catch (error: any) {\n        // Expected to exit\n      }\n\n      // Verify URL is shown\n      const urlCall = consoleLogSpy.mock.calls.find((call: any[]) =>\n        call[0]?.includes('https://github.com/Fission-AI/OpenSpec/issues/new')\n      );\n      expect(urlCall).toBeDefined();\n\n      // Verify URL has proper parameters\n      const url = urlCall?.[0];\n      expect(url).toContain('title=');\n      expect(url).toContain('body=');\n      expect(url).toContain('labels=feedback');\n    });\n  });\n});\n"
  },
  {
    "path": "test/commands/schema.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\n\ndescribe('schema command', () => {\n  let tempDir: string;\n  let originalCwd: string;\n  let originalEnv: NodeJS.ProcessEnv;\n  let consoleLogSpy: ReturnType<typeof vi.spyOn>;\n  let consoleErrorSpy: ReturnType<typeof vi.spyOn>;\n\n  beforeEach(() => {\n    // Create unique temp directory for each test\n    tempDir = path.join(\n      os.tmpdir(),\n      `openspec-schema-test-${Date.now()}-${Math.random().toString(36).slice(2)}`\n    );\n    fs.mkdirSync(tempDir, { recursive: true });\n\n    // Create openspec directory structure\n    fs.mkdirSync(path.join(tempDir, 'openspec', 'schemas'), { recursive: true });\n\n    // Save original cwd and env\n    originalCwd = process.cwd();\n    originalEnv = { ...process.env };\n\n    // Change to temp directory\n    process.chdir(tempDir);\n\n    // Set XDG paths to temp to avoid polluting user directories\n    process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg-data');\n    process.env.XDG_CONFIG_HOME = path.join(tempDir, 'xdg-config');\n\n    // Spy on console\n    consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});\n    consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n  });\n\n  afterEach(() => {\n    // Restore cwd and env\n    process.chdir(originalCwd);\n    process.env = originalEnv;\n\n    // Clean up temp directory\n    fs.rmSync(tempDir, { recursive: true, force: true });\n\n    // Restore spies\n    consoleLogSpy.mockRestore();\n    consoleErrorSpy.mockRestore();\n\n    // Reset module cache\n    vi.resetModules();\n  });\n\n  describe('schema which', () => {\n    it('should show schema resolution from package', async () => {\n      const { getSchemaDir, listSchemas } = await import(\n        '../../src/core/artifact-graph/resolver.js'\n      );\n\n      // Verify spec-driven exists in package\n      const schemas = listSchemas(tempDir);\n      expect(schemas).toContain('spec-driven');\n\n      const schemaDir = getSchemaDir('spec-driven', tempDir);\n      expect(schemaDir).not.toBeNull();\n      expect(schemaDir).toContain('schemas');\n    });\n\n    it('should detect project schema shadowing package', async () => {\n      // Create a project-local spec-driven schema\n      const projectSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');\n      fs.mkdirSync(projectSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(projectSchemaDir, 'schema.yaml'),\n        `name: spec-driven\nversion: 1\ndescription: Custom spec-driven\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    description: Proposal\n    template: proposal.md\n`\n      );\n      fs.writeFileSync(path.join(projectSchemaDir, 'proposal.md'), '# Proposal');\n\n      const { getSchemaDir } = await import('../../src/core/artifact-graph/resolver.js');\n\n      // Should resolve to project\n      const schemaDir = getSchemaDir('spec-driven', tempDir);\n      expect(schemaDir).toBe(projectSchemaDir);\n    });\n\n    it('should list all schemas with --all flag', async () => {\n      const { listSchemas } = await import('../../src/core/artifact-graph/resolver.js');\n\n      const schemas = listSchemas(tempDir);\n      expect(schemas.length).toBeGreaterThan(0);\n      expect(schemas).toContain('spec-driven');\n    });\n  });\n\n  describe('schema validate', () => {\n    it('should validate a valid schema', async () => {\n      // Create a valid project schema\n      const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'test-schema');\n      fs.mkdirSync(schemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(schemaDir, 'schema.yaml'),\n        `name: test-schema\nversion: 1\ndescription: Test schema\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    description: Proposal\n    template: proposal.md\n`\n      );\n      fs.writeFileSync(path.join(schemaDir, 'proposal.md'), '# Proposal Template');\n\n      const { parseSchema } = await import('../../src/core/artifact-graph/schema.js');\n      const content = fs.readFileSync(path.join(schemaDir, 'schema.yaml'), 'utf-8');\n      const schema = parseSchema(content);\n\n      expect(schema.name).toBe('test-schema');\n      expect(schema.artifacts).toHaveLength(1);\n    });\n\n    it('should detect missing template file', async () => {\n      const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'bad-schema');\n      fs.mkdirSync(schemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(schemaDir, 'schema.yaml'),\n        `name: bad-schema\nversion: 1\ndescription: Bad schema\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    description: Proposal\n    template: missing-template.md\n`\n      );\n\n      // Template file doesn't exist, validation should report this\n      const templatePath = path.join(schemaDir, 'missing-template.md');\n      expect(fs.existsSync(templatePath)).toBe(false);\n    });\n\n    it('should detect circular dependencies', async () => {\n      const { parseSchema, SchemaValidationError } = await import(\n        '../../src/core/artifact-graph/schema.js'\n      );\n\n      const content = `name: circular-schema\nversion: 1\ndescription: Schema with circular deps\nartifacts:\n  - id: a\n    generates: a.md\n    description: A\n    template: a.md\n    requires:\n      - b\n  - id: b\n    generates: b.md\n    description: B\n    template: b.md\n    requires:\n      - a\n`;\n\n      expect(() => parseSchema(content)).toThrow(SchemaValidationError);\n      expect(() => parseSchema(content)).toThrow(/[Cc]yclic/);\n    });\n\n    it('should detect unknown dependency reference', async () => {\n      const { parseSchema, SchemaValidationError } = await import(\n        '../../src/core/artifact-graph/schema.js'\n      );\n\n      const content = `name: bad-ref-schema\nversion: 1\ndescription: Schema with bad ref\nartifacts:\n  - id: a\n    generates: a.md\n    description: A\n    template: a.md\n    requires:\n      - nonexistent\n`;\n\n      expect(() => parseSchema(content)).toThrow(SchemaValidationError);\n      expect(() => parseSchema(content)).toThrow(/nonexistent/);\n    });\n  });\n\n  describe('schema fork', () => {\n    it('should copy schema to project directory', async () => {\n      const { getSchemaDir } = await import('../../src/core/artifact-graph/resolver.js');\n\n      // Get the package spec-driven schema\n      const sourceDir = getSchemaDir('spec-driven', tempDir);\n      expect(sourceDir).not.toBeNull();\n\n      // Copy manually to simulate fork\n      const destDir = path.join(tempDir, 'openspec', 'schemas', 'my-custom');\n      fs.mkdirSync(destDir, { recursive: true });\n\n      // Copy files\n      const files = fs.readdirSync(sourceDir!);\n      for (const file of files) {\n        const srcPath = path.join(sourceDir!, file);\n        const destPath = path.join(destDir, file);\n        const stat = fs.statSync(srcPath);\n\n        if (stat.isFile()) {\n          fs.copyFileSync(srcPath, destPath);\n        }\n      }\n\n      // Verify destination exists\n      expect(fs.existsSync(path.join(destDir, 'schema.yaml'))).toBe(true);\n    });\n\n    it('should reject invalid schema names', () => {\n      // Test kebab-case validation\n      const isValidSchemaName = (name: string): boolean => {\n        return /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name);\n      };\n\n      expect(isValidSchemaName('my-schema')).toBe(true);\n      expect(isValidSchemaName('my-schema-v2')).toBe(true);\n      expect(isValidSchemaName('schema123')).toBe(true);\n      expect(isValidSchemaName('My Schema')).toBe(false);\n      expect(isValidSchemaName('my_schema')).toBe(false);\n      expect(isValidSchemaName('MySchema')).toBe(false);\n      expect(isValidSchemaName('-my-schema')).toBe(false);\n      expect(isValidSchemaName('123schema')).toBe(false);\n    });\n  });\n\n  describe('schema init', () => {\n    it('should create schema directory with schema.yaml', async () => {\n      const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'new-schema');\n      fs.mkdirSync(schemaDir, { recursive: true });\n\n      const { stringify: stringifyYaml } = await import('yaml');\n\n      const schema = {\n        name: 'new-schema',\n        version: 1,\n        description: 'A new schema',\n        artifacts: [\n          {\n            id: 'proposal',\n            generates: 'proposal.md',\n            description: 'Proposal',\n            template: 'proposal.md',\n            requires: [],\n          },\n        ],\n      };\n\n      fs.writeFileSync(path.join(schemaDir, 'schema.yaml'), stringifyYaml(schema));\n      fs.writeFileSync(path.join(schemaDir, 'proposal.md'), '# Proposal');\n\n      // Verify\n      expect(fs.existsSync(path.join(schemaDir, 'schema.yaml'))).toBe(true);\n      expect(fs.existsSync(path.join(schemaDir, 'proposal.md'))).toBe(true);\n    });\n\n    it('should validate schema name format', () => {\n      const isValidSchemaName = (name: string): boolean => {\n        return /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name);\n      };\n\n      expect(isValidSchemaName('valid-name')).toBe(true);\n      expect(isValidSchemaName('Invalid Name')).toBe(false);\n    });\n\n    it('should set up artifact dependencies correctly', async () => {\n      const { parseSchema } = await import('../../src/core/artifact-graph/schema.js');\n\n      // Create schema with standard artifact chain\n      const content = `name: test-workflow\nversion: 1\ndescription: Test workflow\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    description: Proposal\n    template: proposal.md\n  - id: specs\n    generates: specs/**/*.md\n    description: Specs\n    template: specs/spec.md\n    requires:\n      - proposal\n  - id: design\n    generates: design.md\n    description: Design\n    template: design.md\n    requires:\n      - specs\n  - id: tasks\n    generates: tasks.md\n    description: Tasks\n    template: tasks.md\n    requires:\n      - design\n`;\n\n      const schema = parseSchema(content);\n      expect(schema.artifacts[0].requires).toEqual([]);\n      expect(schema.artifacts[1].requires).toEqual(['proposal']);\n      expect(schema.artifacts[2].requires).toEqual(['specs']);\n      expect(schema.artifacts[3].requires).toEqual(['design']);\n    });\n  });\n\n  describe('JSON output format', () => {\n    it('should output valid JSON for schema which', async () => {\n      const { listSchemas } = await import('../../src/core/artifact-graph/resolver.js');\n\n      const schemas = listSchemas(tempDir);\n      const jsonOutput = JSON.stringify(schemas);\n\n      expect(() => JSON.parse(jsonOutput)).not.toThrow();\n    });\n\n    it('should include expected fields in validation JSON', () => {\n      const validationResult = {\n        valid: true,\n        name: 'test-schema',\n        path: '/path/to/schema',\n        issues: [],\n      };\n\n      const json = JSON.stringify(validationResult);\n      const parsed = JSON.parse(json);\n\n      expect(parsed).toHaveProperty('valid');\n      expect(parsed).toHaveProperty('name');\n      expect(parsed).toHaveProperty('path');\n      expect(parsed).toHaveProperty('issues');\n    });\n\n    it('should include expected fields in fork JSON', () => {\n      const forkResult = {\n        forked: true,\n        source: 'spec-driven',\n        sourcePath: '/path/to/source',\n        sourceLocation: 'package',\n        destination: 'my-custom',\n        destinationPath: '/path/to/dest',\n      };\n\n      const json = JSON.stringify(forkResult);\n      const parsed = JSON.parse(json);\n\n      expect(parsed).toHaveProperty('forked');\n      expect(parsed).toHaveProperty('source');\n      expect(parsed).toHaveProperty('sourceLocation');\n      expect(parsed).toHaveProperty('destination');\n    });\n\n    it('should include expected fields in init JSON', () => {\n      const initResult = {\n        created: true,\n        path: '/path/to/schema',\n        schema: 'new-schema',\n        artifacts: ['proposal', 'specs'],\n        setAsDefault: false,\n      };\n\n      const json = JSON.stringify(initResult);\n      const parsed = JSON.parse(json);\n\n      expect(parsed).toHaveProperty('created');\n      expect(parsed).toHaveProperty('path');\n      expect(parsed).toHaveProperty('schema');\n      expect(parsed).toHaveProperty('artifacts');\n    });\n  });\n});\n\ndescribe('schema command shell completion registry', () => {\n  it('should have schema command in registry', async () => {\n    const { COMMAND_REGISTRY } = await import(\n      '../../src/core/completions/command-registry.js'\n    );\n\n    const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');\n    expect(schemaCmd).toBeDefined();\n    expect(schemaCmd?.description).toBe('Manage workflow schemas');\n  });\n\n  it('should have all schema subcommands in registry', async () => {\n    const { COMMAND_REGISTRY } = await import(\n      '../../src/core/completions/command-registry.js'\n    );\n\n    const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');\n    const subcommandNames = schemaCmd?.subcommands?.map((s) => s.name) ?? [];\n\n    expect(subcommandNames).toContain('which');\n    expect(subcommandNames).toContain('validate');\n    expect(subcommandNames).toContain('fork');\n    expect(subcommandNames).toContain('init');\n  });\n\n  it('should have --json flag on all subcommands', async () => {\n    const { COMMAND_REGISTRY } = await import(\n      '../../src/core/completions/command-registry.js'\n    );\n\n    const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');\n    const subcommands = schemaCmd?.subcommands ?? [];\n\n    for (const subcmd of subcommands) {\n      const flagNames = subcmd.flags?.map((f) => f.name) ?? [];\n      expect(flagNames).toContain('json');\n    }\n  });\n\n  it('should have --all flag on which subcommand', async () => {\n    const { COMMAND_REGISTRY } = await import(\n      '../../src/core/completions/command-registry.js'\n    );\n\n    const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');\n    const whichCmd = schemaCmd?.subcommands?.find((s) => s.name === 'which');\n    const flagNames = whichCmd?.flags?.map((f) => f.name) ?? [];\n\n    expect(flagNames).toContain('all');\n  });\n\n  it('should have --verbose flag on validate subcommand', async () => {\n    const { COMMAND_REGISTRY } = await import(\n      '../../src/core/completions/command-registry.js'\n    );\n\n    const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');\n    const validateCmd = schemaCmd?.subcommands?.find((s) => s.name === 'validate');\n    const flagNames = validateCmd?.flags?.map((f) => f.name) ?? [];\n\n    expect(flagNames).toContain('verbose');\n  });\n\n  it('should have --force flag on fork and init subcommands', async () => {\n    const { COMMAND_REGISTRY } = await import(\n      '../../src/core/completions/command-registry.js'\n    );\n\n    const schemaCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'schema');\n    const forkCmd = schemaCmd?.subcommands?.find((s) => s.name === 'fork');\n    const initCmd = schemaCmd?.subcommands?.find((s) => s.name === 'init');\n\n    expect(forkCmd?.flags?.map((f) => f.name)).toContain('force');\n    expect(initCmd?.flags?.map((f) => f.name)).toContain('force');\n  });\n});\n"
  },
  {
    "path": "test/commands/show.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { execSync } from 'child_process';\n\ndescribe('top-level show command', () => {\n  const projectRoot = process.cwd();\n  const testDir = path.join(projectRoot, 'test-show-command-tmp');\n  const changesDir = path.join(testDir, 'openspec', 'changes');\n  const specsDir = path.join(testDir, 'openspec', 'specs');\n  const openspecBin = path.join(projectRoot, 'bin', 'openspec.js');\n\n\n  beforeEach(async () => {\n    await fs.mkdir(changesDir, { recursive: true });\n    await fs.mkdir(specsDir, { recursive: true });\n\n    const changeContent = `# Change: Demo\\n\\n## Why\\nBecause reasons.\\n\\n## What Changes\\n- **auth:** Add requirement\\n`;\n    await fs.mkdir(path.join(changesDir, 'demo'), { recursive: true });\n    await fs.writeFile(path.join(changesDir, 'demo', 'proposal.md'), changeContent, 'utf-8');\n\n    const specContent = `## Purpose\\nAuth spec.\\n\\n## Requirements\\n\\n### Requirement: User Authentication\\nText\\n`;\n    await fs.mkdir(path.join(specsDir, 'auth'), { recursive: true });\n    await fs.writeFile(path.join(specsDir, 'auth', 'spec.md'), specContent, 'utf-8');\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  it('prints hint and non-zero exit when no args and non-interactive', () => {\n    const originalCwd = process.cwd();\n    const originalEnv = { ...process.env };\n    try {\n      process.chdir(testDir);\n      process.env.OPEN_SPEC_INTERACTIVE = '0';\n      let err: any;\n      try {\n        execSync(`node ${openspecBin} show`, { encoding: 'utf-8' });\n      } catch (e) { err = e; }\n      expect(err).toBeDefined();\n      expect(err.status).not.toBe(0);\n      const stderr = err.stderr.toString();\n      expect(stderr).toContain('Nothing to show.');\n      expect(stderr).toContain('openspec show <item>');\n      expect(stderr).toContain('openspec change show');\n      expect(stderr).toContain('openspec spec show');\n    } finally {\n      process.chdir(originalCwd);\n      process.env = originalEnv;\n    }\n  });\n\n  it('auto-detects change id and supports --json', () => {\n    const originalCwd = process.cwd();\n    try {\n      process.chdir(testDir);\n      const output = execSync(`node ${openspecBin} show demo --json`, { encoding: 'utf-8' });\n      const json = JSON.parse(output);\n      expect(json.id).toBe('demo');\n      expect(Array.isArray(json.deltas)).toBe(true);\n    } finally {\n      process.chdir(originalCwd);\n    }\n  });\n\n  it('auto-detects spec id and supports spec-only flags', () => {\n    const originalCwd = process.cwd();\n    try {\n      process.chdir(testDir);\n      const output = execSync(`node ${openspecBin} show auth --json --requirements`, { encoding: 'utf-8' });\n      const json = JSON.parse(output);\n      expect(json.id).toBe('auth');\n      expect(Array.isArray(json.requirements)).toBe(true);\n    } finally {\n      process.chdir(originalCwd);\n    }\n  });\n\n  it('handles ambiguity and suggests --type', async () => {\n    // create matching spec and change named 'foo'\n    await fs.mkdir(path.join(changesDir, 'foo'), { recursive: true });\n    await fs.writeFile(path.join(changesDir, 'foo', 'proposal.md'), '# Change: Foo\\n\\n## Why\\n\\n## What Changes\\n', 'utf-8');\n    await fs.mkdir(path.join(specsDir, 'foo'), { recursive: true });\n    await fs.writeFile(path.join(specsDir, 'foo', 'spec.md'), '## Purpose\\n\\n## Requirements\\n\\n### Requirement: R\\nX', 'utf-8');\n\n    const originalCwd = process.cwd();\n    try {\n      process.chdir(testDir);\n      let err: any;\n      try {\n        execSync(`node ${openspecBin} show foo`, { encoding: 'utf-8' });\n      } catch (e) { err = e; }\n      expect(err).toBeDefined();\n      expect(err.status).not.toBe(0);\n      const stderr = err.stderr.toString();\n      expect(stderr).toContain('Ambiguous item');\n      expect(stderr).toContain('--type change|spec');\n    } finally {\n      process.chdir(originalCwd);\n    }\n  });\n\n  it('prints nearest matches when not found', () => {\n    const originalCwd = process.cwd();\n    try {\n      process.chdir(testDir);\n      let err: any;\n      try {\n        execSync(`node ${openspecBin} show unknown-item`, { encoding: 'utf-8' });\n      } catch (e) { err = e; }\n      expect(err).toBeDefined();\n      expect(err.status).not.toBe(0);\n      const stderr = err.stderr.toString();\n      expect(stderr).toContain(\"Unknown item 'unknown-item'\");\n      expect(stderr).toContain('Did you mean:');\n    } finally {\n      process.chdir(originalCwd);\n    }\n  });\n});\n\n\n"
  },
  {
    "path": "test/commands/spec.interactive-show.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { execSync } from 'child_process';\n\ndescribe('spec show (interactive behavior)', () => {\n  const projectRoot = process.cwd();\n  const testDir = path.join(projectRoot, 'test-spec-show-tmp');\n  const specsDir = path.join(testDir, 'openspec', 'specs');\n  const bin = path.join(projectRoot, 'bin', 'openspec.js');\n\n\n  beforeEach(async () => {\n    await fs.mkdir(specsDir, { recursive: true });\n    const content = `## Purpose\\nX\\n\\n## Requirements\\n\\n### Requirement: R\\nText`;\n    await fs.mkdir(path.join(specsDir, 's1'), { recursive: true });\n    await fs.writeFile(path.join(specsDir, 's1', 'spec.md'), content, 'utf-8');\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  it('errors when no arg and non-interactive', () => {\n    const originalCwd = process.cwd();\n    const originalEnv = { ...process.env };\n    try {\n      process.chdir(testDir);\n      process.env.OPEN_SPEC_INTERACTIVE = '0';\n      let err: any;\n      try {\n        execSync(`node ${bin} spec show`, { encoding: 'utf-8' });\n      } catch (e) { err = e; }\n      expect(err).toBeDefined();\n      expect(err.status).not.toBe(0);\n      expect(err.stderr.toString()).toContain('Missing required argument <spec-id>');\n    } finally {\n      process.chdir(originalCwd);\n      process.env = originalEnv;\n    }\n  });\n});\n\n\n"
  },
  {
    "path": "test/commands/spec.interactive-validate.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { execSync } from 'child_process';\n\ndescribe('spec validate (interactive behavior)', () => {\n  const projectRoot = process.cwd();\n  const testDir = path.join(projectRoot, 'test-spec-validate-tmp');\n  const specsDir = path.join(testDir, 'openspec', 'specs');\n  const bin = path.join(projectRoot, 'bin', 'openspec.js');\n\n\n  beforeEach(async () => {\n    await fs.mkdir(specsDir, { recursive: true });\n    const content = `## Purpose\\nValid spec for interactive test.\\n\\n## Requirements\\n\\n### Requirement: X\\nText`;\n    await fs.mkdir(path.join(specsDir, 's1'), { recursive: true });\n    await fs.writeFile(path.join(specsDir, 's1', 'spec.md'), content, 'utf-8');\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  it('errors when no arg and non-interactive', () => {\n    const originalCwd = process.cwd();\n    const originalEnv = { ...process.env };\n    try {\n      process.chdir(testDir);\n      process.env.OPEN_SPEC_INTERACTIVE = '0';\n      let err: any;\n      try {\n        execSync(`node ${bin} spec validate`, { encoding: 'utf-8' });\n      } catch (e) { err = e; }\n      expect(err).toBeDefined();\n      expect(err.status).not.toBe(0);\n      expect(err.stderr.toString()).toContain('Missing required argument <spec-id>');\n    } finally {\n      process.chdir(originalCwd);\n      process.env = originalEnv;\n    }\n  });\n});\n\n\n"
  },
  {
    "path": "test/commands/spec.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { execSync } from 'child_process';\n\ndescribe('spec command', () => {\n  const projectRoot = process.cwd();\n  const testDir = path.join(projectRoot, 'test-spec-command-tmp');\n  const specsDir = path.join(testDir, 'openspec', 'specs');\n  const openspecBin = path.join(projectRoot, 'bin', 'openspec.js');\n  \n  \n  beforeEach(async () => {\n    await fs.mkdir(specsDir, { recursive: true });\n    \n    // Create test spec files\n    const testSpec = `## Purpose\nThis is a test specification for the authentication system.\n\n## Requirements\n\n### Requirement: User Authentication\nThe system SHALL provide secure user authentication\n\n#### Scenario: Successful login\n- **GIVEN** a user with valid credentials\n- **WHEN** they submit the login form  \n- **THEN** they are authenticated\n\n### Requirement: Password Reset\nThe system SHALL allow users to reset their password\n\n#### Scenario: Reset via email\n- **GIVEN** a user with a registered email\n- **WHEN** they request a password reset\n- **THEN** they receive a reset link`;\n\n    await fs.mkdir(path.join(specsDir, 'auth'), { recursive: true });\n    await fs.writeFile(path.join(specsDir, 'auth', 'spec.md'), testSpec);\n    \n    const testSpec2 = `## Purpose\nThis specification defines the payment processing system.\n\n## Requirements\n\n### Requirement: Process Payments\nThe system SHALL process credit card payments securely`;\n\n    await fs.mkdir(path.join(specsDir, 'payment'), { recursive: true });\n    await fs.writeFile(path.join(specsDir, 'payment', 'spec.md'), testSpec2);\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('spec show', () => {\n    it('should display spec in text format', async () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec show auth`, {\n          encoding: 'utf-8'\n        });\n        \n        // Raw passthrough should match spec.md content\n        const raw = await fs.readFile(path.join(specsDir, 'auth', 'spec.md'), 'utf-8');\n        expect(output.trim()).toBe(raw.trim());\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n\n    it('should output spec as JSON with --json flag', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec show auth --json`, {\n          encoding: 'utf-8'\n        });\n        \n        const json = JSON.parse(output);\n        expect(json.id).toBe('auth');\n        expect(json.title).toBe('auth');\n        expect(json.overview).toContain('test specification');\n        expect(json.requirements).toHaveLength(2);\n        expect(json.metadata.format).toBe('openspec');\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n\n    it('should filter to show only requirements with --requirements flag (JSON only)', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec show auth --json --requirements`, {\n          encoding: 'utf-8'\n        });\n        \n        const json = JSON.parse(output);\n        expect(json.requirements).toHaveLength(2);\n        // Scenarios should be excluded when --requirements is used\n        expect(json.requirements.every((r: any) => Array.isArray(r.scenarios) && r.scenarios.length === 0)).toBe(true);\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n\n    it('should exclude scenarios with --no-scenarios flag (JSON only)', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec show auth --json --no-scenarios`, {\n          encoding: 'utf-8'\n        });\n        \n        const json = JSON.parse(output);\n        expect(json.requirements).toHaveLength(2);\n        expect(json.requirements.every((r: any) => Array.isArray(r.scenarios) && r.scenarios.length === 0)).toBe(true);\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n\n    it('should show specific requirement with -r flag (JSON only)', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec show auth --json -r 1`, {\n          encoding: 'utf-8'\n        });\n        \n        const json = JSON.parse(output);\n        expect(json.requirements).toHaveLength(1);\n        expect(json.requirements[0].text).toContain('The system SHALL provide secure user authentication');\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n\n    it('should return JSON with filtered requirements', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec show auth --json --no-scenarios`, {\n          encoding: 'utf-8'\n        });\n        \n        const json = JSON.parse(output);\n        expect(json.requirements).toHaveLength(2);\n        expect(json.requirements[0].scenarios).toHaveLength(0);\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n  });\n\n  describe('spec list', () => {\n    it('should list all available specs (IDs only by default)', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec list`, {\n          encoding: 'utf-8'\n        });\n        \n        expect(output).toContain('auth');\n        expect(output).toContain('payment');\n        // Default should not include counts or teasers\n        expect(output).not.toMatch(/Requirements:\\s*\\d+/);\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n\n    it('should output spec list as JSON with --json flag', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec list --json`, {\n          encoding: 'utf-8'\n        });\n        \n        const json = JSON.parse(output);\n        expect(json).toHaveLength(2);\n        expect(json.find((s: any) => s.id === 'auth')).toBeDefined();\n        expect(json.find((s: any) => s.id === 'payment')).toBeDefined();\n        expect(json[0].requirementCount).toBeDefined();\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n  });\n\n  describe('spec validate', () => {\n    it('should validate a valid spec', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec validate auth`, {\n          encoding: 'utf-8'\n        });\n        \n        expect(output).toContain(\"Specification 'auth' is valid\");\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n\n    it('should output validation report as JSON with --json flag', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec validate auth --json`, {\n          encoding: 'utf-8'\n        });\n        \n        const json = JSON.parse(output);\n        expect(json.valid).toBeDefined();\n        expect(json.issues).toBeDefined();\n        expect(json.summary).toBeDefined();\n        expect(json.summary.errors).toBeDefined();\n        expect(json.summary.warnings).toBeDefined();\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n\n    it('should validate with strict mode', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec validate auth --strict --json`, {\n          encoding: 'utf-8'\n        });\n        \n        const json = JSON.parse(output);\n        expect(json.valid).toBeDefined();\n        // In strict mode, warnings also affect validity\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n\n    it('should detect invalid spec structure', async () => {\n      const invalidSpec = `## Purpose\n\n## Requirements\nThis section has no actual requirements`;\n\n      await fs.mkdir(path.join(specsDir, 'invalid'), { recursive: true });\n      await fs.writeFile(path.join(specsDir, 'invalid', 'spec.md'), invalidSpec);\n\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        \n        // This should exit with non-zero code\n        let exitCode = 0;\n        try {\n          execSync(`node ${openspecBin} spec validate invalid`, {\n            encoding: 'utf-8'\n          });\n        } catch (error: any) {\n          exitCode = error.status;\n        }\n        \n        expect(exitCode).not.toBe(0);\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n  });\n\n  describe('error handling', () => {\n    it('should handle non-existent spec gracefully', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        \n        let error: any;\n        try {\n          execSync(`node ${openspecBin} spec show nonexistent`, {\n            encoding: 'utf-8'\n          });\n        } catch (e) {\n          error = e;\n        }\n        \n        expect(error).toBeDefined();\n        expect(error.status).not.toBe(0);\n        expect(error.stderr.toString()).toContain('not found');\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n\n    it('should handle missing specs directory gracefully', async () => {\n      await fs.rm(specsDir, { recursive: true, force: true });\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} spec list`, { encoding: 'utf-8' });\n        expect(output.trim()).toBe('No items found');\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n\n    it('should honor --no-color (no ANSI escapes)', () => {\n      const originalCwd = process.cwd();\n      try {\n        process.chdir(testDir);\n        const output = execSync(`node ${openspecBin} --no-color spec list --long`, { encoding: 'utf-8' });\n        // Basic ANSI escape pattern\n        const hasAnsi = /\\u001b\\[[0-9;]*m/.test(output);\n        expect(hasAnsi).toBe(false);\n      } finally {\n        process.chdir(originalCwd);\n      }\n    });\n  });\n});"
  },
  {
    "path": "test/commands/validate.enriched-output.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { execSync } from 'child_process';\n\ndescribe('validate command enriched human output', () => {\n  const projectRoot = process.cwd();\n  const testDir = path.join(projectRoot, 'test-validate-enriched-tmp');\n  const changesDir = path.join(testDir, 'openspec', 'changes');\n  const bin = path.join(projectRoot, 'bin', 'openspec.js');\n\n\n  beforeEach(async () => {\n    await fs.mkdir(changesDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  it('prints Next steps footer and guidance on invalid change', async () => {\n    const changeContent = `# Test Change\\n\\n## Why\\nThis is a sufficiently long explanation to pass the why length requirement for validation purposes.\\n\\n## What Changes\\nThere are changes proposed, but no delta specs provided yet.`;\n    const changeId = 'c-next-steps';\n    const changePath = path.join(changesDir, changeId);\n    await fs.mkdir(changePath, { recursive: true });\n    await fs.writeFile(path.join(changePath, 'proposal.md'), changeContent);\n\n    const originalCwd = process.cwd();\n    try {\n      process.chdir(testDir);\n      let code = 0;\n      let stderr = '';\n      try {\n        execSync(`node ${bin} change validate ${changeId}`, { encoding: 'utf-8', stdio: 'pipe' });\n      } catch (e: any) {\n        code = e?.status ?? 1;\n        stderr = e?.stderr?.toString?.() ?? '';\n      }\n      expect(code).not.toBe(0);\n      expect(stderr).toContain('has issues');\n      expect(stderr).toContain('Next steps:');\n      expect(stderr).toContain('openspec change show');\n    } finally {\n      process.chdir(originalCwd);\n    }\n  });\n});\n\n\n"
  },
  {
    "path": "test/commands/validate.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { runCLI } from '../helpers/run-cli.js';\n\ndescribe('top-level validate command', () => {\n  const projectRoot = process.cwd();\n  const testDir = path.join(projectRoot, 'test-validate-command-tmp');\n  const changesDir = path.join(testDir, 'openspec', 'changes');\n  const specsDir = path.join(testDir, 'openspec', 'specs');\n\n  beforeEach(async () => {\n    await fs.mkdir(changesDir, { recursive: true });\n    await fs.mkdir(specsDir, { recursive: true });\n\n    // Create a valid spec\n    const specContent = [\n      '## Purpose',\n      'This spec ensures the validation harness exercises a deterministic alpha module for automated tests.',\n      '',\n      '## Requirements',\n      '',\n      '### Requirement: Alpha module SHALL produce deterministic output',\n      'The alpha module SHALL produce a deterministic response for validation.',\n      '',\n      '#### Scenario: Deterministic alpha run',\n      '- **GIVEN** a configured alpha module',\n      '- **WHEN** the module runs the default flow',\n      '- **THEN** the output matches the expected fixture result',\n    ].join('\\n');\n    await fs.mkdir(path.join(specsDir, 'alpha'), { recursive: true });\n    await fs.writeFile(path.join(specsDir, 'alpha', 'spec.md'), specContent, 'utf-8');\n\n    // Create a simple change with bullets (parser supports this)\n    const changeContent = `# Test Change\\n\\n## Why\\nBecause reasons that are sufficiently long for validation.\\n\\n## What Changes\\n- **alpha:** Add something`;\n    await fs.mkdir(path.join(changesDir, 'c1'), { recursive: true });\n    await fs.writeFile(path.join(changesDir, 'c1', 'proposal.md'), changeContent, 'utf-8');\n    const deltaContent = [\n      '## ADDED Requirements',\n      '### Requirement: Validator SHALL support alpha change deltas',\n      'The validator SHALL accept deltas provided by the test harness.',\n      '',\n      '#### Scenario: Apply alpha delta',\n      '- **GIVEN** the test change delta',\n      '- **WHEN** openspec validate runs',\n      '- **THEN** the validator reports the change as valid',\n    ].join('\\n');\n    const c1DeltaDir = path.join(changesDir, 'c1', 'specs', 'alpha');\n    await fs.mkdir(c1DeltaDir, { recursive: true });\n    await fs.writeFile(path.join(c1DeltaDir, 'spec.md'), deltaContent, 'utf-8');\n\n    // Duplicate name for ambiguity test\n    await fs.mkdir(path.join(changesDir, 'dup'), { recursive: true });\n    await fs.writeFile(path.join(changesDir, 'dup', 'proposal.md'), changeContent, 'utf-8');\n    const dupDeltaDir = path.join(changesDir, 'dup', 'specs', 'dup');\n    await fs.mkdir(dupDeltaDir, { recursive: true });\n    await fs.writeFile(path.join(dupDeltaDir, 'spec.md'), deltaContent, 'utf-8');\n    await fs.mkdir(path.join(specsDir, 'dup'), { recursive: true });\n    await fs.writeFile(path.join(specsDir, 'dup', 'spec.md'), specContent, 'utf-8');\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  it('prints a helpful hint when no args in non-interactive mode', async () => {\n    const result = await runCLI(['validate'], { cwd: testDir });\n    expect(result.exitCode).toBe(1);\n    expect(result.stderr).toContain('Nothing to validate. Try one of:');\n  });\n\n  it('validates all with --all and outputs JSON summary', async () => {\n    const result = await runCLI(['validate', '--all', '--json'], { cwd: testDir });\n    expect(result.exitCode).toBe(0);\n    const output = result.stdout.trim();\n    expect(output).not.toBe('');\n    const json = JSON.parse(output);\n    expect(Array.isArray(json.items)).toBe(true);\n    expect(json.summary?.totals?.items).toBeDefined();\n    expect(json.version).toBe('1.0');\n  });\n\n  it('validates only specs with --specs and respects --concurrency', async () => {\n    const result = await runCLI(['validate', '--specs', '--json', '--concurrency', '1'], { cwd: testDir });\n    expect(result.exitCode).toBe(0);\n    const output = result.stdout.trim();\n    expect(output).not.toBe('');\n    const json = JSON.parse(output);\n    expect(json.items.every((i: any) => i.type === 'spec')).toBe(true);\n  });\n\n  it('errors on ambiguous item names and suggests type override', async () => {\n    const result = await runCLI(['validate', 'dup'], { cwd: testDir });\n    expect(result.exitCode).toBe(1);\n    expect(result.stderr).toContain('Ambiguous item');\n  });\n\n  it('accepts change proposals saved with CRLF line endings', async () => {\n    const changeId = 'crlf-change';\n    const toCrlf = (segments: string[]) => segments.join('\\n').replace(/\\n/g, '\\r\\n');\n\n    const crlfContent = toCrlf([\n      '# CRLF Proposal',\n      '',\n      '## Why',\n      'This change verifies validation works with Windows line endings.',\n      '',\n      '## What Changes',\n      '- **alpha:** Ensure validation passes on CRLF files',\n    ]);\n\n    await fs.mkdir(path.join(changesDir, changeId), { recursive: true });\n    await fs.writeFile(path.join(changesDir, changeId, 'proposal.md'), crlfContent, 'utf-8');\n\n    const deltaContent = toCrlf([\n      '## ADDED Requirements',\n      '### Requirement: Parser SHALL accept CRLF change proposals',\n      'The parser SHALL accept CRLF change proposals without manual edits.',\n      '',\n      '#### Scenario: Validate CRLF change',\n      '- **GIVEN** a change proposal saved with CRLF line endings',\n      '- **WHEN** a developer runs openspec validate on the proposal',\n      '- **THEN** validation succeeds without section errors',\n    ]);\n\n    const deltaDir = path.join(changesDir, changeId, 'specs', 'alpha');\n    await fs.mkdir(deltaDir, { recursive: true });\n    await fs.writeFile(path.join(deltaDir, 'spec.md'), deltaContent, 'utf-8');\n\n    const result = await runCLI(['validate', changeId], { cwd: testDir });\n    expect(result.exitCode).toBe(0);\n  });\n\n  it('respects --no-interactive flag passed via CLI', async () => {\n    // This test ensures Commander.js --no-interactive flag is correctly parsed\n    // and passed to the validate command. The flag sets options.interactive = false\n    // (not options.noInteractive = true) due to Commander.js convention.\n    const result = await runCLI(['validate', '--specs', '--no-interactive'], {\n      cwd: testDir,\n      // Don't set OPEN_SPEC_INTERACTIVE to ensure we're testing the flag itself\n      env: { ...process.env, OPEN_SPEC_INTERACTIVE: undefined },\n    });\n    expect(result.exitCode).toBe(0);\n    // Should complete without hanging and without prompts\n    expect(result.stderr).not.toContain('What would you like to validate?');\n  });\n});\n"
  },
  {
    "path": "test/core/archive.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { ArchiveCommand } from '../../src/core/archive.js';\nimport { Validator } from '../../src/core/validation/validator.js';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\n\n// Mock @inquirer/prompts\nvi.mock('@inquirer/prompts', () => ({\n  select: vi.fn(),\n  confirm: vi.fn()\n}));\n\ndescribe('ArchiveCommand', () => {\n  let tempDir: string;\n  let archiveCommand: ArchiveCommand;\n  const originalConsoleLog = console.log;\n\n  beforeEach(async () => {\n    // Create temp directory\n    tempDir = path.join(os.tmpdir(), `openspec-archive-test-${Date.now()}`);\n    await fs.mkdir(tempDir, { recursive: true });\n    \n    // Change to temp directory\n    process.chdir(tempDir);\n    \n    // Create OpenSpec structure\n    const openspecDir = path.join(tempDir, 'openspec');\n    await fs.mkdir(path.join(openspecDir, 'changes'), { recursive: true });\n    await fs.mkdir(path.join(openspecDir, 'specs'), { recursive: true });\n    await fs.mkdir(path.join(openspecDir, 'changes', 'archive'), { recursive: true });\n    \n    // Suppress console.log during tests\n    console.log = vi.fn();\n    \n    archiveCommand = new ArchiveCommand();\n  });\n\n  afterEach(async () => {\n    // Restore console.log\n    console.log = originalConsoleLog;\n    \n    // Clear mocks\n    vi.clearAllMocks();\n    \n    // Clean up temp directory\n    try {\n      await fs.rm(tempDir, { recursive: true, force: true });\n    } catch (error) {\n      // Ignore cleanup errors\n    }\n  });\n\n  describe('execute', () => {\n    it('should archive a change successfully', async () => {\n      // Create a test change\n      const changeName = 'test-feature';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      await fs.mkdir(changeDir, { recursive: true });\n      \n      // Create tasks.md with completed tasks\n      const tasksContent = '- [x] Task 1\\n- [x] Task 2';\n      await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);\n      \n      // Execute archive with --yes flag\n      await archiveCommand.execute(changeName, { yes: true });\n      \n      // Check that change was moved to archive\n      const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');\n      const archives = await fs.readdir(archiveDir);\n      \n      expect(archives.length).toBe(1);\n      expect(archives[0]).toMatch(new RegExp(`\\\\d{4}-\\\\d{2}-\\\\d{2}-${changeName}`));\n      \n      // Verify original change directory no longer exists\n      await expect(fs.access(changeDir)).rejects.toThrow();\n    });\n\n    it('should warn about incomplete tasks', async () => {\n      const changeName = 'incomplete-feature';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      await fs.mkdir(changeDir, { recursive: true });\n      \n      // Create tasks.md with incomplete tasks\n      const tasksContent = '- [x] Task 1\\n- [ ] Task 2\\n- [ ] Task 3';\n      await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);\n      \n      // Execute archive with --yes flag\n      await archiveCommand.execute(changeName, { yes: true });\n      \n      // Verify warning was logged\n      expect(console.log).toHaveBeenCalledWith(\n        expect.stringContaining('Warning: 2 incomplete task(s) found')\n      );\n    });\n\n    it('should update specs when archiving (delta-based ADDED) and include change name in skeleton', async () => {\n      const changeName = 'spec-feature';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');\n      await fs.mkdir(changeSpecDir, { recursive: true });\n      \n      // Create delta-based change spec (ADDED requirement)\n      const specContent = `# Test Capability Spec - Changes\n\n## ADDED Requirements\n\n### Requirement: The system SHALL provide test capability\n\n#### Scenario: Basic test\nGiven a test condition\nWhen an action occurs\nThen expected result happens`;\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);\n      \n      // Execute archive with --yes flag and skip validation for speed\n      await archiveCommand.execute(changeName, { yes: true, noValidate: true });\n      \n      // Verify spec was created from skeleton and ADDED requirement applied\n      const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');\n      const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');\n      expect(updatedContent).toContain('# test-capability Specification');\n      expect(updatedContent).toContain('## Purpose');\n      expect(updatedContent).toContain(`created by archiving change ${changeName}`);\n      expect(updatedContent).toContain('## Requirements');\n      expect(updatedContent).toContain('### Requirement: The system SHALL provide test capability');\n      expect(updatedContent).toContain('#### Scenario: Basic test');\n    });\n\n    it('should allow REMOVED requirements when creating new spec file (issue #403)', async () => {\n      const changeName = 'new-spec-with-removed';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const changeSpecDir = path.join(changeDir, 'specs', 'gift-card');\n      await fs.mkdir(changeSpecDir, { recursive: true });\n      \n      // Create delta spec with both ADDED and REMOVED requirements\n      // This simulates refactoring where old fields are removed and new ones are added\n      const specContent = `# Gift Card - Changes\n\n## ADDED Requirements\n\n### Requirement: Logo and Background Color\nThe system SHALL support logo and backgroundColor fields for gift cards.\n\n#### Scenario: Display gift card with logo\n- **WHEN** a gift card is displayed\n- **THEN** it shows the logo and backgroundColor\n\n## REMOVED Requirements\n\n### Requirement: Image Field\n### Requirement: Thumbnail Field`;\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);\n      \n      // Execute archive - should succeed with warning about REMOVED requirements\n      await archiveCommand.execute(changeName, { yes: true, noValidate: true });\n      \n      // Verify warning was logged about REMOVED requirements being ignored\n      expect(console.log).toHaveBeenCalledWith(\n        expect.stringContaining('Warning: gift-card - 2 REMOVED requirement(s) ignored for new spec (nothing to remove).')\n      );\n      \n      // Verify spec was created with only ADDED requirements\n      const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'gift-card', 'spec.md');\n      const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');\n      expect(updatedContent).toContain('# gift-card Specification');\n      expect(updatedContent).toContain('### Requirement: Logo and Background Color');\n      expect(updatedContent).toContain('#### Scenario: Display gift card with logo');\n      // REMOVED requirements should not be in the final spec\n      expect(updatedContent).not.toContain('### Requirement: Image Field');\n      expect(updatedContent).not.toContain('### Requirement: Thumbnail Field');\n      \n      // Verify change was archived successfully\n      const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');\n      const archives = await fs.readdir(archiveDir);\n      expect(archives.length).toBeGreaterThan(0);\n      expect(archives.some(a => a.includes(changeName))).toBe(true);\n    });\n\n    it('should still error on MODIFIED when creating new spec file', async () => {\n      const changeName = 'new-spec-with-modified';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const changeSpecDir = path.join(changeDir, 'specs', 'new-capability');\n      await fs.mkdir(changeSpecDir, { recursive: true });\n      \n      // Create delta spec with MODIFIED requirement (should fail for new spec)\n      const specContent = `# New Capability - Changes\n\n## ADDED Requirements\n\n### Requirement: New Feature\nNew feature description.\n\n## MODIFIED Requirements\n\n### Requirement: Existing Feature\nModified content.`;\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);\n      \n      // Execute archive - should abort with error message (not throw, but log and return)\n      await archiveCommand.execute(changeName, { yes: true, noValidate: true });\n      \n      // Verify error message mentions MODIFIED not allowed for new specs\n      expect(console.log).toHaveBeenCalledWith(\n        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.')\n      );\n      expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');\n      \n      // Verify spec was NOT created\n      const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md');\n      await expect(fs.access(mainSpecPath)).rejects.toThrow();\n      \n      // Verify change was NOT archived\n      const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');\n      const archives = await fs.readdir(archiveDir);\n      expect(archives.some(a => a.includes(changeName))).toBe(false);\n    });\n\n    it('should still error on RENAMED when creating new spec file', async () => {\n      const changeName = 'new-spec-with-renamed';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const changeSpecDir = path.join(changeDir, 'specs', 'another-capability');\n      await fs.mkdir(changeSpecDir, { recursive: true });\n      \n      // Create delta spec with RENAMED requirement (should fail for new spec)\n      const specContent = `# Another Capability - Changes\n\n## ADDED Requirements\n\n### Requirement: New Feature\nNew feature description.\n\n## RENAMED Requirements\n- FROM: \\`### Requirement: Old Name\\`\n- TO: \\`### Requirement: New Name\\``;\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);\n      \n      // Execute archive - should abort with error message (not throw, but log and return)\n      await archiveCommand.execute(changeName, { yes: true, noValidate: true });\n      \n      // Verify error message mentions RENAMED not allowed for new specs\n      expect(console.log).toHaveBeenCalledWith(\n        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.')\n      );\n      expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.');\n      \n      // Verify spec was NOT created\n      const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'another-capability', 'spec.md');\n      await expect(fs.access(mainSpecPath)).rejects.toThrow();\n      \n      // Verify change was NOT archived\n      const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');\n      const archives = await fs.readdir(archiveDir);\n      expect(archives.some(a => a.includes(changeName))).toBe(false);\n    });\n\n    it('should throw error if change does not exist', async () => {\n      await expect(\n        archiveCommand.execute('non-existent-change', { yes: true })\n      ).rejects.toThrow(\"Change 'non-existent-change' not found.\");\n    });\n\n    it('should throw error if archive already exists', async () => {\n      const changeName = 'duplicate-feature';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      await fs.mkdir(changeDir, { recursive: true });\n      \n      // Create existing archive with same date\n      const date = new Date().toISOString().split('T')[0];\n      const archivePath = path.join(tempDir, 'openspec', 'changes', 'archive', `${date}-${changeName}`);\n      await fs.mkdir(archivePath, { recursive: true });\n      \n      // Try to archive\n      await expect(\n        archiveCommand.execute(changeName, { yes: true })\n      ).rejects.toThrow(`Archive '${date}-${changeName}' already exists.`);\n    });\n\n    it('should handle changes without tasks.md', async () => {\n      const changeName = 'no-tasks-feature';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      await fs.mkdir(changeDir, { recursive: true });\n      \n      // Execute archive without tasks.md\n      await archiveCommand.execute(changeName, { yes: true });\n      \n      // Should complete without warnings\n      expect(console.log).not.toHaveBeenCalledWith(\n        expect.stringContaining('incomplete task(s)')\n      );\n      \n      // Verify change was archived\n      const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');\n      const archives = await fs.readdir(archiveDir);\n      expect(archives.length).toBe(1);\n    });\n\n    it('should handle changes without specs', async () => {\n      const changeName = 'no-specs-feature';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      await fs.mkdir(changeDir, { recursive: true });\n      \n      // Execute archive without specs\n      await archiveCommand.execute(changeName, { yes: true });\n      \n      // Should complete without spec updates\n      expect(console.log).not.toHaveBeenCalledWith(\n        expect.stringContaining('Specs to update')\n      );\n      \n      // Verify change was archived\n      const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');\n      const archives = await fs.readdir(archiveDir);\n      expect(archives.length).toBe(1);\n    });\n\n    it('should skip spec updates when --skip-specs flag is used', async () => {\n      const changeName = 'skip-specs-feature';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');\n      await fs.mkdir(changeSpecDir, { recursive: true });\n      \n      // Create spec in change\n      const specContent = '# Test Capability Spec\\n\\nTest content';\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);\n      \n      // Execute archive with --skip-specs flag and noValidate to skip validation\n      await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, noValidate: true });\n      \n      // Verify skip message was logged\n      expect(console.log).toHaveBeenCalledWith(\n        'Skipping spec updates (--skip-specs flag provided).'\n      );\n      \n      // Verify spec was NOT copied to main specs\n      const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');\n      await expect(fs.access(mainSpecPath)).rejects.toThrow();\n      \n      // Verify change was still archived\n      const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');\n      const archives = await fs.readdir(archiveDir);\n      expect(archives.length).toBe(1);\n      expect(archives[0]).toMatch(new RegExp(`\\\\d{4}-\\\\d{2}-\\\\d{2}-${changeName}`));\n    });\n\n    it('should skip validation when commander sets validate to false (--no-validate)', async () => {\n      const changeName = 'skip-validation-flag';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const changeSpecDir = path.join(changeDir, 'specs', 'unstable-capability');\n      await fs.mkdir(changeSpecDir, { recursive: true });\n\n      const deltaSpec = `# Unstable Capability\n\n## ADDED Requirements\n\n### Requirement: Logging Feature\n**ID**: REQ-LOG-001\n\nThe system will log all events.\n\n#### Scenario: Event recorded\n- **WHEN** an event occurs\n- **THEN** it is captured`;\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaSpec);\n      await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\\n');\n\n      const deltaSpy = vi.spyOn(Validator.prototype, 'validateChangeDeltaSpecs');\n      const specContentSpy = vi.spyOn(Validator.prototype, 'validateSpecContent');\n\n      try {\n        await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, validate: false });\n\n        expect(deltaSpy).not.toHaveBeenCalled();\n        expect(specContentSpy).not.toHaveBeenCalled();\n\n        const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');\n        const archives = await fs.readdir(archiveDir);\n        expect(archives.length).toBe(1);\n        expect(archives[0]).toMatch(new RegExp(`\\\\d{4}-\\\\d{2}-\\\\d{2}-${changeName}`));\n      } finally {\n        deltaSpy.mockRestore();\n        specContentSpy.mockRestore();\n      }\n    });\n\n    it('should proceed with archive when user declines spec updates', async () => {\n      const { confirm } = await import('@inquirer/prompts');\n      const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;\n      \n      const changeName = 'decline-specs-feature';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');\n      await fs.mkdir(changeSpecDir, { recursive: true });\n      \n      // Create valid spec in change\n      const specContent = `# Test Capability Spec\n\n## Purpose\nThis is a test capability specification.\n\n## Requirements\n\n### The system SHALL provide test capability\n\n#### Scenario: Basic test\nGiven a test condition\nWhen an action occurs\nThen expected result happens`;\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);\n      \n      // Mock confirm to return false (decline spec updates)\n      mockConfirm.mockResolvedValueOnce(false);\n      \n      // Execute archive without --yes flag\n      await archiveCommand.execute(changeName);\n      \n      // Verify user was prompted about specs\n      expect(mockConfirm).toHaveBeenCalledWith({\n        message: 'Proceed with spec updates?',\n        default: true\n      });\n      \n      // Verify skip message was logged\n      expect(console.log).toHaveBeenCalledWith(\n        'Skipping spec updates. Proceeding with archive.'\n      );\n      \n      // Verify spec was NOT copied to main specs\n      const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');\n      await expect(fs.access(mainSpecPath)).rejects.toThrow();\n      \n      // Verify change was still archived\n      const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');\n      const archives = await fs.readdir(archiveDir);\n      expect(archives.length).toBe(1);\n      expect(archives[0]).toMatch(new RegExp(`\\\\d{4}-\\\\d{2}-\\\\d{2}-${changeName}`));\n    });\n\n    it('should support header trim-only normalization for matching', async () => {\n      const changeName = 'normalize-headers';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const changeSpecDir = path.join(changeDir, 'specs', 'alpha');\n      await fs.mkdir(changeSpecDir, { recursive: true });\n\n      // Create existing main spec with a requirement (no extra trailing spaces)\n      const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'alpha');\n      await fs.mkdir(mainSpecDir, { recursive: true });\n      const mainContent = `# alpha Specification\n\n## Purpose\nAlpha purpose.\n\n## Requirements\n\n### Requirement: Important Rule\nSome details.`;\n      await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);\n\n      // Change attempts to modify the same requirement but with trailing spaces after the name\n      const deltaContent = `# Alpha - Changes\n\n## MODIFIED Requirements\n\n### Requirement: Important Rule   \nUpdated details.`;\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);\n\n      await archiveCommand.execute(changeName, { yes: true, noValidate: true });\n\n      const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');\n      expect(updated).toContain('### Requirement: Important Rule');\n      expect(updated).toContain('Updated details.');\n    });\n\n    it('should apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED', async () => {\n      const changeName = 'apply-order';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const changeSpecDir = path.join(changeDir, 'specs', 'beta');\n      await fs.mkdir(changeSpecDir, { recursive: true });\n\n      // Main spec with two requirements A and B\n      const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'beta');\n      await fs.mkdir(mainSpecDir, { recursive: true });\n      const mainContent = `# beta Specification\n\n## Purpose\nBeta purpose.\n\n## Requirements\n\n### Requirement: A\ncontent A\n\n### Requirement: B\ncontent B`;\n      await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);\n\n      // Rename A->C, Remove B, Modify C, Add D\n      const deltaContent = `# Beta - Changes\n\n## RENAMED Requirements\n- FROM: \\`### Requirement: A\\`\n- TO: \\`### Requirement: C\\`\n\n## REMOVED Requirements\n### Requirement: B\n\n## MODIFIED Requirements\n### Requirement: C\nupdated C\n\n## ADDED Requirements\n### Requirement: D\ncontent D`;\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);\n\n      await archiveCommand.execute(changeName, { yes: true, noValidate: true });\n\n      const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');\n      expect(updated).toContain('### Requirement: C');\n      expect(updated).toContain('updated C');\n      expect(updated).toContain('### Requirement: D');\n      expect(updated).not.toContain('### Requirement: A');\n      expect(updated).not.toContain('### Requirement: B');\n    });\n\n    it('should abort with error when MODIFIED/REMOVED reference non-existent requirements', async () => {\n      const changeName = 'validate-missing';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const changeSpecDir = path.join(changeDir, 'specs', 'gamma');\n      await fs.mkdir(changeSpecDir, { recursive: true });\n\n      // Main spec with no requirements\n      const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'gamma');\n      await fs.mkdir(mainSpecDir, { recursive: true });\n      const mainContent = `# gamma Specification\n\n## Purpose\nGamma purpose.\n\n## Requirements`;\n      await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);\n\n      // Delta tries to modify and remove non-existent requirement\n      const deltaContent = `# Gamma - Changes\n\n## MODIFIED Requirements\n### Requirement: Missing\nnew text\n\n## REMOVED Requirements\n### Requirement: Another Missing`;\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);\n\n      await archiveCommand.execute(changeName, { yes: true, noValidate: true });\n\n      // Should not change the main spec and should not archive the change dir\n      const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');\n      expect(still).toBe(mainContent);\n      // Change dir should still exist since operation aborted\n      await expect(fs.access(changeDir)).resolves.not.toThrow();\n    });\n\n    it('should require MODIFIED to reference the NEW header when a rename exists (error format)', async () => {\n      const changeName = 'rename-modify-new-header';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const changeSpecDir = path.join(changeDir, 'specs', 'delta');\n      await fs.mkdir(changeSpecDir, { recursive: true });\n\n      // Main spec with Old\n      const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'delta');\n      await fs.mkdir(mainSpecDir, { recursive: true });\n      const mainContent = `# delta Specification\n\n## Purpose\nDelta purpose.\n\n## Requirements\n\n### Requirement: Old\nold body`;\n      await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);\n\n      // Delta: rename Old->New, but MODIFIED references Old (should abort)\n      const badDelta = `# Delta - Changes\n\n## RENAMED Requirements\n- FROM: \\`### Requirement: Old\\`\n- TO: \\`### Requirement: New\\`\n\n## MODIFIED Requirements\n### Requirement: Old\nnew body`;\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), badDelta);\n\n      await archiveCommand.execute(changeName, { yes: true, noValidate: true });\n      const unchanged = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');\n      expect(unchanged).toBe(mainContent);\n      // Assert error message format and abort notice\n      expect(console.log).toHaveBeenCalledWith(\n        expect.stringContaining('delta validation failed')\n      );\n      expect(console.log).toHaveBeenCalledWith(\n        expect.stringContaining('Aborted. No files were changed.')\n      );\n\n      // Fix MODIFIED to reference New (should succeed)\n      const goodDelta = `# Delta - Changes\n\n## RENAMED Requirements\n- FROM: \\`### Requirement: Old\\`\n- TO: \\`### Requirement: New\\`\n\n## MODIFIED Requirements\n### Requirement: New\nnew body`;\n      await fs.writeFile(path.join(changeSpecDir, 'spec.md'), goodDelta);\n\n      await archiveCommand.execute(changeName, { yes: true, noValidate: true });\n      const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');\n      expect(updated).toContain('### Requirement: New');\n      expect(updated).toContain('new body');\n      expect(updated).not.toContain('### Requirement: Old');\n    });\n\n    it('should process multiple specs atomically (any failure aborts all)', async () => {\n      const changeName = 'multi-spec-atomic';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const spec1Dir = path.join(changeDir, 'specs', 'epsilon');\n      const spec2Dir = path.join(changeDir, 'specs', 'zeta');\n      await fs.mkdir(spec1Dir, { recursive: true });\n      await fs.mkdir(spec2Dir, { recursive: true });\n\n      // Existing main specs\n      const epsilonMain = path.join(tempDir, 'openspec', 'specs', 'epsilon', 'spec.md');\n      await fs.mkdir(path.dirname(epsilonMain), { recursive: true });\n      await fs.writeFile(epsilonMain, `# epsilon Specification\n\n## Purpose\nEpsilon purpose.\n\n## Requirements\n\n### Requirement: E1\ne1`);\n\n      const zetaMain = path.join(tempDir, 'openspec', 'specs', 'zeta', 'spec.md');\n      await fs.mkdir(path.dirname(zetaMain), { recursive: true });\n      await fs.writeFile(zetaMain, `# zeta Specification\n\n## Purpose\nZeta purpose.\n\n## Requirements\n\n### Requirement: Z1\nz1`);\n\n      // Delta: epsilon is valid modification; zeta tries to remove non-existent -> should abort both\n      await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Epsilon - Changes\n\n## MODIFIED Requirements\n### Requirement: E1\nE1 updated`);\n\n      await fs.writeFile(path.join(spec2Dir, 'spec.md'), `# Zeta - Changes\n\n## REMOVED Requirements\n### Requirement: Missing`);\n\n      await archiveCommand.execute(changeName, { yes: true, noValidate: true });\n\n      const e1 = await fs.readFile(epsilonMain, 'utf-8');\n      const z1 = await fs.readFile(zetaMain, 'utf-8');\n      expect(e1).toContain('### Requirement: E1');\n      expect(e1).not.toContain('E1 updated');\n      expect(z1).toContain('### Requirement: Z1');\n      // changeDir should still exist\n      await expect(fs.access(changeDir)).resolves.not.toThrow();\n    });\n\n    it('should display aggregated totals across multiple specs', async () => {\n      const changeName = 'multi-spec-totals';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      const spec1Dir = path.join(changeDir, 'specs', 'omega');\n      const spec2Dir = path.join(changeDir, 'specs', 'psi');\n      await fs.mkdir(spec1Dir, { recursive: true });\n      await fs.mkdir(spec2Dir, { recursive: true });\n\n      // Existing main specs\n      const omegaMain = path.join(tempDir, 'openspec', 'specs', 'omega', 'spec.md');\n      await fs.mkdir(path.dirname(omegaMain), { recursive: true });\n      await fs.writeFile(omegaMain, `# omega Specification\\n\\n## Purpose\\nOmega purpose.\\n\\n## Requirements\\n\\n### Requirement: O1\\no1`);\n\n      const psiMain = path.join(tempDir, 'openspec', 'specs', 'psi', 'spec.md');\n      await fs.mkdir(path.dirname(psiMain), { recursive: true });\n      await fs.writeFile(psiMain, `# psi Specification\\n\\n## Purpose\\nPsi purpose.\\n\\n## Requirements\\n\\n### Requirement: P1\\np1`);\n\n      // Deltas: omega add one, psi rename and modify -> totals: +1, ~1, -0, →1\n      await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Omega - Changes\\n\\n## ADDED Requirements\\n\\n### Requirement: O2\\nnew`);\n      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`);\n\n      await archiveCommand.execute(changeName, { yes: true, noValidate: true });\n\n      // Verify aggregated totals line was printed\n      expect(console.log).toHaveBeenCalledWith(\n        expect.stringContaining('Totals: + 1, ~ 1, - 0, → 1')\n      );\n    });\n  });\n\n  describe('error handling', () => {\n    it('should throw error when openspec directory does not exist', async () => {\n      // Remove openspec directory\n      await fs.rm(path.join(tempDir, 'openspec'), { recursive: true });\n      \n      await expect(\n        archiveCommand.execute('any-change', { yes: true })\n      ).rejects.toThrow(\"No OpenSpec changes directory found. Run 'openspec init' first.\");\n    });\n  });\n\n  describe('interactive mode', () => {\n    it('should use select prompt for change selection', async () => {\n      const { select } = await import('@inquirer/prompts');\n      const mockSelect = select as unknown as ReturnType<typeof vi.fn>;\n      \n      // Create test changes\n      const change1 = 'feature-a';\n      const change2 = 'feature-b';\n      await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change1), { recursive: true });\n      await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change2), { recursive: true });\n      \n      // Mock select to return first change\n      mockSelect.mockResolvedValueOnce(change1);\n      \n      // Execute without change name\n      await archiveCommand.execute(undefined, { yes: true });\n      \n      // Verify select was called with correct options (values matter, names may include progress)\n      expect(mockSelect).toHaveBeenCalledWith(expect.objectContaining({\n        message: 'Select a change to archive',\n        choices: expect.arrayContaining([\n          expect.objectContaining({ value: change1 }),\n          expect.objectContaining({ value: change2 })\n        ])\n      }));\n      \n      // Verify the selected change was archived\n      const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');\n      const archives = await fs.readdir(archiveDir);\n      expect(archives[0]).toContain(change1);\n    });\n\n    it('should use confirm prompt for task warnings', async () => {\n      const { confirm } = await import('@inquirer/prompts');\n      const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;\n      \n      const changeName = 'incomplete-interactive';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      await fs.mkdir(changeDir, { recursive: true });\n      \n      // Create tasks.md with incomplete tasks\n      const tasksContent = '- [ ] Task 1';\n      await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);\n      \n      // Mock confirm to return true (proceed)\n      mockConfirm.mockResolvedValueOnce(true);\n      \n      // Execute without --yes flag\n      await archiveCommand.execute(changeName);\n      \n      // Verify confirm was called\n      expect(mockConfirm).toHaveBeenCalledWith({\n        message: 'Warning: 1 incomplete task(s) found. Continue?',\n        default: false\n      });\n    });\n\n    it('should cancel when user declines task warning', async () => {\n      const { confirm } = await import('@inquirer/prompts');\n      const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;\n      \n      const changeName = 'cancel-test';\n      const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);\n      await fs.mkdir(changeDir, { recursive: true });\n      \n      // Create tasks.md with incomplete tasks\n      const tasksContent = '- [ ] Task 1';\n      await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);\n      \n      // Mock confirm to return false (cancel) for validation skip\n      mockConfirm.mockResolvedValueOnce(false);\n      // Mock another false for task warning\n      mockConfirm.mockResolvedValueOnce(false);\n      \n      // Execute without --yes flag but skip validation to test task warning\n      await archiveCommand.execute(changeName, { noValidate: true });\n      \n      // Verify archive was cancelled\n      expect(console.log).toHaveBeenCalledWith('Archive cancelled.');\n      \n      // Verify change was not archived\n      await expect(fs.access(changeDir)).resolves.not.toThrow();\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/artifact-graph/graph.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';\nimport type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';\n\ndescribe('artifact-graph/graph', () => {\n  const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({\n    name: 'test',\n    version: 1,\n    artifacts,\n  });\n\n  describe('fromSchema', () => {\n    it('should create graph from schema object', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n      ]);\n\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      expect(graph.getName()).toBe('test');\n      expect(graph.getVersion()).toBe(1);\n    });\n  });\n\n  describe('fromYamlContent', () => {\n    it('should create graph from YAML string', () => {\n      const yaml = `\nname: my-workflow\nversion: 2\nartifacts:\n  - id: doc\n    generates: doc.md\n    description: Documentation\n    template: templates/doc.md\n`;\n      const graph = ArtifactGraph.fromYamlContent(yaml);\n\n      expect(graph.getName()).toBe('my-workflow');\n      expect(graph.getVersion()).toBe(2);\n      expect(graph.getArtifact('doc')).toBeDefined();\n    });\n  });\n\n  describe('getArtifact', () => {\n    it('should return artifact by ID', () => {\n      const schema = createSchema([\n        { id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const artifact = graph.getArtifact('proposal');\n\n      expect(artifact).toBeDefined();\n      expect(artifact?.id).toBe('proposal');\n      expect(artifact?.generates).toBe('proposal.md');\n    });\n\n    it('should return undefined for non-existent ID', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      expect(graph.getArtifact('nonexistent')).toBeUndefined();\n    });\n  });\n\n  describe('getAllArtifacts', () => {\n    it('should return all artifacts', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },\n        { id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const artifacts = graph.getAllArtifacts();\n\n      expect(artifacts).toHaveLength(3);\n      expect(artifacts.map(a => a.id).sort()).toEqual(['A', 'B', 'C']);\n    });\n  });\n\n  describe('getBuildOrder', () => {\n    it('should return correct order for linear chain A → B → C', () => {\n      const schema = createSchema([\n        { id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['B'] },\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const order = graph.getBuildOrder();\n\n      expect(order).toEqual(['A', 'B', 'C']);\n    });\n\n    it('should handle diamond dependency correctly', () => {\n      // A → B, A → C, B → D, C → D\n      const schema = createSchema([\n        { id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },\n        { id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const order = graph.getBuildOrder();\n\n      // A must come before B and C; D must come last\n      expect(order.indexOf('A')).toBeLessThan(order.indexOf('B'));\n      expect(order.indexOf('A')).toBeLessThan(order.indexOf('C'));\n      expect(order.indexOf('B')).toBeLessThan(order.indexOf('D'));\n      expect(order.indexOf('C')).toBeLessThan(order.indexOf('D'));\n    });\n\n    it('should return independent artifacts in stable sorted order', () => {\n      const schema = createSchema([\n        { id: 'Z', generates: 'z.md', description: 'Z', template: 't.md', requires: [] },\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'M', generates: 'm.md', description: 'M', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const order = graph.getBuildOrder();\n\n      // All independent, should be sorted alphabetically for stability\n      expect(order).toEqual(['A', 'M', 'Z']);\n    });\n  });\n\n  describe('getNextArtifacts', () => {\n    it('should return root artifacts when nothing completed', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },\n        { id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const ready = graph.getNextArtifacts(new Set());\n\n      expect(ready.sort()).toEqual(['A', 'C']);\n    });\n\n    it('should include artifact when all deps completed', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const ready = graph.getNextArtifacts(new Set(['A']));\n\n      expect(ready).toEqual(['B']);\n    });\n\n    it('should not include completed artifacts', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const ready = graph.getNextArtifacts(new Set(['A', 'B']));\n\n      expect(ready).toEqual([]);\n    });\n\n    it('should handle diamond dependency correctly', () => {\n      // D requires B and C\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },\n        { id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A'] },\n        { id: 'D', generates: 'd.md', description: 'D', template: 't.md', requires: ['B', 'C'] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Only A completed - B and C ready, D not\n      expect(graph.getNextArtifacts(new Set(['A'])).sort()).toEqual(['B', 'C']);\n\n      // Only B completed (from deps) - C still needed for D\n      expect(graph.getNextArtifacts(new Set(['A', 'B']))).toEqual(['C']);\n\n      // Both B and C completed - D ready\n      expect(graph.getNextArtifacts(new Set(['A', 'B', 'C']))).toEqual(['D']);\n    });\n  });\n\n  describe('isComplete', () => {\n    it('should return true when all artifacts completed', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      expect(graph.isComplete(new Set(['A', 'B']))).toBe(true);\n    });\n\n    it('should return false when some artifacts incomplete', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      expect(graph.isComplete(new Set(['A']))).toBe(false);\n      expect(graph.isComplete(new Set())).toBe(false);\n    });\n  });\n\n  describe('getBlocked', () => {\n    it('should return empty object when nothing is blocked', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      expect(graph.getBlocked(new Set())).toEqual({});\n    });\n\n    it('should return artifact blocked by single dependency', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      expect(graph.getBlocked(new Set())).toEqual({ B: ['A'] });\n    });\n\n    it('should return artifact blocked by multiple dependencies', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },\n        { id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Neither A nor B completed\n      expect(graph.getBlocked(new Set())).toEqual({ C: ['A', 'B'] });\n    });\n\n    it('should only list unmet dependencies', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: [] },\n        { id: 'C', generates: 'c.md', description: 'C', template: 't.md', requires: ['A', 'B'] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // A completed, B not\n      expect(graph.getBlocked(new Set(['A']))).toEqual({ C: ['B'] });\n    });\n\n    it('should not include completed artifacts', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n        { id: 'B', generates: 'b.md', description: 'B', template: 't.md', requires: ['A'] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      expect(graph.getBlocked(new Set(['A', 'B']))).toEqual({});\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/artifact-graph/instruction-loader.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport {\n  loadTemplate,\n  loadChangeContext,\n  generateInstructions,\n  formatChangeStatus,\n  TemplateLoadError,\n} from '../../../src/core/artifact-graph/instruction-loader.js';\n\ndescribe('instruction-loader', () => {\n  describe('loadTemplate', () => {\n    it('should load template from schema directory', () => {\n      // Uses built-in spec-driven schema\n      const template = loadTemplate('spec-driven', 'proposal.md');\n\n      expect(template).toContain('## Why');\n      expect(template).toContain('## What Changes');\n    });\n\n    it('should throw TemplateLoadError for non-existent template', () => {\n      expect(() => loadTemplate('spec-driven', 'nonexistent.md')).toThrow(\n        TemplateLoadError\n      );\n    });\n\n    it('should throw TemplateLoadError for non-existent schema', () => {\n      expect(() => loadTemplate('nonexistent-schema', 'proposal.md')).toThrow(\n        TemplateLoadError\n      );\n    });\n\n    it('should include template path in error', () => {\n      try {\n        loadTemplate('spec-driven', 'nonexistent.md');\n        expect.fail('Should have thrown');\n      } catch (err) {\n        expect(err).toBeInstanceOf(TemplateLoadError);\n        expect((err as TemplateLoadError).templatePath).toContain('nonexistent.md');\n      }\n    });\n  });\n\n  describe('loadChangeContext', () => {\n    let tempDir: string;\n\n    beforeEach(() => {\n      tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));\n    });\n\n    afterEach(() => {\n      fs.rmSync(tempDir, { recursive: true, force: true });\n    });\n\n    it('should load context with default schema', () => {\n      const context = loadChangeContext(tempDir, 'my-change');\n\n      expect(context.schemaName).toBe('spec-driven');\n      expect(context.changeName).toBe('my-change');\n      expect(context.graph.getName()).toBe('spec-driven');\n      expect(context.completed.size).toBe(0);\n    });\n\n    it('should load context with explicit schema', () => {\n      const context = loadChangeContext(tempDir, 'my-change', 'spec-driven');\n\n      expect(context.schemaName).toBe('spec-driven');\n      expect(context.graph.getName()).toBe('spec-driven');\n    });\n\n    it('should detect completed artifacts', () => {\n      // Create change directory with proposal.md\n      const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');\n      fs.mkdirSync(changeDir, { recursive: true });\n      fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');\n\n      const context = loadChangeContext(tempDir, 'my-change');\n\n      expect(context.completed.has('proposal')).toBe(true);\n    });\n\n    it('should return empty completed set for non-existent change directory', () => {\n      const context = loadChangeContext(tempDir, 'nonexistent-change');\n\n      expect(context.completed.size).toBe(0);\n    });\n\n    it('should auto-detect schema from .openspec.yaml metadata', () => {\n      // Create change directory with metadata file\n      const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');\n      fs.mkdirSync(changeDir, { recursive: true });\n      fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\\ncreated: \"2025-01-05\"\\n');\n\n      // Load without explicit schema - should detect from metadata\n      const context = loadChangeContext(tempDir, 'my-change');\n\n      expect(context.schemaName).toBe('spec-driven');\n      expect(context.graph.getName()).toBe('spec-driven');\n    });\n\n    it('should use explicit schema over metadata schema', () => {\n      // Create change directory with metadata file using spec-driven\n      const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');\n      fs.mkdirSync(changeDir, { recursive: true });\n      fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\\n');\n\n      // Load with explicit schema - should override metadata\n      const context = loadChangeContext(tempDir, 'my-change', 'spec-driven');\n\n      expect(context.schemaName).toBe('spec-driven');\n      expect(context.graph.getName()).toBe('spec-driven');\n    });\n\n    it('should fall back to default when no metadata and no explicit schema', () => {\n      // Create change directory without metadata file\n      const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');\n      fs.mkdirSync(changeDir, { recursive: true });\n\n      const context = loadChangeContext(tempDir, 'my-change');\n\n      expect(context.schemaName).toBe('spec-driven');\n    });\n  });\n\n  describe('generateInstructions', () => {\n    let tempDir: string;\n\n    beforeEach(() => {\n      tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));\n    });\n\n    afterEach(() => {\n      fs.rmSync(tempDir, { recursive: true, force: true });\n    });\n\n    it('should include artifact metadata', () => {\n      const context = loadChangeContext(tempDir, 'my-change');\n      const instructions = generateInstructions(context, 'proposal');\n\n      expect(instructions.changeName).toBe('my-change');\n      expect(instructions.artifactId).toBe('proposal');\n      expect(instructions.schemaName).toBe('spec-driven');\n      expect(instructions.outputPath).toBe('proposal.md');\n    });\n\n    it('should include template content', () => {\n      const context = loadChangeContext(tempDir, 'my-change');\n      const instructions = generateInstructions(context, 'proposal');\n\n      expect(instructions.template).toContain('## Why');\n    });\n\n    it('should show dependencies with completion status', () => {\n      const context = loadChangeContext(tempDir, 'my-change');\n      const instructions = generateInstructions(context, 'specs');\n\n      expect(instructions.dependencies).toHaveLength(1);\n      expect(instructions.dependencies[0].id).toBe('proposal');\n      expect(instructions.dependencies[0].done).toBe(false);\n    });\n\n    it('should mark completed dependencies as done', () => {\n      // Create proposal\n      const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');\n      fs.mkdirSync(changeDir, { recursive: true });\n      fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');\n\n      const context = loadChangeContext(tempDir, 'my-change');\n      const instructions = generateInstructions(context, 'specs');\n\n      expect(instructions.dependencies[0].done).toBe(true);\n    });\n\n    it('should list artifacts unlocked by this one', () => {\n      const context = loadChangeContext(tempDir, 'my-change');\n      const instructions = generateInstructions(context, 'proposal');\n\n      // proposal unlocks specs and design\n      expect(instructions.unlocks).toContain('specs');\n      expect(instructions.unlocks).toContain('design');\n    });\n\n    it('should have empty dependencies for root artifact', () => {\n      const context = loadChangeContext(tempDir, 'my-change');\n      const instructions = generateInstructions(context, 'proposal');\n\n      expect(instructions.dependencies).toHaveLength(0);\n    });\n\n    it('should throw for non-existent artifact', () => {\n      const context = loadChangeContext(tempDir, 'my-change');\n\n      expect(() => generateInstructions(context, 'nonexistent')).toThrow(\n        \"Artifact 'nonexistent' not found\"\n      );\n    });\n\n    describe('project config integration', () => {\n      it('should return context as separate field for all artifacts', () => {\n        // Create project config\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: |\n  Tech stack: TypeScript, React\n  API style: RESTful\n`\n        );\n\n        const context = loadChangeContext(tempDir, 'my-change');\n        const instructions = generateInstructions(context, 'proposal', tempDir);\n\n        // Context should be in separate field, not in template\n        expect(instructions.context).toContain('Tech stack: TypeScript, React');\n        expect(instructions.context).toContain('API style: RESTful');\n        expect(instructions.template).not.toContain('Tech stack');\n        expect(instructions.template).toContain('## Why'); // Actual template content\n      });\n\n      it('should return undefined context when config is absent', () => {\n        const context = loadChangeContext(tempDir, 'my-change');\n        const instructions = generateInstructions(context, 'proposal', tempDir);\n\n        expect(instructions.context).toBeUndefined();\n        expect(instructions.rules).toBeUndefined();\n        expect(instructions.template).toContain('## Why'); // Actual template content\n      });\n\n      it('should preserve multi-line context', () => {\n        // Create project config with multi-line context\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: |\n  Line 1\n  Line 2\n  Line 3\n`\n        );\n\n        const context = loadChangeContext(tempDir, 'my-change');\n        const instructions = generateInstructions(context, 'proposal', tempDir);\n\n        expect(instructions.context).toContain('Line 1\\nLine 2\\nLine 3');\n      });\n\n      it('should preserve special characters in context', () => {\n        // Create project config with special characters\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: |\n  Special: < > & \" ' @ # $ % [ ] { }\n`\n        );\n\n        const context = loadChangeContext(tempDir, 'my-change');\n        const instructions = generateInstructions(context, 'proposal', tempDir);\n\n        expect(instructions.context).toContain('Special: < > & \" \\' @ # $ % [ ] { }');\n      });\n\n      it('should return rules only for matching artifact', () => {\n        // Create project config with rules\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\nrules:\n  proposal:\n    - Include rollback plan\n    - Identify affected teams\n  specs:\n    - Use Given/When/Then format\n`\n        );\n\n        const context = loadChangeContext(tempDir, 'my-change');\n\n        // Check proposal artifact has its rules\n        const proposalInstructions = generateInstructions(context, 'proposal', tempDir);\n        expect(proposalInstructions.rules).toEqual(['Include rollback plan', 'Identify affected teams']);\n        expect(proposalInstructions.template).not.toContain('rollback plan');\n\n        // Check specs artifact has its rules\n        const specsInstructions = generateInstructions(context, 'specs', tempDir);\n        expect(specsInstructions.rules).toEqual(['Use Given/When/Then format']);\n        expect(specsInstructions.template).not.toContain('Given/When/Then');\n      });\n\n      it('should return undefined rules for non-matching artifact', () => {\n        // Create project config with rules only for proposal\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\nrules:\n  proposal:\n    - Include rollback plan\n`\n        );\n\n        const context = loadChangeContext(tempDir, 'my-change');\n\n        // Check design artifact (no rules configured) has undefined rules\n        const designInstructions = generateInstructions(context, 'design', tempDir);\n        expect(designInstructions.rules).toBeUndefined();\n      });\n\n      it('should return undefined rules when empty array', () => {\n        // Create project config with empty rules array\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: Some context\nrules:\n  proposal: []\n`\n        );\n\n        const context = loadChangeContext(tempDir, 'my-change');\n        const instructions = generateInstructions(context, 'proposal', tempDir);\n\n        expect(instructions.context).toBe('Some context');\n        expect(instructions.rules).toBeUndefined();\n      });\n\n      it('should keep context, rules, and template as separate fields', () => {\n        // Create project config with both context and rules\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: Project context here\nrules:\n  proposal:\n    - Rule 1\n`\n        );\n\n        const context = loadChangeContext(tempDir, 'my-change');\n        const instructions = generateInstructions(context, 'proposal', tempDir);\n\n        // All three should be separate\n        expect(instructions.context).toBe('Project context here');\n        expect(instructions.rules).toEqual(['Rule 1']);\n        expect(instructions.template).toContain('## Why');\n        // Template should not contain context or rules\n        expect(instructions.template).not.toContain('Project context here');\n        expect(instructions.template).not.toContain('Rule 1');\n      });\n\n      it('should handle context without rules', () => {\n        // Create project config with only context\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: Project context only\n`\n        );\n\n        const context = loadChangeContext(tempDir, 'my-change');\n        const instructions = generateInstructions(context, 'proposal', tempDir);\n\n        expect(instructions.context).toBe('Project context only');\n        expect(instructions.rules).toBeUndefined();\n        expect(instructions.template).toContain('## Why');\n      });\n\n      it('should handle rules without context', () => {\n        // Create project config with only rules\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\nrules:\n  proposal:\n    - Rule only\n`\n        );\n\n        const context = loadChangeContext(tempDir, 'my-change');\n        const instructions = generateInstructions(context, 'proposal', tempDir);\n\n        expect(instructions.context).toBeUndefined();\n        expect(instructions.rules).toEqual(['Rule only']);\n        expect(instructions.template).toContain('## Why');\n      });\n\n      it('should work without project root parameter', () => {\n        const context = loadChangeContext(tempDir, 'my-change');\n        const instructions = generateInstructions(context, 'proposal'); // No projectRoot\n\n        expect(instructions.context).toBeUndefined();\n        expect(instructions.rules).toBeUndefined();\n        expect(instructions.template).toContain('## Why');\n      });\n    });\n\n    describe('validation and warnings', () => {\n      let consoleWarnSpy: ReturnType<typeof vi.spyOn>;\n\n      beforeEach(() => {\n        consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n      });\n\n      afterEach(() => {\n        consoleWarnSpy.mockRestore();\n      });\n\n      it('should warn about unknown artifact IDs in rules', () => {\n        // Create project config with invalid artifact ID\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\nrules:\n  proposal:\n    - Valid rule\n  invalid-artifact:\n    - Invalid rule\n`\n        );\n\n        const context = loadChangeContext(tempDir, 'my-change');\n        generateInstructions(context, 'proposal', tempDir);\n\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining('Unknown artifact ID in rules: \"invalid-artifact\"')\n        );\n      });\n\n      it('should deduplicate validation warnings within session', () => {\n        // Create a fresh temp directory to avoid cache pollution\n        const freshTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));\n\n        try {\n          // Create project config with a uniquely named invalid artifact ID\n          const configDir = path.join(freshTempDir, 'openspec');\n          fs.mkdirSync(configDir, { recursive: true });\n          fs.writeFileSync(\n            path.join(configDir, 'config.yaml'),\n            `schema: spec-driven\nrules:\n  unique-invalid-artifact-${Date.now()}:\n    - Invalid rule\n`\n          );\n\n          const context = loadChangeContext(freshTempDir, 'my-change');\n\n          // Call multiple times\n          generateInstructions(context, 'proposal', freshTempDir);\n          generateInstructions(context, 'specs', freshTempDir);\n          generateInstructions(context, 'design', freshTempDir);\n\n          // Warning should be shown only once (deduplication works)\n          // Note: We may have gotten warnings from other tests, so check that\n          // the count didn't increase by more than 1 from the first call\n          const callCount = consoleWarnSpy.mock.calls.filter(call =>\n            call[0]?.includes('Unknown artifact ID in rules')\n          ).length;\n\n          expect(callCount).toBeGreaterThanOrEqual(1);\n        } finally {\n          fs.rmSync(freshTempDir, { recursive: true, force: true });\n        }\n      });\n\n      it('should not warn for valid artifact IDs', () => {\n        // Create project config with valid artifact IDs\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\nrules:\n  proposal:\n    - Rule 1\n  specs:\n    - Rule 2\n`\n        );\n\n        const context = loadChangeContext(tempDir, 'my-change');\n        generateInstructions(context, 'proposal', tempDir);\n\n        expect(consoleWarnSpy).not.toHaveBeenCalled();\n      });\n    });\n  });\n\n  describe('formatChangeStatus', () => {\n    let tempDir: string;\n\n    beforeEach(() => {\n      tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));\n    });\n\n    afterEach(() => {\n      fs.rmSync(tempDir, { recursive: true, force: true });\n    });\n\n    it('should show all artifacts as ready/blocked when nothing completed', () => {\n      const context = loadChangeContext(tempDir, 'my-change');\n      const status = formatChangeStatus(context);\n\n      expect(status.changeName).toBe('my-change');\n      expect(status.schemaName).toBe('spec-driven');\n      expect(status.isComplete).toBe(false);\n\n      // proposal has no deps, should be ready\n      const proposal = status.artifacts.find(a => a.id === 'proposal');\n      expect(proposal?.status).toBe('ready');\n\n      // specs depends on proposal, should be blocked\n      const specs = status.artifacts.find(a => a.id === 'specs');\n      expect(specs?.status).toBe('blocked');\n      expect(specs?.missingDeps).toContain('proposal');\n    });\n\n    it('should show completed artifacts as done', () => {\n      const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');\n      fs.mkdirSync(changeDir, { recursive: true });\n      fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');\n\n      const context = loadChangeContext(tempDir, 'my-change');\n      const status = formatChangeStatus(context);\n\n      const proposal = status.artifacts.find(a => a.id === 'proposal');\n      expect(proposal?.status).toBe('done');\n\n      // specs should now be ready\n      const specs = status.artifacts.find(a => a.id === 'specs');\n      expect(specs?.status).toBe('ready');\n    });\n\n    it('should include output paths for each artifact', () => {\n      const context = loadChangeContext(tempDir, 'my-change');\n      const status = formatChangeStatus(context);\n\n      const proposal = status.artifacts.find(a => a.id === 'proposal');\n      expect(proposal?.outputPath).toBe('proposal.md');\n\n      const specs = status.artifacts.find(a => a.id === 'specs');\n      expect(specs?.outputPath).toBe('specs/**/*.md');\n    });\n\n    it('should report isComplete true when all done', () => {\n      const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');\n      fs.mkdirSync(changeDir, { recursive: true });\n      fs.mkdirSync(path.join(changeDir, 'specs'), { recursive: true });\n\n      // Create all required files for spec-driven schema\n      fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');\n      fs.writeFileSync(path.join(changeDir, 'specs', 'test.md'), '# Spec');\n      fs.writeFileSync(path.join(changeDir, 'design.md'), '# Design');\n      fs.writeFileSync(path.join(changeDir, 'tasks.md'), '# Tasks');\n\n      const context = loadChangeContext(tempDir, 'my-change');\n      const status = formatChangeStatus(context);\n\n      expect(status.isComplete).toBe(true);\n      expect(status.artifacts.every(a => a.status === 'done')).toBe(true);\n    });\n\n    it('should show blocked artifacts with missing dependencies', () => {\n      const context = loadChangeContext(tempDir, 'my-change');\n      const status = formatChangeStatus(context);\n\n      // tasks requires specs and design\n      const tasks = status.artifacts.find(a => a.id === 'tasks');\n      expect(tasks?.status).toBe('blocked');\n      expect(tasks?.missingDeps).toContain('specs');\n      expect(tasks?.missingDeps).toContain('design');\n    });\n\n    it('should sort artifacts in build order', () => {\n      const context = loadChangeContext(tempDir, 'my-change');\n      const status = formatChangeStatus(context);\n\n      const ids = status.artifacts.map(a => a.id);\n      const proposalIdx = ids.indexOf('proposal');\n      const specsIdx = ids.indexOf('specs');\n      const tasksIdx = ids.indexOf('tasks');\n\n      // proposal must come before specs, specs before tasks\n      expect(proposalIdx).toBeLessThan(specsIdx);\n      expect(specsIdx).toBeLessThan(tasksIdx);\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/artifact-graph/resolver.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport {\n  resolveSchema,\n  listSchemas,\n  listSchemasWithInfo,\n  SchemaLoadError,\n  getSchemaDir,\n  getPackageSchemasDir,\n  getUserSchemasDir,\n  getProjectSchemasDir,\n} from '../../../src/core/artifact-graph/resolver.js';\n\ndescribe('artifact-graph/resolver', () => {\n  let tempDir: string;\n  let originalEnv: NodeJS.ProcessEnv;\n\n  beforeEach(() => {\n    tempDir = path.join(os.tmpdir(), `openspec-resolver-test-${Date.now()}`);\n    fs.mkdirSync(tempDir, { recursive: true });\n    originalEnv = { ...process.env };\n  });\n\n  afterEach(() => {\n    process.env = originalEnv;\n    fs.rmSync(tempDir, { recursive: true, force: true });\n  });\n\n  describe('getPackageSchemasDir', () => {\n    it('should return a valid path', () => {\n      const schemasDir = getPackageSchemasDir();\n      expect(typeof schemasDir).toBe('string');\n      expect(schemasDir.length).toBeGreaterThan(0);\n    });\n  });\n\n  describe('getUserSchemasDir', () => {\n    it('should use XDG_DATA_HOME when set', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      const userDir = getUserSchemasDir();\n      expect(userDir).toBe(path.join(tempDir, 'openspec', 'schemas'));\n    });\n  });\n\n  describe('getSchemaDir', () => {\n    it('should return null for non-existent schema', () => {\n      const dir = getSchemaDir('nonexistent-schema');\n      expect(dir).toBeNull();\n    });\n\n    it('should return package dir for built-in schema', () => {\n      const dir = getSchemaDir('spec-driven');\n      expect(dir).not.toBeNull();\n      expect(dir).toContain('schemas');\n      expect(dir).toContain('spec-driven');\n    });\n\n    it('should prefer user override directory', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(userSchemaDir, 'schema.yaml'),\n        'name: custom\\nversion: 1\\nartifacts: []'\n      );\n\n      const dir = getSchemaDir('spec-driven');\n      expect(dir).toBe(userSchemaDir);\n    });\n  });\n\n  describe('resolveSchema', () => {\n    it('should return built-in spec-driven schema', () => {\n      const schema = resolveSchema('spec-driven');\n\n      expect(schema.name).toBe('spec-driven');\n      expect(schema.version).toBe(1);\n      expect(schema.artifacts.length).toBeGreaterThan(0);\n    });\n\n    it('should strip .yaml extension from name', () => {\n      const schema1 = resolveSchema('spec-driven');\n      const schema2 = resolveSchema('spec-driven.yaml');\n\n      expect(schema1).toEqual(schema2);\n    });\n\n    it('should strip .yml extension from name', () => {\n      const schema1 = resolveSchema('spec-driven');\n      const schema2 = resolveSchema('spec-driven.yml');\n\n      expect(schema1).toEqual(schema2);\n    });\n\n    it('should prefer user override over built-in', () => {\n      // Set up global data dir\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n\n      // Create a custom schema with same name as built-in\n      const customSchema = `\nname: custom-override\nversion: 99\nartifacts:\n  - id: custom\n    generates: custom.md\n    description: Custom artifact\n    template: custom.md\n`;\n      fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), customSchema);\n\n      const schema = resolveSchema('spec-driven');\n\n      expect(schema.name).toBe('custom-override');\n      expect(schema.version).toBe(99);\n    });\n\n    it('should validate user override and throw on invalid schema', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n\n      // Create an invalid schema (missing required fields)\n      const invalidSchema = `\nname: invalid\nversion: 1\nartifacts:\n  - id: broken\n    # missing generates, description, template\n`;\n      fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), invalidSchema);\n\n      expect(() => resolveSchema('spec-driven')).toThrow(SchemaLoadError);\n    });\n\n    it('should include file path in validation error message', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n\n      const invalidSchema = `\nname: invalid\nversion: 1\nartifacts:\n  - id: broken\n`;\n      const schemaPath = path.join(userSchemaDir, 'schema.yaml');\n      fs.writeFileSync(schemaPath, invalidSchema);\n\n      try {\n        resolveSchema('spec-driven');\n        expect.fail('Should have thrown');\n      } catch (e) {\n        const error = e as SchemaLoadError;\n        expect(error.message).toContain(schemaPath);\n        expect(error.schemaPath).toBe(schemaPath);\n        expect(error.cause).toBeDefined();\n      }\n    });\n\n    it('should detect cycles in user override schemas', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n\n      // Create a schema with cyclic dependencies\n      const cyclicSchema = `\nname: cyclic\nversion: 1\nartifacts:\n  - id: a\n    generates: a.md\n    description: A\n    template: a.md\n    requires: [b]\n  - id: b\n    generates: b.md\n    description: B\n    template: b.md\n    requires: [a]\n`;\n      fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), cyclicSchema);\n\n      expect(() => resolveSchema('spec-driven')).toThrow(/Cyclic dependency/);\n    });\n\n    it('should detect invalid requires references in user override schemas', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n\n      // Create a schema with invalid requires reference\n      const invalidRefSchema = `\nname: invalid-ref\nversion: 1\nartifacts:\n  - id: a\n    generates: a.md\n    description: A\n    template: a.md\n    requires: [nonexistent]\n`;\n      fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), invalidRefSchema);\n\n      expect(() => resolveSchema('spec-driven')).toThrow(/does not exist/);\n    });\n\n    it('should throw SchemaLoadError on YAML syntax errors', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n\n      // Create malformed YAML\n      const malformedYaml = `\nname: bad\nversion: [[[invalid yaml\n`;\n      const schemaPath = path.join(userSchemaDir, 'schema.yaml');\n      fs.writeFileSync(schemaPath, malformedYaml);\n\n      try {\n        resolveSchema('spec-driven');\n        expect.fail('Should have thrown');\n      } catch (e) {\n        expect(e).toBeInstanceOf(SchemaLoadError);\n        const error = e as SchemaLoadError;\n        expect(error.message).toContain('Failed to parse');\n        expect(error.message).toContain(schemaPath);\n      }\n    });\n\n    it('should fall back to built-in when user override not found', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      // Don't create any user schemas\n\n      const schema = resolveSchema('spec-driven');\n\n      expect(schema.name).toBe('spec-driven');\n      expect(schema.version).toBe(1);\n    });\n\n    it('should throw when schema not found', () => {\n      expect(() => resolveSchema('nonexistent-schema')).toThrow(/not found/);\n    });\n\n    it('should list available schemas in error message', () => {\n      try {\n        resolveSchema('nonexistent');\n        expect.fail('Should have thrown');\n      } catch (e) {\n        const error = e as Error;\n        expect(error.message).toContain('spec-driven');\n      }\n    });\n  });\n\n  describe('listSchemas', () => {\n    it('should list built-in schemas', () => {\n      const schemas = listSchemas();\n\n      expect(schemas).toContain('spec-driven');\n    });\n\n    it('should include user override schemas', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'custom-workflow');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n      fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: custom\\nversion: 1\\nartifacts: []');\n\n      const schemas = listSchemas();\n\n      expect(schemas).toContain('custom-workflow');\n      expect(schemas).toContain('spec-driven');\n    });\n\n    it('should deduplicate schemas with same name', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n      // Override spec-driven\n      fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: custom\\nversion: 1\\nartifacts: []');\n\n      const schemas = listSchemas();\n\n      // Should only appear once\n      const count = schemas.filter(s => s === 'spec-driven').length;\n      expect(count).toBe(1);\n    });\n\n    it('should return sorted list', () => {\n      const schemas = listSchemas();\n\n      const sorted = [...schemas].sort();\n      expect(schemas).toEqual(sorted);\n    });\n\n    it('should only include directories with schema.yaml', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemasBase = path.join(tempDir, 'openspec', 'schemas');\n\n      // Create a directory without schema.yaml\n      const emptyDir = path.join(userSchemasBase, 'empty-dir');\n      fs.mkdirSync(emptyDir, { recursive: true });\n\n      // Create a valid schema directory\n      const validDir = path.join(userSchemasBase, 'valid-schema');\n      fs.mkdirSync(validDir, { recursive: true });\n      fs.writeFileSync(path.join(validDir, 'schema.yaml'), 'name: valid\\nversion: 1\\nartifacts: []');\n\n      const schemas = listSchemas();\n\n      expect(schemas).toContain('valid-schema');\n      expect(schemas).not.toContain('empty-dir');\n    });\n  });\n\n  // =========================================================================\n  // Project-local schema tests\n  // =========================================================================\n\n  describe('getProjectSchemasDir', () => {\n    it('should return correct path', () => {\n      const projectRoot = '/path/to/project';\n      const schemasDir = getProjectSchemasDir(projectRoot);\n      expect(schemasDir).toBe(path.join('/path/to/project', 'openspec', 'schemas'));\n    });\n\n    it('should work with relative-looking paths', () => {\n      const schemasDir = getProjectSchemasDir('./my-project');\n      expect(schemasDir).toBe(path.join('my-project', 'openspec', 'schemas'));\n    });\n  });\n\n  describe('getSchemaDir with projectRoot', () => {\n    it('should return null for non-existent project schema', () => {\n      const dir = getSchemaDir('nonexistent-schema', tempDir);\n      expect(dir).toBeNull();\n    });\n\n    it('should prefer project-local schema over user override', () => {\n      // Set up user override\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'my-schema');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(userSchemaDir, 'schema.yaml'),\n        'name: user-version\\nversion: 1\\nartifacts: []'\n      );\n\n      // Set up project-local schema\n      const projectRoot = path.join(tempDir, 'project');\n      const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'my-schema');\n      fs.mkdirSync(projectSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(projectSchemaDir, 'schema.yaml'),\n        'name: project-version\\nversion: 2\\nartifacts: []'\n      );\n\n      const dir = getSchemaDir('my-schema', projectRoot);\n      expect(dir).toBe(projectSchemaDir);\n    });\n\n    it('should prefer project-local schema over package built-in', () => {\n      // Set up project-local schema that overrides built-in\n      const projectRoot = path.join(tempDir, 'project');\n      const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'spec-driven');\n      fs.mkdirSync(projectSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(projectSchemaDir, 'schema.yaml'),\n        'name: project-spec-driven\\nversion: 99\\nartifacts: []\\n'\n      );\n\n      const dir = getSchemaDir('spec-driven', projectRoot);\n      expect(dir).toBe(projectSchemaDir);\n    });\n\n    it('should fall back to user override when no project-local schema', () => {\n      // Set up user override only\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'user-only-schema');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(userSchemaDir, 'schema.yaml'),\n        'name: user-only\\nversion: 1\\nartifacts: []'\n      );\n\n      const projectRoot = path.join(tempDir, 'project');\n      fs.mkdirSync(projectRoot, { recursive: true });\n\n      const dir = getSchemaDir('user-only-schema', projectRoot);\n      expect(dir).toBe(userSchemaDir);\n    });\n\n    it('should fall back to package built-in when no project or user schema', () => {\n      const projectRoot = path.join(tempDir, 'project');\n      fs.mkdirSync(projectRoot, { recursive: true });\n\n      const dir = getSchemaDir('spec-driven', projectRoot);\n      expect(dir).not.toBeNull();\n      // Should be package path, not project or user\n      expect(dir).not.toContain(projectRoot);\n    });\n\n    it('should maintain backward compatibility when projectRoot not provided', () => {\n      // Set up user override\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'my-schema');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(userSchemaDir, 'schema.yaml'),\n        'name: user-version\\nversion: 1\\nartifacts: []'\n      );\n\n      // Set up project-local schema (should be ignored when projectRoot not provided)\n      const projectRoot = path.join(tempDir, 'project');\n      const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'my-schema');\n      fs.mkdirSync(projectSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(projectSchemaDir, 'schema.yaml'),\n        'name: project-version\\nversion: 2\\nartifacts: []'\n      );\n\n      // Without projectRoot, should get user version\n      const dir = getSchemaDir('my-schema');\n      expect(dir).toBe(userSchemaDir);\n    });\n  });\n\n  describe('resolveSchema with projectRoot', () => {\n    it('should resolve project-local schema', () => {\n      const projectRoot = path.join(tempDir, 'project');\n      const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'team-workflow');\n      fs.mkdirSync(projectSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(projectSchemaDir, 'schema.yaml'),\n        `name: team-workflow\nversion: 1\ndescription: Team workflow\nartifacts:\n  - id: spec\n    generates: spec.md\n    description: Specification\n    template: spec.md\n`\n      );\n\n      const schema = resolveSchema('team-workflow', projectRoot);\n      expect(schema.name).toBe('team-workflow');\n      expect(schema.version).toBe(1);\n    });\n\n    it('should prefer project-local over user override when resolving', () => {\n      // Set up user override\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'shared-schema');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(userSchemaDir, 'schema.yaml'),\n        `name: user-version\nversion: 1\nartifacts:\n  - id: user-artifact\n    generates: user.md\n    description: User artifact\n    template: user.md\n`\n      );\n\n      // Set up project-local schema\n      const projectRoot = path.join(tempDir, 'project');\n      const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'shared-schema');\n      fs.mkdirSync(projectSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(projectSchemaDir, 'schema.yaml'),\n        `name: project-version\nversion: 2\nartifacts:\n  - id: project-artifact\n    generates: project.md\n    description: Project artifact\n    template: project.md\n`\n      );\n\n      const schema = resolveSchema('shared-schema', projectRoot);\n      expect(schema.name).toBe('project-version');\n      expect(schema.version).toBe(2);\n    });\n  });\n\n  describe('listSchemas with projectRoot', () => {\n    it('should include project-local schemas', () => {\n      const projectRoot = path.join(tempDir, 'project');\n      const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'team-workflow');\n      fs.mkdirSync(projectSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(projectSchemaDir, 'schema.yaml'),\n        'name: team-workflow\\nversion: 1\\nartifacts: []'\n      );\n\n      const schemas = listSchemas(projectRoot);\n      expect(schemas).toContain('team-workflow');\n      expect(schemas).toContain('spec-driven'); // built-in still included\n    });\n\n    it('should deduplicate project-local schema that shadows user override', () => {\n      // Set up user override\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'my-schema');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(userSchemaDir, 'schema.yaml'),\n        'name: user\\nversion: 1\\nartifacts: []'\n      );\n\n      // Set up project-local schema with same name\n      const projectRoot = path.join(tempDir, 'project');\n      const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'my-schema');\n      fs.mkdirSync(projectSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(projectSchemaDir, 'schema.yaml'),\n        'name: project\\nversion: 2\\nartifacts: []'\n      );\n\n      const schemas = listSchemas(projectRoot);\n      const count = schemas.filter(s => s === 'my-schema').length;\n      expect(count).toBe(1);\n    });\n\n    it('should maintain backward compatibility when projectRoot not provided', () => {\n      // Set up project-local schema\n      const projectRoot = path.join(tempDir, 'project');\n      const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'project-only');\n      fs.mkdirSync(projectSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(projectSchemaDir, 'schema.yaml'),\n        'name: project-only\\nversion: 1\\nartifacts: []'\n      );\n\n      // Without projectRoot, project-only schema should not appear\n      const schemas = listSchemas();\n      expect(schemas).not.toContain('project-only');\n    });\n  });\n\n  describe('listSchemasWithInfo with projectRoot', () => {\n    it('should return source: project for project-local schemas', () => {\n      const projectRoot = path.join(tempDir, 'project');\n      const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'team-workflow');\n      fs.mkdirSync(projectSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(projectSchemaDir, 'schema.yaml'),\n        `name: team-workflow\nversion: 1\ndescription: Team workflow\nartifacts:\n  - id: spec\n    generates: spec.md\n    description: Specification\n    template: spec.md\n`\n      );\n\n      const schemas = listSchemasWithInfo(projectRoot);\n      const teamSchema = schemas.find(s => s.name === 'team-workflow');\n      expect(teamSchema).toBeDefined();\n      expect(teamSchema!.source).toBe('project');\n    });\n\n    it('should return source: package for built-in schemas', () => {\n      const projectRoot = path.join(tempDir, 'project');\n      fs.mkdirSync(projectRoot, { recursive: true });\n\n      const schemas = listSchemasWithInfo(projectRoot);\n      const specDriven = schemas.find(s => s.name === 'spec-driven');\n      expect(specDriven).toBeDefined();\n      expect(specDriven!.source).toBe('package');\n    });\n\n    it('should return source: user for user override schemas', () => {\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'user-custom');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(userSchemaDir, 'schema.yaml'),\n        `name: user-custom\nversion: 1\ndescription: User custom\nartifacts:\n  - id: artifact\n    generates: artifact.md\n    description: Artifact\n    template: artifact.md\n`\n      );\n\n      const projectRoot = path.join(tempDir, 'project');\n      fs.mkdirSync(projectRoot, { recursive: true });\n\n      const schemas = listSchemasWithInfo(projectRoot);\n      const userSchema = schemas.find(s => s.name === 'user-custom');\n      expect(userSchema).toBeDefined();\n      expect(userSchema!.source).toBe('user');\n    });\n\n    it('should show project source when project-local shadows user override', () => {\n      // Set up user override\n      process.env.XDG_DATA_HOME = tempDir;\n      const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'shared');\n      fs.mkdirSync(userSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(userSchemaDir, 'schema.yaml'),\n        `name: user-shared\nversion: 1\ndescription: User shared\nartifacts:\n  - id: a\n    generates: a.md\n    description: A\n    template: a.md\n`\n      );\n\n      // Set up project-local with same name\n      const projectRoot = path.join(tempDir, 'project');\n      const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'shared');\n      fs.mkdirSync(projectSchemaDir, { recursive: true });\n      fs.writeFileSync(\n        path.join(projectSchemaDir, 'schema.yaml'),\n        `name: project-shared\nversion: 2\ndescription: Project shared\nartifacts:\n  - id: b\n    generates: b.md\n    description: B\n    template: b.md\n`\n      );\n\n      const schemas = listSchemasWithInfo(projectRoot);\n      const sharedSchema = schemas.find(s => s.name === 'shared');\n      expect(sharedSchema).toBeDefined();\n      expect(sharedSchema!.source).toBe('project');\n      expect(sharedSchema!.description).toBe('Project shared'); // project version wins\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/artifact-graph/schema.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { parseSchema, SchemaValidationError } from '../../../src/core/artifact-graph/schema.js';\n\ndescribe('artifact-graph/schema', () => {\n  describe('parseSchema', () => {\n    it('should parse valid schema YAML', () => {\n      const yaml = `\nname: test-schema\nversion: 1\ndescription: A test schema\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    description: Initial proposal\n    template: templates/proposal.md\n    requires: []\n  - id: design\n    generates: design.md\n    description: Design document\n    template: templates/design.md\n    requires:\n      - proposal\n`;\n      const schema = parseSchema(yaml);\n\n      expect(schema.name).toBe('test-schema');\n      expect(schema.version).toBe(1);\n      expect(schema.description).toBe('A test schema');\n      expect(schema.artifacts).toHaveLength(2);\n      expect(schema.artifacts[0].id).toBe('proposal');\n      expect(schema.artifacts[1].requires).toEqual(['proposal']);\n    });\n\n    it('should throw on missing required fields', () => {\n      const yaml = `\nname: test-schema\nversion: 1\nartifacts:\n  - id: proposal\n    description: Missing generates and template\n`;\n      expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);\n      expect(() => parseSchema(yaml)).toThrow(/generates/);\n    });\n\n    it('should throw on missing schema name', () => {\n      const yaml = `\nversion: 1\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    description: Test\n    template: templates/proposal.md\n`;\n      expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);\n      expect(() => parseSchema(yaml)).toThrow(/name/);\n    });\n\n    it('should throw on invalid version (non-positive)', () => {\n      const yaml = `\nname: test\nversion: 0\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    description: Test\n    template: templates/proposal.md\n`;\n      expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);\n      expect(() => parseSchema(yaml)).toThrow(/positive/);\n    });\n\n    it('should throw on empty artifacts array', () => {\n      const yaml = `\nname: test\nversion: 1\nartifacts: []\n`;\n      expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);\n      expect(() => parseSchema(yaml)).toThrow(/artifact/i);\n    });\n\n    it('should throw on duplicate artifact IDs', () => {\n      const yaml = `\nname: test\nversion: 1\nartifacts:\n  - id: proposal\n    generates: proposal.md\n    description: First\n    template: templates/proposal.md\n  - id: proposal\n    generates: other.md\n    description: Duplicate\n    template: templates/other.md\n`;\n      expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);\n      expect(() => parseSchema(yaml)).toThrow(/Duplicate artifact ID: proposal/);\n    });\n\n    it('should throw on invalid requires reference', () => {\n      const yaml = `\nname: test\nversion: 1\nartifacts:\n  - id: design\n    generates: design.md\n    description: Design doc\n    template: templates/design.md\n    requires:\n      - nonexistent\n`;\n      expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);\n      expect(() => parseSchema(yaml)).toThrow(/Invalid dependency reference.*nonexistent/);\n    });\n\n    it('should detect self-referencing cycle', () => {\n      const yaml = `\nname: test\nversion: 1\nartifacts:\n  - id: A\n    generates: a.md\n    description: Self reference\n    template: templates/a.md\n    requires:\n      - A\n`;\n      expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);\n      expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);\n    });\n\n    it('should detect simple A → B → A cycle', () => {\n      const yaml = `\nname: test\nversion: 1\nartifacts:\n  - id: A\n    generates: a.md\n    description: A\n    template: templates/a.md\n    requires:\n      - B\n  - id: B\n    generates: b.md\n    description: B\n    template: templates/b.md\n    requires:\n      - A\n`;\n      expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);\n      expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);\n      expect(() => parseSchema(yaml)).toThrow(/→/);\n    });\n\n    it('should detect longer A → B → C → A cycle and list all IDs', () => {\n      const yaml = `\nname: test\nversion: 1\nartifacts:\n  - id: A\n    generates: a.md\n    description: A\n    template: templates/a.md\n    requires:\n      - C\n  - id: B\n    generates: b.md\n    description: B\n    template: templates/b.md\n    requires:\n      - A\n  - id: C\n    generates: c.md\n    description: C\n    template: templates/c.md\n    requires:\n      - B\n`;\n      expect(() => parseSchema(yaml)).toThrow(SchemaValidationError);\n      expect(() => parseSchema(yaml)).toThrow(/Cyclic dependency detected/);\n      // Should contain all three in the cycle path\n      const error = (() => {\n        try {\n          parseSchema(yaml);\n        } catch (e) {\n          return e;\n        }\n      })() as Error;\n      expect(error.message).toMatch(/A.*→.*B|B.*→.*C|C.*→.*A/);\n    });\n\n    it('should allow default empty requires array', () => {\n      const yaml = `\nname: test\nversion: 1\nartifacts:\n  - id: root\n    generates: root.md\n    description: Root artifact\n    template: templates/root.md\n`;\n      const schema = parseSchema(yaml);\n      expect(schema.artifacts[0].requires).toEqual([]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/artifact-graph/state.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport { detectCompleted } from '../../../src/core/artifact-graph/state.js';\nimport { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';\nimport type { SchemaYaml } from '../../../src/core/artifact-graph/types.js';\n\ndescribe('artifact-graph/state', () => {\n  let tempDir: string;\n\n  const createSchema = (artifacts: SchemaYaml['artifacts']): SchemaYaml => ({\n    name: 'test',\n    version: 1,\n    artifacts,\n  });\n\n  beforeEach(() => {\n    tempDir = path.join(os.tmpdir(), `openspec-state-test-${Date.now()}`);\n    fs.mkdirSync(tempDir, { recursive: true });\n  });\n\n  afterEach(() => {\n    fs.rmSync(tempDir, { recursive: true, force: true });\n  });\n\n  describe('detectCompleted', () => {\n    it('should return empty set when changeDir does not exist', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const completed = detectCompleted(graph, '/nonexistent/path');\n\n      expect(completed.size).toBe(0);\n    });\n\n    it('should return empty set when changeDir is empty', () => {\n      const schema = createSchema([\n        { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const completed = detectCompleted(graph, tempDir);\n\n      expect(completed.size).toBe(0);\n    });\n\n    it('should mark artifact complete when file exists', () => {\n      const schema = createSchema([\n        { id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Create the file\n      fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');\n\n      const completed = detectCompleted(graph, tempDir);\n\n      expect(completed.has('proposal')).toBe(true);\n    });\n\n    it('should not mark artifact complete when file does not exist', () => {\n      const schema = createSchema([\n        { id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },\n        { id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Only create proposal.md\n      fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');\n\n      const completed = detectCompleted(graph, tempDir);\n\n      expect(completed.has('proposal')).toBe(true);\n      expect(completed.has('design')).toBe(false);\n    });\n\n    it('should handle nested paths', () => {\n      const schema = createSchema([\n        { id: 'nested', generates: 'docs/design.md', description: 'Nested', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Create nested directory and file\n      fs.mkdirSync(path.join(tempDir, 'docs'), { recursive: true });\n      fs.writeFileSync(path.join(tempDir, 'docs', 'design.md'), 'content');\n\n      const completed = detectCompleted(graph, tempDir);\n\n      expect(completed.has('nested')).toBe(true);\n    });\n\n    it('should detect glob pattern as complete when files exist', () => {\n      const schema = createSchema([\n        { id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Create specs directory with files\n      fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });\n      fs.writeFileSync(path.join(tempDir, 'specs', 'feature-a.md'), 'content');\n      fs.writeFileSync(path.join(tempDir, 'specs', 'feature-b.md'), 'content');\n\n      const completed = detectCompleted(graph, tempDir);\n\n      expect(completed.has('specs')).toBe(true);\n    });\n\n    it('should not mark glob pattern complete when directory is empty', () => {\n      const schema = createSchema([\n        { id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Create empty specs directory\n      fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });\n\n      const completed = detectCompleted(graph, tempDir);\n\n      expect(completed.has('specs')).toBe(false);\n    });\n\n    it('should not mark glob pattern complete when directory does not exist', () => {\n      const schema = createSchema([\n        { id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const completed = detectCompleted(graph, tempDir);\n\n      expect(completed.has('specs')).toBe(false);\n    });\n\n    it('should not mark glob pattern complete when only non-matching files exist', () => {\n      const schema = createSchema([\n        { id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: [] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Create specs directory with non-matching files\n      fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });\n      fs.writeFileSync(path.join(tempDir, 'specs', 'readme.txt'), 'content');\n\n      const completed = detectCompleted(graph, tempDir);\n\n      expect(completed.has('specs')).toBe(false);\n    });\n\n    it('should handle multiple artifacts with mixed completion', () => {\n      const schema = createSchema([\n        { id: 'proposal', generates: 'proposal.md', description: 'Proposal', template: 't.md', requires: [] },\n        { id: 'specs', generates: 'specs/*.md', description: 'Specs', template: 't.md', requires: ['proposal'] },\n        { id: 'design', generates: 'design.md', description: 'Design', template: 't.md', requires: ['proposal'] },\n        { id: 'tasks', generates: 'tasks.md', description: 'Tasks', template: 't.md', requires: ['specs', 'design'] },\n      ]);\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Create some files\n      fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');\n      fs.mkdirSync(path.join(tempDir, 'specs'), { recursive: true });\n      fs.writeFileSync(path.join(tempDir, 'specs', 'auth.md'), 'content');\n      // design.md and tasks.md do not exist\n\n      const completed = detectCompleted(graph, tempDir);\n\n      expect(completed.has('proposal')).toBe(true);\n      expect(completed.has('specs')).toBe(true);\n      expect(completed.has('design')).toBe(false);\n      expect(completed.has('tasks')).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/artifact-graph/workflow.integration.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport { resolveSchema } from '../../../src/core/artifact-graph/resolver.js';\nimport { ArtifactGraph } from '../../../src/core/artifact-graph/graph.js';\nimport { detectCompleted } from '../../../src/core/artifact-graph/state.js';\nimport type { BlockedArtifacts } from '../../../src/core/artifact-graph/types.js';\n\n/**\n * Normalize BlockedArtifacts for comparison by sorting dependency arrays.\n * The order of unmet dependencies is not guaranteed, so we sort for stable assertions.\n */\nfunction normalizeBlocked(blocked: BlockedArtifacts): BlockedArtifacts {\n  const normalized: BlockedArtifacts = {};\n  for (const [key, deps] of Object.entries(blocked)) {\n    normalized[key] = [...deps].sort();\n  }\n  return normalized;\n}\n\ndescribe('artifact-graph workflow integration', () => {\n  let tempDir: string;\n\n  beforeEach(() => {\n    // Use a unique temp directory for each test\n    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workflow-test-'));\n  });\n\n  afterEach(() => {\n    // Clean up temp directory after each test\n    if (tempDir && fs.existsSync(tempDir)) {\n      fs.rmSync(tempDir, { recursive: true, force: true });\n    }\n  });\n\n  describe('spec-driven workflow', () => {\n    it('should progress through complete workflow', () => {\n      // 1. Resolve the real built-in schema\n      const schema = resolveSchema('spec-driven');\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Verify schema structure\n      expect(graph.getName()).toBe('spec-driven');\n      expect(graph.getAllArtifacts()).toHaveLength(4);\n\n      // 2. Initial state - nothing complete, only proposal is ready\n      let completed = detectCompleted(graph, tempDir);\n      expect(completed.size).toBe(0);\n      expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);\n      expect(graph.isComplete(completed)).toBe(false);\n      expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({\n        specs: ['proposal'],\n        design: ['proposal'],\n        tasks: ['design', 'specs'],\n      });\n\n      // 3. Create proposal.md - now specs and design become ready\n      fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal\\n\\nInitial proposal content.');\n      completed = detectCompleted(graph, tempDir);\n      expect(completed).toEqual(new Set(['proposal']));\n      expect(graph.getNextArtifacts(completed).sort()).toEqual(['design', 'specs']);\n      expect(normalizeBlocked(graph.getBlocked(completed))).toEqual({\n        tasks: ['design', 'specs'],\n      });\n\n      // 4. Create design.md - specs still needed for tasks\n      fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design\\n\\nTechnical design content.');\n      completed = detectCompleted(graph, tempDir);\n      expect(completed).toEqual(new Set(['proposal', 'design']));\n      expect(graph.getNextArtifacts(completed)).toEqual(['specs']);\n      expect(graph.getBlocked(completed)).toEqual({\n        tasks: ['specs'],\n      });\n\n      // 5. Create specs directory with a spec file - tasks becomes ready\n      const specsDir = path.join(tempDir, 'specs');\n      fs.mkdirSync(specsDir, { recursive: true });\n      fs.writeFileSync(path.join(specsDir, 'feature-auth.md'), '# Auth Spec\\n\\nAuthentication specification.');\n      completed = detectCompleted(graph, tempDir);\n      expect(completed).toEqual(new Set(['proposal', 'design', 'specs']));\n      expect(graph.getNextArtifacts(completed)).toEqual(['tasks']);\n      expect(graph.getBlocked(completed)).toEqual({});\n\n      // 6. Create tasks.md - workflow complete\n      fs.writeFileSync(path.join(tempDir, 'tasks.md'), '# Tasks\\n\\n- [ ] Implement feature');\n      completed = detectCompleted(graph, tempDir);\n      expect(completed).toEqual(new Set(['proposal', 'design', 'specs', 'tasks']));\n      expect(graph.getNextArtifacts(completed)).toEqual([]);\n      expect(graph.isComplete(completed)).toBe(true);\n      expect(graph.getBlocked(completed)).toEqual({});\n    });\n\n    it('should handle out-of-order file creation', () => {\n      const schema = resolveSchema('spec-driven');\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Create files in wrong order - design before proposal\n      fs.writeFileSync(path.join(tempDir, 'design.md'), '# Design');\n\n      let completed = detectCompleted(graph, tempDir);\n      // design file exists but it's still marked complete (filesystem-based)\n      expect(completed).toEqual(new Set(['design']));\n      // proposal is still the only \"ready\" artifact since it has no deps\n      expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);\n\n      // Now create proposal\n      fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');\n      completed = detectCompleted(graph, tempDir);\n      expect(completed).toEqual(new Set(['proposal', 'design']));\n      // specs is the only thing ready now (design already done)\n      expect(graph.getNextArtifacts(completed)).toEqual(['specs']);\n    });\n\n    it('should handle multiple spec files in glob pattern', () => {\n      const schema = resolveSchema('spec-driven');\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Complete prerequisites\n      fs.writeFileSync(path.join(tempDir, 'proposal.md'), '# Proposal');\n\n      // Create specs directory with multiple files\n      const specsDir = path.join(tempDir, 'specs');\n      fs.mkdirSync(specsDir, { recursive: true });\n      fs.writeFileSync(path.join(specsDir, 'auth.md'), '# Auth');\n      fs.writeFileSync(path.join(specsDir, 'api.md'), '# API');\n      fs.writeFileSync(path.join(specsDir, 'database.md'), '# Database');\n\n      const completed = detectCompleted(graph, tempDir);\n      expect(completed.has('specs')).toBe(true);\n    });\n  });\n\n  describe('build order consistency', () => {\n    it('should return consistent build order across multiple calls', () => {\n      const schema = resolveSchema('spec-driven');\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const order1 = graph.getBuildOrder();\n      const order2 = graph.getBuildOrder();\n      const order3 = graph.getBuildOrder();\n\n      expect(order1).toEqual(order2);\n      expect(order2).toEqual(order3);\n    });\n  });\n\n  describe('empty and edge cases', () => {\n    it('should handle empty change directory gracefully', () => {\n      const schema = resolveSchema('spec-driven');\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Directory exists but is empty\n      const completed = detectCompleted(graph, tempDir);\n      expect(completed.size).toBe(0);\n      expect(graph.getNextArtifacts(completed)).toEqual(['proposal']);\n    });\n\n    it('should handle non-existent change directory', () => {\n      const schema = resolveSchema('spec-driven');\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      const nonExistentDir = path.join(tempDir, 'does-not-exist');\n      const completed = detectCompleted(graph, nonExistentDir);\n      expect(completed.size).toBe(0);\n    });\n\n    it('should not count non-matching files in glob directories', () => {\n      const schema = resolveSchema('spec-driven');\n      const graph = ArtifactGraph.fromSchema(schema);\n\n      // Create specs directory with wrong file types\n      const specsDir = path.join(tempDir, 'specs');\n      fs.mkdirSync(specsDir, { recursive: true });\n      fs.writeFileSync(path.join(specsDir, 'notes.txt'), 'not a markdown file');\n      fs.writeFileSync(path.join(specsDir, 'data.json'), '{}');\n\n      const completed = detectCompleted(graph, tempDir);\n      expect(completed.has('specs')).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/available-tools.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\nimport { getAvailableTools } from '../../src/core/available-tools.js';\n\ndescribe('available-tools', () => {\n  let testDir: string;\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);\n    await fs.mkdir(testDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('getAvailableTools', () => {\n    it('should return empty array when no tool directories exist', () => {\n      const tools = getAvailableTools(testDir);\n      expect(tools).toEqual([]);\n    });\n\n    it('should detect a single tool directory', async () => {\n      await fs.mkdir(path.join(testDir, '.claude'), { recursive: true });\n\n      const tools = getAvailableTools(testDir);\n      expect(tools).toHaveLength(1);\n      expect(tools[0].value).toBe('claude');\n      expect(tools[0].name).toBe('Claude Code');\n      expect(tools[0].skillsDir).toBe('.claude');\n    });\n\n    it('should detect multiple tool directories', async () => {\n      await fs.mkdir(path.join(testDir, '.claude'), { recursive: true });\n      await fs.mkdir(path.join(testDir, '.cursor'), { recursive: true });\n      await fs.mkdir(path.join(testDir, '.windsurf'), { recursive: true });\n\n      const tools = getAvailableTools(testDir);\n      const toolValues = tools.map((t) => t.value);\n      expect(toolValues).toContain('claude');\n      expect(toolValues).toContain('cursor');\n      expect(toolValues).toContain('windsurf');\n      expect(tools).toHaveLength(3);\n    });\n\n    it('should ignore files that are not directories', async () => {\n      // Create a file named .claude instead of a directory\n      await fs.writeFile(path.join(testDir, '.claude'), 'not a directory');\n\n      const tools = getAvailableTools(testDir);\n      expect(tools).toEqual([]);\n    });\n\n    it('should only return tools that have a skillsDir property', async () => {\n      // .agents value has no skillsDir in AI_TOOLS config\n      // Create directories for both a valid and the agents case\n      await fs.mkdir(path.join(testDir, '.claude'), { recursive: true });\n\n      const tools = getAvailableTools(testDir);\n      const toolValues = tools.map((t) => t.value);\n      expect(toolValues).toContain('claude');\n      expect(toolValues).not.toContain('agents');\n    });\n\n    it('should return full AIToolOption objects', async () => {\n      await fs.mkdir(path.join(testDir, '.cursor'), { recursive: true });\n\n      const tools = getAvailableTools(testDir);\n      expect(tools).toHaveLength(1);\n      expect(tools[0]).toMatchObject({\n        name: 'Cursor',\n        value: 'cursor',\n        available: true,\n        skillsDir: '.cursor',\n      });\n    });\n\n    it('should handle paths with spaces', async () => {\n      const spacedDir = path.join(testDir, 'path with spaces');\n      await fs.mkdir(spacedDir, { recursive: true });\n      await fs.mkdir(path.join(spacedDir, '.claude'), { recursive: true });\n\n      const tools = getAvailableTools(spacedDir);\n      expect(tools).toHaveLength(1);\n      expect(tools[0].value).toBe('claude');\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/command-generation/adapters.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport os from 'os';\nimport path from 'path';\nimport { amazonQAdapter } from '../../../src/core/command-generation/adapters/amazon-q.js';\nimport { antigravityAdapter } from '../../../src/core/command-generation/adapters/antigravity.js';\nimport { auggieAdapter } from '../../../src/core/command-generation/adapters/auggie.js';\nimport { claudeAdapter } from '../../../src/core/command-generation/adapters/claude.js';\nimport { clineAdapter } from '../../../src/core/command-generation/adapters/cline.js';\nimport { codexAdapter } from '../../../src/core/command-generation/adapters/codex.js';\nimport { codebuddyAdapter } from '../../../src/core/command-generation/adapters/codebuddy.js';\nimport { continueAdapter } from '../../../src/core/command-generation/adapters/continue.js';\nimport { costrictAdapter } from '../../../src/core/command-generation/adapters/costrict.js';\nimport { crushAdapter } from '../../../src/core/command-generation/adapters/crush.js';\nimport { cursorAdapter } from '../../../src/core/command-generation/adapters/cursor.js';\nimport { factoryAdapter } from '../../../src/core/command-generation/adapters/factory.js';\nimport { geminiAdapter } from '../../../src/core/command-generation/adapters/gemini.js';\nimport { githubCopilotAdapter } from '../../../src/core/command-generation/adapters/github-copilot.js';\nimport { iflowAdapter } from '../../../src/core/command-generation/adapters/iflow.js';\nimport { kilocodeAdapter } from '../../../src/core/command-generation/adapters/kilocode.js';\nimport { opencodeAdapter } from '../../../src/core/command-generation/adapters/opencode.js';\nimport { piAdapter } from '../../../src/core/command-generation/adapters/pi.js';\nimport { qoderAdapter } from '../../../src/core/command-generation/adapters/qoder.js';\nimport { qwenAdapter } from '../../../src/core/command-generation/adapters/qwen.js';\nimport { roocodeAdapter } from '../../../src/core/command-generation/adapters/roocode.js';\nimport { windsurfAdapter } from '../../../src/core/command-generation/adapters/windsurf.js';\nimport type { CommandContent } from '../../../src/core/command-generation/types.js';\n\ndescribe('command-generation/adapters', () => {\n  const sampleContent: CommandContent = {\n    id: 'explore',\n    name: 'OpenSpec Explore',\n    description: 'Enter explore mode for thinking',\n    category: 'Workflow',\n    tags: ['workflow', 'explore', 'experimental'],\n    body: 'This is the command body.\\n\\nWith multiple lines.',\n  };\n\n  describe('claudeAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(claudeAdapter.toolId).toBe('claude');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = claudeAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.claude', 'commands', 'opsx', 'explore.md'));\n    });\n\n    it('should generate correct file path for different command IDs', () => {\n      expect(claudeAdapter.getFilePath('new')).toBe(path.join('.claude', 'commands', 'opsx', 'new.md'));\n      expect(claudeAdapter.getFilePath('bulk-archive')).toBe(path.join('.claude', 'commands', 'opsx', 'bulk-archive.md'));\n    });\n\n    it('should format file with correct YAML frontmatter', () => {\n      const output = claudeAdapter.formatFile(sampleContent);\n\n      expect(output).toContain('---\\n');\n      expect(output).toContain('name: OpenSpec Explore');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('category: Workflow');\n      expect(output).toContain('tags: [workflow, explore, experimental]');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.\\n\\nWith multiple lines.');\n    });\n\n    it('should handle empty tags', () => {\n      const contentNoTags: CommandContent = { ...sampleContent, tags: [] };\n      const output = claudeAdapter.formatFile(contentNoTags);\n      expect(output).toContain('tags: []');\n    });\n  });\n\n  describe('cursorAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(cursorAdapter.toolId).toBe('cursor');\n    });\n\n    it('should generate correct file path with opsx- prefix', () => {\n      const filePath = cursorAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.cursor', 'commands', 'opsx-explore.md'));\n    });\n\n    it('should generate correct file paths for different commands', () => {\n      expect(cursorAdapter.getFilePath('new')).toBe(path.join('.cursor', 'commands', 'opsx-new.md'));\n      expect(cursorAdapter.getFilePath('bulk-archive')).toBe(path.join('.cursor', 'commands', 'opsx-bulk-archive.md'));\n    });\n\n    it('should format file with Cursor-specific frontmatter', () => {\n      const output = cursorAdapter.formatFile(sampleContent);\n\n      expect(output).toContain('---\\n');\n      expect(output).toContain('name: /opsx-explore');\n      expect(output).toContain('id: opsx-explore');\n      expect(output).toContain('category: Workflow');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n\n    it('should not include tags in Cursor format', () => {\n      const output = cursorAdapter.formatFile(sampleContent);\n      expect(output).not.toContain('tags:');\n    });\n  });\n\n  describe('windsurfAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(windsurfAdapter.toolId).toBe('windsurf');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = windsurfAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.windsurf', 'workflows', 'opsx-explore.md'));\n    });\n\n    it('should format file similar to Claude format', () => {\n      const output = windsurfAdapter.formatFile(sampleContent);\n\n      expect(output).toContain('---\\n');\n      expect(output).toContain('name: OpenSpec Explore');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('category: Workflow');\n      expect(output).toContain('tags: [workflow, explore, experimental]');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('amazonQAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(amazonQAdapter.toolId).toBe('amazon-q');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = amazonQAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.amazonq', 'prompts', 'opsx-explore.md'));\n    });\n\n    it('should format file with description frontmatter', () => {\n      const output = amazonQAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('antigravityAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(antigravityAdapter.toolId).toBe('antigravity');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = antigravityAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.agent', 'workflows', 'opsx-explore.md'));\n    });\n\n    it('should format file with description frontmatter', () => {\n      const output = antigravityAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('auggieAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(auggieAdapter.toolId).toBe('auggie');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = auggieAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.augment', 'commands', 'opsx-explore.md'));\n    });\n\n    it('should format file with description and argument-hint', () => {\n      const output = auggieAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('argument-hint: command arguments');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('clineAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(clineAdapter.toolId).toBe('cline');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = clineAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.clinerules', 'workflows', 'opsx-explore.md'));\n    });\n\n    it('should format file with markdown header (no YAML frontmatter)', () => {\n      const output = clineAdapter.formatFile(sampleContent);\n      expect(output).toContain('# OpenSpec Explore');\n      expect(output).toContain('Enter explore mode for thinking');\n      expect(output).toContain('This is the command body.');\n      expect(output).not.toContain('---');\n    });\n  });\n\n  describe('codexAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(codexAdapter.toolId).toBe('codex');\n    });\n\n    it('should return an absolute path', () => {\n      const filePath = codexAdapter.getFilePath('explore');\n      expect(path.isAbsolute(filePath)).toBe(true);\n    });\n\n    it('should generate path ending with correct structure', () => {\n      const filePath = codexAdapter.getFilePath('explore');\n      expect(filePath).toMatch(/prompts[/\\\\]opsx-explore\\.md$/);\n    });\n\n    it('should default to homedir/.codex', () => {\n      const original = process.env.CODEX_HOME;\n      delete process.env.CODEX_HOME;\n      try {\n        const filePath = codexAdapter.getFilePath('explore');\n        const expected = path.join(os.homedir(), '.codex', 'prompts', 'opsx-explore.md');\n        expect(filePath).toBe(expected);\n      } finally {\n        if (original !== undefined) {\n          process.env.CODEX_HOME = original;\n        }\n      }\n    });\n\n    it('should respect CODEX_HOME env var', () => {\n      const original = process.env.CODEX_HOME;\n      process.env.CODEX_HOME = '/custom/codex-home';\n      try {\n        const filePath = codexAdapter.getFilePath('explore');\n        expect(filePath).toBe(path.join(path.resolve('/custom/codex-home'), 'prompts', 'opsx-explore.md'));\n      } finally {\n        if (original !== undefined) {\n          process.env.CODEX_HOME = original;\n        } else {\n          delete process.env.CODEX_HOME;\n        }\n      }\n    });\n\n    it('should format file with description and argument-hint', () => {\n      const output = codexAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('argument-hint: command arguments');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('codebuddyAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(codebuddyAdapter.toolId).toBe('codebuddy');\n    });\n\n    it('should generate correct file path with nested opsx folder', () => {\n      const filePath = codebuddyAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.codebuddy', 'commands', 'opsx', 'explore.md'));\n    });\n\n    it('should format file with name, description, and argument-hint', () => {\n      const output = codebuddyAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('name: OpenSpec Explore');\n      expect(output).toContain('description: \"Enter explore mode for thinking\"');\n      expect(output).toContain('argument-hint: \"[command arguments]\"');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('continueAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(continueAdapter.toolId).toBe('continue');\n    });\n\n    it('should generate correct file path with .prompt extension', () => {\n      const filePath = continueAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.continue', 'prompts', 'opsx-explore.prompt'));\n    });\n\n    it('should format file with name, description, and invokable', () => {\n      const output = continueAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('name: opsx-explore');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('invokable: true');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('costrictAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(costrictAdapter.toolId).toBe('costrict');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = costrictAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.cospec', 'openspec', 'commands', 'opsx-explore.md'));\n    });\n\n    it('should format file with description and argument-hint', () => {\n      const output = costrictAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('description: \"Enter explore mode for thinking\"');\n      expect(output).toContain('argument-hint: command arguments');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('crushAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(crushAdapter.toolId).toBe('crush');\n    });\n\n    it('should generate correct file path with nested opsx folder', () => {\n      const filePath = crushAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.crush', 'commands', 'opsx', 'explore.md'));\n    });\n\n    it('should format file with name, description, category, and tags', () => {\n      const output = crushAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('name: OpenSpec Explore');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('category: Workflow');\n      expect(output).toContain('tags: [workflow, explore, experimental]');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('factoryAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(factoryAdapter.toolId).toBe('factory');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = factoryAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.factory', 'commands', 'opsx-explore.md'));\n    });\n\n    it('should format file with description and argument-hint', () => {\n      const output = factoryAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('argument-hint: command arguments');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('geminiAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(geminiAdapter.toolId).toBe('gemini');\n    });\n\n    it('should generate correct file path with .toml extension', () => {\n      const filePath = geminiAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.gemini', 'commands', 'opsx', 'explore.toml'));\n    });\n\n    it('should format file in TOML format', () => {\n      const output = geminiAdapter.formatFile(sampleContent);\n      expect(output).toContain('description = \"Enter explore mode for thinking\"');\n      expect(output).toContain('prompt = \"\"\"');\n      expect(output).toContain('This is the command body.');\n      expect(output).toContain('\"\"\"');\n    });\n  });\n\n  describe('githubCopilotAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(githubCopilotAdapter.toolId).toBe('github-copilot');\n    });\n\n    it('should generate correct file path with .prompt.md extension', () => {\n      const filePath = githubCopilotAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.github', 'prompts', 'opsx-explore.prompt.md'));\n    });\n\n    it('should format file with description frontmatter', () => {\n      const output = githubCopilotAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('iflowAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(iflowAdapter.toolId).toBe('iflow');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = iflowAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.iflow', 'commands', 'opsx-explore.md'));\n    });\n\n    it('should format file with name, id, category, and description', () => {\n      const output = iflowAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('name: /opsx-explore');\n      expect(output).toContain('id: opsx-explore');\n      expect(output).toContain('category: Workflow');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('kilocodeAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(kilocodeAdapter.toolId).toBe('kilocode');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = kilocodeAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.kilocode', 'workflows', 'opsx-explore.md'));\n    });\n\n    it('should format file without frontmatter', () => {\n      const output = kilocodeAdapter.formatFile(sampleContent);\n      expect(output).not.toContain('---');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('opencodeAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(opencodeAdapter.toolId).toBe('opencode');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = opencodeAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.opencode', 'commands', 'opsx-explore.md'));\n    });\n\n    it('should format file with description frontmatter', () => {\n      const output = opencodeAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n\n    it('should transform colon-based command references to hyphen-based', () => {\n      const contentWithCommands: CommandContent = {\n        ...sampleContent,\n        body: 'Use /opsx:new to start, then /opsx:apply to implement.',\n      };\n      const output = opencodeAdapter.formatFile(contentWithCommands);\n      expect(output).toContain('/opsx-new');\n      expect(output).toContain('/opsx-apply');\n      expect(output).not.toContain('/opsx:new');\n      expect(output).not.toContain('/opsx:apply');\n    });\n\n    it('should handle multiple command references in body', () => {\n      const contentWithMultipleCommands: CommandContent = {\n        ...sampleContent,\n        body: `/opsx:explore for ideas\n/opsx:new to create\n/opsx:continue to proceed\n/opsx:apply to implement`,\n      };\n      const output = opencodeAdapter.formatFile(contentWithMultipleCommands);\n      expect(output).toContain('/opsx-explore');\n      expect(output).toContain('/opsx-new');\n      expect(output).toContain('/opsx-continue');\n      expect(output).toContain('/opsx-apply');\n    });\n  });\n\n  describe('qoderAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(qoderAdapter.toolId).toBe('qoder');\n    });\n\n    it('should generate correct file path with nested opsx folder', () => {\n      const filePath = qoderAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.qoder', 'commands', 'opsx', 'explore.md'));\n    });\n\n    it('should format file with name, description, category, and tags', () => {\n      const output = qoderAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('name: OpenSpec Explore');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('category: Workflow');\n      expect(output).toContain('tags: [workflow, explore, experimental]');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n  });\n\n  describe('qwenAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(qwenAdapter.toolId).toBe('qwen');\n    });\n\n    it('should generate correct file path with .toml extension', () => {\n      const filePath = qwenAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.qwen', 'commands', 'opsx-explore.toml'));\n    });\n\n    it('should format file in TOML format', () => {\n      const output = qwenAdapter.formatFile(sampleContent);\n      expect(output).toContain('description = \"Enter explore mode for thinking\"');\n      expect(output).toContain('prompt = \"\"\"');\n      expect(output).toContain('This is the command body.');\n      expect(output).toContain('\"\"\"');\n    });\n  });\n\n  describe('piAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(piAdapter.toolId).toBe('pi');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = piAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.pi', 'prompts', 'opsx-explore.md'));\n    });\n\n    it('should generate correct file paths for different commands', () => {\n      expect(piAdapter.getFilePath('new')).toBe(path.join('.pi', 'prompts', 'opsx-new.md'));\n      expect(piAdapter.getFilePath('bulk-archive')).toBe(path.join('.pi', 'prompts', 'opsx-bulk-archive.md'));\n    });\n\n    it('should format file with description frontmatter', () => {\n      const output = piAdapter.formatFile(sampleContent);\n      expect(output).toContain('---\\n');\n      expect(output).toContain('description: Enter explore mode for thinking');\n      expect(output).toContain('---\\n\\n');\n      expect(output).toContain('This is the command body.');\n    });\n\n    it('should escape YAML special characters in description', () => {\n      const contentWithSpecialChars: CommandContent = {\n        ...sampleContent,\n        description: 'Fix: regression in \"auth\" feature',\n      };\n      const output = piAdapter.formatFile(contentWithSpecialChars);\n      expect(output).toContain('description: \"Fix: regression in \\\\\"auth\\\\\" feature\"');\n    });\n\n    it('should escape newlines in description', () => {\n      const contentWithNewline: CommandContent = {\n        ...sampleContent,\n        description: 'Line 1\\nLine 2',\n      };\n      const output = piAdapter.formatFile(contentWithNewline);\n      expect(output).toContain('description: \"Line 1\\\\nLine 2\"');\n    });\n  });\n\n  describe('roocodeAdapter', () => {\n    it('should have correct toolId', () => {\n      expect(roocodeAdapter.toolId).toBe('roocode');\n    });\n\n    it('should generate correct file path', () => {\n      const filePath = roocodeAdapter.getFilePath('explore');\n      expect(filePath).toBe(path.join('.roo', 'commands', 'opsx-explore.md'));\n    });\n\n    it('should format file with markdown header (no YAML frontmatter)', () => {\n      const output = roocodeAdapter.formatFile(sampleContent);\n      expect(output).toContain('# OpenSpec Explore');\n      expect(output).toContain('Enter explore mode for thinking');\n      expect(output).toContain('This is the command body.');\n      expect(output).not.toContain('---');\n    });\n  });\n\n  describe('cross-platform path handling', () => {\n    it('Claude adapter uses path.join for paths', () => {\n      // path.join handles platform-specific separators\n      const filePath = claudeAdapter.getFilePath('test');\n      // On any platform, path.join returns the correct separator\n      expect(filePath.split(path.sep)).toEqual(['.claude', 'commands', 'opsx', 'test.md']);\n    });\n\n    it('Cursor adapter uses path.join for paths', () => {\n      const filePath = cursorAdapter.getFilePath('test');\n      expect(filePath.split(path.sep)).toEqual(['.cursor', 'commands', 'opsx-test.md']);\n    });\n\n    it('Windsurf adapter uses path.join for paths', () => {\n      const filePath = windsurfAdapter.getFilePath('test');\n      expect(filePath.split(path.sep)).toEqual(['.windsurf', 'workflows', 'opsx-test.md']);\n    });\n\n    it('All adapters use path.join for paths', () => {\n      // Verify all adapters produce valid paths\n      const adapters = [\n        amazonQAdapter, antigravityAdapter, auggieAdapter, clineAdapter,\n        codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter,\n        crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter,\n        iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter,\n        qwenAdapter, roocodeAdapter\n      ];\n      for (const adapter of adapters) {\n        const filePath = adapter.getFilePath('test');\n        expect(filePath.length).toBeGreaterThan(0);\n        expect(filePath.includes(path.sep) || filePath.includes('.')).toBe(true);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/command-generation/generator.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { generateCommand, generateCommands } from '../../../src/core/command-generation/generator.js';\nimport { claudeAdapter } from '../../../src/core/command-generation/adapters/claude.js';\nimport { cursorAdapter } from '../../../src/core/command-generation/adapters/cursor.js';\nimport type { CommandContent, ToolCommandAdapter } from '../../../src/core/command-generation/types.js';\n\ndescribe('command-generation/generator', () => {\n  const sampleContent: CommandContent = {\n    id: 'explore',\n    name: 'OpenSpec Explore',\n    description: 'Enter explore mode',\n    category: 'Workflow',\n    tags: ['workflow'],\n    body: 'Command body here.',\n  };\n\n  describe('generateCommand', () => {\n    it('should generate command with path and content using Claude adapter', () => {\n      const result = generateCommand(sampleContent, claudeAdapter);\n\n      expect(result.path).toContain('.claude');\n      expect(result.path).toContain('explore.md');\n      expect(result.fileContent).toContain('name: OpenSpec Explore');\n      expect(result.fileContent).toContain('Command body here.');\n    });\n\n    it('should generate command with path and content using Cursor adapter', () => {\n      const result = generateCommand(sampleContent, cursorAdapter);\n\n      expect(result.path).toContain('.cursor');\n      expect(result.path).toContain('opsx-explore.md');\n      expect(result.fileContent).toContain('name: /opsx-explore');\n      expect(result.fileContent).toContain('id: opsx-explore');\n      expect(result.fileContent).toContain('Command body here.');\n    });\n\n    it('should use command id for path', () => {\n      const content: CommandContent = { ...sampleContent, id: 'custom-cmd' };\n      const result = generateCommand(content, claudeAdapter);\n\n      expect(result.path).toContain('custom-cmd.md');\n    });\n\n    it('should work with custom adapter', () => {\n      const customAdapter: ToolCommandAdapter = {\n        toolId: 'custom',\n        getFilePath: (id) => `.custom/${id}.txt`,\n        formatFile: (content) => `# ${content.name}\\n\\n${content.body}`,\n      };\n\n      const result = generateCommand(sampleContent, customAdapter);\n\n      expect(result.path).toBe('.custom/explore.txt');\n      expect(result.fileContent).toBe('# OpenSpec Explore\\n\\nCommand body here.');\n    });\n  });\n\n  describe('generateCommands', () => {\n    it('should generate multiple commands', () => {\n      const contents: CommandContent[] = [\n        { ...sampleContent, id: 'explore', name: 'Explore' },\n        { ...sampleContent, id: 'new', name: 'New' },\n        { ...sampleContent, id: 'apply', name: 'Apply' },\n      ];\n\n      const results = generateCommands(contents, claudeAdapter);\n\n      expect(results).toHaveLength(3);\n      expect(results[0].path).toContain('explore.md');\n      expect(results[1].path).toContain('new.md');\n      expect(results[2].path).toContain('apply.md');\n    });\n\n    it('should return empty array for empty input', () => {\n      const results = generateCommands([], claudeAdapter);\n      expect(results).toEqual([]);\n    });\n\n    it('should preserve order of input', () => {\n      const contents: CommandContent[] = [\n        { ...sampleContent, id: 'c', name: 'C' },\n        { ...sampleContent, id: 'a', name: 'A' },\n        { ...sampleContent, id: 'b', name: 'B' },\n      ];\n\n      const results = generateCommands(contents, claudeAdapter);\n\n      expect(results[0].path).toContain('c.md');\n      expect(results[1].path).toContain('a.md');\n      expect(results[2].path).toContain('b.md');\n    });\n\n    it('should generate each command independently', () => {\n      const contents: CommandContent[] = [\n        { id: 'a', name: 'A', description: 'DA', category: 'C1', tags: ['t1'], body: 'B1' },\n        { id: 'b', name: 'B', description: 'DB', category: 'C2', tags: ['t2'], body: 'B2' },\n      ];\n\n      const results = generateCommands(contents, claudeAdapter);\n\n      expect(results[0].fileContent).toContain('name: A');\n      expect(results[0].fileContent).toContain('B1');\n      expect(results[0].fileContent).not.toContain('name: B');\n\n      expect(results[1].fileContent).toContain('name: B');\n      expect(results[1].fileContent).toContain('B2');\n      expect(results[1].fileContent).not.toContain('name: A');\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/command-generation/registry.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js';\n\ndescribe('command-generation/registry', () => {\n  describe('get', () => {\n    it('should return Claude adapter for \"claude\"', () => {\n      const adapter = CommandAdapterRegistry.get('claude');\n      expect(adapter).toBeDefined();\n      expect(adapter?.toolId).toBe('claude');\n    });\n\n    it('should return Cursor adapter for \"cursor\"', () => {\n      const adapter = CommandAdapterRegistry.get('cursor');\n      expect(adapter).toBeDefined();\n      expect(adapter?.toolId).toBe('cursor');\n    });\n\n    it('should return Windsurf adapter for \"windsurf\"', () => {\n      const adapter = CommandAdapterRegistry.get('windsurf');\n      expect(adapter).toBeDefined();\n      expect(adapter?.toolId).toBe('windsurf');\n    });\n\n    it('should return undefined for unregistered tool', () => {\n      const adapter = CommandAdapterRegistry.get('unknown-tool');\n      expect(adapter).toBeUndefined();\n    });\n\n    it('should return undefined for empty string', () => {\n      const adapter = CommandAdapterRegistry.get('');\n      expect(adapter).toBeUndefined();\n    });\n  });\n\n  describe('getAll', () => {\n    it('should return array of all registered adapters', () => {\n      const adapters = CommandAdapterRegistry.getAll();\n      expect(Array.isArray(adapters)).toBe(true);\n      expect(adapters.length).toBeGreaterThanOrEqual(3); // At least Claude, Cursor, Windsurf\n    });\n\n    it('should include Claude, Cursor, and Windsurf adapters', () => {\n      const adapters = CommandAdapterRegistry.getAll();\n      const toolIds = adapters.map((a) => a.toolId);\n\n      expect(toolIds).toContain('claude');\n      expect(toolIds).toContain('cursor');\n      expect(toolIds).toContain('windsurf');\n    });\n  });\n\n  describe('has', () => {\n    it('should return true for registered tools', () => {\n      expect(CommandAdapterRegistry.has('claude')).toBe(true);\n      expect(CommandAdapterRegistry.has('cursor')).toBe(true);\n      expect(CommandAdapterRegistry.has('windsurf')).toBe(true);\n    });\n\n    it('should return false for unregistered tools', () => {\n      expect(CommandAdapterRegistry.has('unknown')).toBe(false);\n      expect(CommandAdapterRegistry.has('')).toBe(false);\n    });\n  });\n\n  describe('adapter functionality', () => {\n    it('registered adapters should have working getFilePath', () => {\n      const claudeAdapter = CommandAdapterRegistry.get('claude');\n      const cursorAdapter = CommandAdapterRegistry.get('cursor');\n      const windsurfAdapter = CommandAdapterRegistry.get('windsurf');\n\n      expect(claudeAdapter?.getFilePath('test')).toContain('.claude');\n      expect(cursorAdapter?.getFilePath('test')).toContain('.cursor');\n      expect(windsurfAdapter?.getFilePath('test')).toContain('.windsurf');\n    });\n\n    it('registered adapters should have working formatFile', () => {\n      const content = {\n        id: 'test',\n        name: 'Test',\n        description: 'Test desc',\n        category: 'Test',\n        tags: ['tag1'],\n        body: 'Body content',\n      };\n\n      // Tools that don't use YAML frontmatter (markdown headers or TOML or plain)\n      const noYamlFrontmatter = ['cline', 'kilocode', 'roocode', 'gemini', 'qwen'];\n\n      const adapters = CommandAdapterRegistry.getAll();\n      for (const adapter of adapters) {\n        const output = adapter.formatFile(content);\n        // All adapters should include the body content\n        expect(output).toContain('Body content');\n        // Only check for YAML frontmatter for tools that use it\n        if (!noYamlFrontmatter.includes(adapter.toolId)) {\n          expect(output).toContain('---');\n        }\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/command-generation/types.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport type { CommandContent, ToolCommandAdapter, GeneratedCommand } from '../../../src/core/command-generation/types.js';\n\ndescribe('command-generation/types', () => {\n  describe('CommandContent interface', () => {\n    it('should allow creating valid command content', () => {\n      const content: CommandContent = {\n        id: 'explore',\n        name: 'OpenSpec Explore',\n        description: 'Enter explore mode for thinking',\n        category: 'Workflow',\n        tags: ['workflow', 'explore'],\n        body: 'This is the command body content.',\n      };\n\n      expect(content.id).toBe('explore');\n      expect(content.name).toBe('OpenSpec Explore');\n      expect(content.description).toBe('Enter explore mode for thinking');\n      expect(content.category).toBe('Workflow');\n      expect(content.tags).toEqual(['workflow', 'explore']);\n      expect(content.body).toBe('This is the command body content.');\n    });\n\n    it('should allow empty tags array', () => {\n      const content: CommandContent = {\n        id: 'test',\n        name: 'Test',\n        description: 'Test command',\n        category: 'Test',\n        tags: [],\n        body: 'Body',\n      };\n\n      expect(content.tags).toEqual([]);\n    });\n  });\n\n  describe('ToolCommandAdapter interface contract', () => {\n    it('should implement adapter with getFilePath and formatFile', () => {\n      const mockAdapter: ToolCommandAdapter = {\n        toolId: 'test-tool',\n        getFilePath(commandId: string): string {\n          return `.test/${commandId}.md`;\n        },\n        formatFile(content: CommandContent): string {\n          return `---\\nname: ${content.name}\\n---\\n\\n${content.body}\\n`;\n        },\n      };\n\n      expect(mockAdapter.toolId).toBe('test-tool');\n      expect(mockAdapter.getFilePath('explore')).toBe('.test/explore.md');\n\n      const content: CommandContent = {\n        id: 'test',\n        name: 'Test Command',\n        description: 'Desc',\n        category: 'Cat',\n        tags: [],\n        body: 'Body content',\n      };\n\n      const formatted = mockAdapter.formatFile(content);\n      expect(formatted).toContain('name: Test Command');\n      expect(formatted).toContain('Body content');\n    });\n  });\n\n  describe('GeneratedCommand interface', () => {\n    it('should represent generated command output', () => {\n      const generated: GeneratedCommand = {\n        path: '.claude/commands/opsx/explore.md',\n        fileContent: '---\\nname: Test\\n---\\n\\nBody\\n',\n      };\n\n      expect(generated.path).toBe('.claude/commands/opsx/explore.md');\n      expect(generated.fileContent).toContain('name: Test');\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/commands/change-command.list.test.ts",
    "content": "import { describe, it, expect, beforeAll, afterAll } from 'vitest';\nimport { ChangeCommand } from '../../../src/commands/change.js';\nimport path from 'path';\nimport { promises as fs } from 'fs';\nimport os from 'os';\n\ndescribe('ChangeCommand.list', () => {\n  let cmd: ChangeCommand;\n  let tempRoot: string;\n  let originalCwd: string;\n\n  beforeAll(async () => {\n    cmd = new ChangeCommand();\n    originalCwd = process.cwd();\n    tempRoot = path.join(os.tmpdir(), `openspec-change-command-list-${Date.now()}`);\n    const changeDir = path.join(tempRoot, 'openspec', 'changes', 'demo');\n    await fs.mkdir(changeDir, { recursive: true });\n    const proposal = `# Change: Demo\\n\\n## Why\\nTest list.\\n\\n## What Changes\\n- **auth:** Add requirement`;\n    await fs.writeFile(path.join(changeDir, 'proposal.md'), proposal, 'utf-8');\n    await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\\n- [ ] Task 2\\n', 'utf-8');\n    process.chdir(tempRoot);\n  });\n\n  afterAll(async () => {\n    process.chdir(originalCwd);\n    await fs.rm(tempRoot, { recursive: true, force: true });\n  });\n\n  it('returns JSON with expected shape', async () => {\n    // Capture console output\n    const logs: string[] = [];\n    const origLog = console.log;\n    try {\n      console.log = (msg?: any, ...args: any[]) => {\n        logs.push([msg, ...args].filter(Boolean).join(' '));\n      };\n\n      await cmd.list({ json: true });\n\n      const output = logs.join('\\n');\n      const parsed = JSON.parse(output);\n      expect(Array.isArray(parsed)).toBe(true);\n      if (parsed.length > 0) {\n        const item = parsed[0];\n        expect(item).toHaveProperty('id');\n        expect(item).toHaveProperty('title');\n        expect(item).toHaveProperty('deltaCount');\n        expect(item).toHaveProperty('taskStatus');\n        expect(item.taskStatus).toHaveProperty('total');\n        expect(item.taskStatus).toHaveProperty('completed');\n      }\n    } finally {\n      console.log = origLog;\n    }\n  });\n\n  it('prints IDs by default and details with --long', async () => {\n    const logs: string[] = [];\n    const origLog = console.log;\n    try {\n      console.log = (msg?: any, ...args: any[]) => {\n        logs.push([msg, ...args].filter(Boolean).join(' '));\n      };\n      await cmd.list({});\n      const idsOnly = logs.join('\\n');\n      expect(idsOnly).toMatch(/\\w+/);\n      logs.length = 0;\n      await cmd.list({ long: true });\n      const longOut = logs.join('\\n');\n      expect(longOut).toMatch(/:\\s/);\n      expect(longOut).toMatch(/\\[deltas\\s\\d+\\]/);\n    } finally {\n      console.log = origLog;\n    }\n  });\n});\n"
  },
  {
    "path": "test/core/commands/change-command.show-validate.test.ts",
    "content": "import { describe, it, expect, beforeAll, afterAll } from 'vitest';\nimport { ChangeCommand } from '../../../src/commands/change.js';\nimport path from 'path';\nimport { promises as fs } from 'fs';\nimport os from 'os';\n\ndescribe('ChangeCommand.show/validate', () => {\n  let cmd: ChangeCommand;\n  let changeName: string;\n  let tempRoot: string;\n  let originalCwd: string;\n\n  beforeAll(async () => {\n    cmd = new ChangeCommand();\n    originalCwd = process.cwd();\n    tempRoot = path.join(os.tmpdir(), `openspec-change-command-${Date.now()}`);\n    const changesDir = path.join(tempRoot, 'openspec', 'changes', 'sample-change');\n    await fs.mkdir(changesDir, { recursive: true });\n    const proposal = `# Change: Sample Change\\n\\n## Why\\nConsistency in tests.\\n\\n## What Changes\\n- **auth:** Add requirement`;\n    await fs.writeFile(path.join(changesDir, 'proposal.md'), proposal, 'utf-8');\n    process.chdir(tempRoot);\n    changeName = 'sample-change';\n  });\n\n  afterAll(async () => {\n    process.chdir(originalCwd);\n    await fs.rm(tempRoot, { recursive: true, force: true });\n  });\n\n  it('show --json prints JSON including deltas', async () => {\n    const logs: string[] = [];\n    const origLog = console.log;\n    try {\n      console.log = (msg?: any, ...args: any[]) => {\n        logs.push([msg, ...args].filter(Boolean).join(' '));\n      };\n\n      await cmd.show(changeName, { json: true });\n\n      const output = logs.join('\\n');\n      const parsed = JSON.parse(output);\n      expect(parsed).toHaveProperty('deltas');\n      expect(Array.isArray(parsed.deltas)).toBe(true);\n    } finally {\n      console.log = origLog;\n    }\n  });\n\n  it('error when no change specified: prints available IDs', async () => {\n    const logsErr: string[] = [];\n    const origErr = console.error;\n    try {\n      console.error = (msg?: any, ...args: any[]) => {\n        logsErr.push([msg, ...args].filter(Boolean).join(' '));\n      };\n      await cmd.show(undefined as unknown as string, { json: false } as any);\n      // Should have set exit code and printed hint\n      expect(process.exitCode).toBe(1);\n      const errOut = logsErr.join('\\n');\n      expect(errOut).toMatch(/No change specified/);\n      expect(errOut).toMatch(/Available IDs/);\n    } finally {\n      console.error = origErr;\n      process.exitCode = 0;\n    }\n  });\n\n  it('show --json --requirements-only returns minimal object with deltas (deprecated alias)', async () => {\n    const logs: string[] = [];\n    const origLog = console.log;\n    try {\n      console.log = (msg?: any, ...args: any[]) => {\n        logs.push([msg, ...args].filter(Boolean).join(' '));\n      };\n\n      await cmd.show(changeName, { json: true, requirementsOnly: true });\n\n      const output = logs.join('\\n');\n      const parsed = JSON.parse(output);\n      expect(parsed).toHaveProperty('deltas');\n      expect(Array.isArray(parsed.deltas)).toBe(true);\n      if (parsed.deltas.length > 0) {\n        expect(parsed.deltas[0]).toHaveProperty('spec');\n        expect(parsed.deltas[0]).toHaveProperty('operation');\n        expect(parsed.deltas[0]).toHaveProperty('description');\n      }\n    } finally {\n      console.log = origLog;\n    }\n  });\n\n  it('validate --strict --json returns a report with valid boolean', async () => {\n    const logs: string[] = [];\n    const origLog = console.log;\n    try {\n      console.log = (msg?: any, ...args: any[]) => {\n        logs.push([msg, ...args].filter(Boolean).join(' '));\n      };\n\n      await cmd.validate(changeName, { strict: true, json: true });\n\n      const output = logs.join('\\n');\n      const parsed = JSON.parse(output);\n      expect(parsed).toHaveProperty('valid');\n      expect(parsed).toHaveProperty('issues');\n      expect(Array.isArray(parsed.issues)).toBe(true);\n    } finally {\n      console.log = origLog;\n    }\n  });\n});\n"
  },
  {
    "path": "test/core/completions/completion-provider.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\nimport { CompletionProvider } from '../../../src/core/completions/completion-provider.js';\n\ndescribe('CompletionProvider', () => {\n  let testDir: string;\n  let provider: CompletionProvider;\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);\n    await fs.mkdir(testDir, { recursive: true });\n    provider = new CompletionProvider(2000, testDir);\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('getChangeIds', () => {\n    it('should return empty array when no changes exist', async () => {\n      const changeIds = await provider.getChangeIds();\n      expect(changeIds).toEqual([]);\n    });\n\n    it('should return active change IDs', async () => {\n      // Create openspec/changes directory structure\n      const changesDir = path.join(testDir, 'openspec', 'changes');\n      await fs.mkdir(changesDir, { recursive: true });\n\n      // Create some changes\n      await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');\n\n      await fs.mkdir(path.join(changesDir, 'change-2'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'change-2', 'proposal.md'), '# Change 2');\n\n      const changeIds = await provider.getChangeIds();\n      expect(changeIds).toEqual(['change-1', 'change-2']);\n    });\n\n    it('should exclude archive directory', async () => {\n      const changesDir = path.join(testDir, 'openspec', 'changes');\n      await fs.mkdir(changesDir, { recursive: true });\n\n      // Create active change\n      await fs.mkdir(path.join(changesDir, 'active-change'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'active-change', 'proposal.md'), '# Active');\n\n      // Create archived change\n      await fs.mkdir(path.join(changesDir, 'archive', 'old-change'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'archive', 'old-change', 'proposal.md'), '# Old');\n\n      const changeIds = await provider.getChangeIds();\n      expect(changeIds).toEqual(['active-change']);\n    });\n\n    it('should cache results for the TTL duration', async () => {\n      const changesDir = path.join(testDir, 'openspec', 'changes');\n      await fs.mkdir(changesDir, { recursive: true });\n\n      await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');\n\n      // First call\n      const firstResult = await provider.getChangeIds();\n      expect(firstResult).toEqual(['change-1']);\n\n      // Add another change\n      await fs.mkdir(path.join(changesDir, 'change-2'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'change-2', 'proposal.md'), '# Change 2');\n\n      // Second call should return cached result (still only change-1)\n      const secondResult = await provider.getChangeIds();\n      expect(secondResult).toEqual(['change-1']);\n    });\n\n    it('should refresh cache after TTL expires', async () => {\n      // Use a very short TTL for testing\n      const shortTTLProvider = new CompletionProvider(50, testDir);\n\n      const changesDir = path.join(testDir, 'openspec', 'changes');\n      await fs.mkdir(changesDir, { recursive: true });\n\n      await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');\n\n      // First call\n      const firstResult = await shortTTLProvider.getChangeIds();\n      expect(firstResult).toEqual(['change-1']);\n\n      // Add another change\n      await fs.mkdir(path.join(changesDir, 'change-2'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'change-2', 'proposal.md'), '# Change 2');\n\n      // Wait for cache to expire\n      await new Promise(resolve => setTimeout(resolve, 60));\n\n      // Should now see both changes\n      const secondResult = await shortTTLProvider.getChangeIds();\n      expect(secondResult).toEqual(['change-1', 'change-2']);\n    });\n  });\n\n  describe('getSpecIds', () => {\n    it('should return empty array when no specs exist', async () => {\n      const specIds = await provider.getSpecIds();\n      expect(specIds).toEqual([]);\n    });\n\n    it('should return spec IDs', async () => {\n      const specsDir = path.join(testDir, 'openspec', 'specs');\n      await fs.mkdir(specsDir, { recursive: true });\n\n      // Create some specs\n      await fs.mkdir(path.join(specsDir, 'spec-1'), { recursive: true });\n      await fs.writeFile(path.join(specsDir, 'spec-1', 'spec.md'), '# Spec 1');\n\n      await fs.mkdir(path.join(specsDir, 'spec-2'), { recursive: true });\n      await fs.writeFile(path.join(specsDir, 'spec-2', 'spec.md'), '# Spec 2');\n\n      const specIds = await provider.getSpecIds();\n      expect(specIds).toEqual(['spec-1', 'spec-2']);\n    });\n\n    it('should cache results for the TTL duration', async () => {\n      const specsDir = path.join(testDir, 'openspec', 'specs');\n      await fs.mkdir(specsDir, { recursive: true });\n\n      await fs.mkdir(path.join(specsDir, 'spec-1'), { recursive: true });\n      await fs.writeFile(path.join(specsDir, 'spec-1', 'spec.md'), '# Spec 1');\n\n      // First call\n      const firstResult = await provider.getSpecIds();\n      expect(firstResult).toEqual(['spec-1']);\n\n      // Add another spec\n      await fs.mkdir(path.join(specsDir, 'spec-2'), { recursive: true });\n      await fs.writeFile(path.join(specsDir, 'spec-2', 'spec.md'), '# Spec 2');\n\n      // Second call should return cached result\n      const secondResult = await provider.getSpecIds();\n      expect(secondResult).toEqual(['spec-1']);\n    });\n\n    it('should refresh cache after TTL expires', async () => {\n      const shortTTLProvider = new CompletionProvider(50, testDir);\n\n      const specsDir = path.join(testDir, 'openspec', 'specs');\n      await fs.mkdir(specsDir, { recursive: true });\n\n      await fs.mkdir(path.join(specsDir, 'spec-1'), { recursive: true });\n      await fs.writeFile(path.join(specsDir, 'spec-1', 'spec.md'), '# Spec 1');\n\n      const firstResult = await shortTTLProvider.getSpecIds();\n      expect(firstResult).toEqual(['spec-1']);\n\n      // Add another spec\n      await fs.mkdir(path.join(specsDir, 'spec-2'), { recursive: true });\n      await fs.writeFile(path.join(specsDir, 'spec-2', 'spec.md'), '# Spec 2');\n\n      // Wait for cache to expire\n      await new Promise(resolve => setTimeout(resolve, 60));\n\n      const secondResult = await shortTTLProvider.getSpecIds();\n      expect(secondResult).toEqual(['spec-1', 'spec-2']);\n    });\n  });\n\n  describe('getAllIds', () => {\n    it('should return both change and spec IDs', async () => {\n      const changesDir = path.join(testDir, 'openspec', 'changes');\n      const specsDir = path.join(testDir, 'openspec', 'specs');\n      await fs.mkdir(changesDir, { recursive: true });\n      await fs.mkdir(specsDir, { recursive: true });\n\n      // Create a change\n      await fs.mkdir(path.join(changesDir, 'my-change'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'my-change', 'proposal.md'), '# Change');\n\n      // Create a spec\n      await fs.mkdir(path.join(specsDir, 'my-spec'), { recursive: true });\n      await fs.writeFile(path.join(specsDir, 'my-spec', 'spec.md'), '# Spec');\n\n      const result = await provider.getAllIds();\n      expect(result).toEqual({\n        changeIds: ['my-change'],\n        specIds: ['my-spec'],\n      });\n    });\n\n    it('should return empty arrays when no items exist', async () => {\n      const result = await provider.getAllIds();\n      expect(result).toEqual({\n        changeIds: [],\n        specIds: [],\n      });\n    });\n  });\n\n  describe('clearCache', () => {\n    it('should clear all cached data', async () => {\n      const changesDir = path.join(testDir, 'openspec', 'changes');\n      await fs.mkdir(changesDir, { recursive: true });\n\n      await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');\n\n      // Populate cache\n      await provider.getChangeIds();\n\n      // Clear cache\n      provider.clearCache();\n\n      // Add new change\n      await fs.mkdir(path.join(changesDir, 'change-2'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'change-2', 'proposal.md'), '# Change 2');\n\n      // Should see new data immediately\n      const result = await provider.getChangeIds();\n      expect(result).toEqual(['change-1', 'change-2']);\n    });\n  });\n\n  describe('getCacheStats', () => {\n    it('should report invalid cache when empty', () => {\n      const stats = provider.getCacheStats();\n      expect(stats.changeCache.valid).toBe(false);\n      expect(stats.specCache.valid).toBe(false);\n      expect(stats.changeCache.age).toBeUndefined();\n      expect(stats.specCache.age).toBeUndefined();\n    });\n\n    it('should report valid cache after data is fetched', async () => {\n      const changesDir = path.join(testDir, 'openspec', 'changes');\n      await fs.mkdir(changesDir, { recursive: true });\n\n      await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');\n\n      await provider.getChangeIds();\n\n      const stats = provider.getCacheStats();\n      expect(stats.changeCache.valid).toBe(true);\n      expect(stats.changeCache.age).toBeDefined();\n      expect(stats.changeCache.age).toBeLessThan(100);\n    });\n\n    it('should report invalid cache after TTL expires', async () => {\n      const shortTTLProvider = new CompletionProvider(50, testDir);\n\n      const changesDir = path.join(testDir, 'openspec', 'changes');\n      await fs.mkdir(changesDir, { recursive: true });\n\n      await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });\n      await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');\n\n      await shortTTLProvider.getChangeIds();\n\n      // Wait for cache to expire\n      await new Promise(resolve => setTimeout(resolve, 60));\n\n      const stats = shortTTLProvider.getCacheStats();\n      expect(stats.changeCache.valid).toBe(false);\n      expect(stats.changeCache.age).toBeGreaterThan(50);\n    });\n  });\n\n  describe('constructor', () => {\n    it('should use default TTL of 2000ms', async () => {\n      const defaultProvider = new CompletionProvider();\n      expect(defaultProvider).toBeDefined();\n      // We can verify this behavior by checking cache stats after waiting\n    });\n\n    it('should accept custom TTL', async () => {\n      const customProvider = new CompletionProvider(5000, testDir);\n      expect(customProvider).toBeDefined();\n    });\n\n    it('should use process.cwd() as default project root', () => {\n      const defaultProvider = new CompletionProvider();\n      expect(defaultProvider).toBeDefined();\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/completions/generators/bash-generator.test.ts",
    "content": "import { describe, it, expect, beforeEach } from 'vitest';\nimport { BashGenerator } from '../../../../src/core/completions/generators/bash-generator.js';\nimport { CommandDefinition } from '../../../../src/core/completions/types.js';\n\ndescribe('BashGenerator', () => {\n  let generator: BashGenerator;\n\n  beforeEach(() => {\n    generator = new BashGenerator();\n  });\n\n  describe('interface compliance', () => {\n    it('should have shell property set to \"bash\"', () => {\n      expect(generator.shell).toBe('bash');\n    });\n\n    it('should implement generate method', () => {\n      expect(typeof generator.generate).toBe('function');\n    });\n  });\n\n  describe('generate', () => {\n    it('should generate valid bash completion script with header', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize OpenSpec',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('# Bash completion script for OpenSpec CLI');\n      expect(script).toContain('_openspec_completion() {');\n      expect(script).toContain('local cur prev words cword');\n      expect(script).toContain('_init_completion -n : || return');\n    });\n\n    it('should include all commands in the command list', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize OpenSpec',\n          flags: [],\n        },\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [],\n        },\n        {\n          name: 'show',\n          description: 'Show a spec',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('init');\n      expect(script).toContain('validate');\n      expect(script).toContain('show');\n    });\n\n    it('should handle commands with flags without short options', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [\n            {\n              name: 'strict',\n              description: 'Enable strict mode',\n            },\n            {\n              name: 'json',\n              description: 'Output as JSON',\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('--strict');\n      expect(script).toContain('--json');\n    });\n\n    it('should handle flags with short options', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show',\n          description: 'Show a spec',\n          flags: [\n            {\n              name: 'requirement',\n              short: 'r',\n              description: 'Show specific requirement',\n              takesValue: true,\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('-r');\n      expect(script).toContain('--requirement');\n    });\n\n    it('should handle boolean flags vs value-taking flags', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [\n            {\n              name: 'strict',\n              description: 'Enable strict mode',\n            },\n            {\n              name: 'output',\n              description: 'Output file',\n              takesValue: true,\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('--strict');\n      expect(script).toContain('--output');\n    });\n\n    it('should handle flags with enum values', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [\n            {\n              name: 'type',\n              description: 'Specify item type',\n              takesValue: true,\n              values: ['change', 'spec'],\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('--type');\n      expect(script).toContain('change');\n      expect(script).toContain('spec');\n    });\n\n    it('should handle flags with takesValue but no specific values', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [\n            {\n              name: 'concurrency',\n              description: 'Max concurrent validations',\n              takesValue: true,\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('--concurrency');\n    });\n\n    it('should handle commands with subcommands', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'change',\n          description: 'Manage changes',\n          flags: [],\n          subcommands: [\n            {\n              name: 'show',\n              description: 'Show a change',\n              flags: [],\n            },\n            {\n              name: 'list',\n              description: 'List changes',\n              flags: [],\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('change)');\n      expect(script).toContain('show');\n      expect(script).toContain('list');\n    });\n\n    it('should offer parent flags when command has both flags and subcommands', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'config',\n          description: 'Manage configuration',\n          flags: [\n            {\n              name: 'scope',\n              short: 's',\n              description: 'Configuration scope',\n            },\n            {\n              name: 'json',\n              description: 'Output as JSON',\n            },\n          ],\n          subcommands: [\n            {\n              name: 'set',\n              description: 'Set a config value',\n              flags: [],\n            },\n            {\n              name: 'get',\n              description: 'Get a config value',\n              flags: [],\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should check for flag prefix before offering subcommands\n      expect(script).toContain('if [[ \"$cur\" == -* ]]; then');\n      // Should include parent command flags\n      expect(script).toContain('-s');\n      expect(script).toContain('--scope');\n      expect(script).toContain('--json');\n      // Should also include subcommands\n      expect(script).toContain('set');\n      expect(script).toContain('get');\n    });\n\n    it('should handle positional arguments for change-id', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'archive',\n          description: 'Archive a change',\n          acceptsPositional: true,\n          positionalType: 'change-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('_openspec_complete_changes');\n    });\n\n    it('should handle positional arguments for spec-id', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show-spec',\n          description: 'Show a spec',\n          acceptsPositional: true,\n          positionalType: 'spec-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('_openspec_complete_specs');\n    });\n\n    it('should handle positional arguments for change-or-spec-id', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show',\n          description: 'Show an item',\n          acceptsPositional: true,\n          positionalType: 'change-or-spec-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('_openspec_complete_items');\n    });\n\n    it('should handle positional arguments for shell', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'generate',\n          description: 'Generate completions',\n          acceptsPositional: true,\n          positionalType: 'shell',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('zsh');\n      expect(script).toContain('bash');\n      expect(script).toContain('fish');\n      expect(script).toContain('powershell');\n    });\n\n    it('should handle positional arguments for paths', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize OpenSpec',\n          acceptsPositional: true,\n          positionalType: 'path',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('compgen -f');\n    });\n\n    it('should generate dynamic completion helper for changes', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'archive',\n          description: 'Archive a change',\n          acceptsPositional: true,\n          positionalType: 'change-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('_openspec_complete_changes() {');\n      expect(script).toContain('openspec __complete changes 2>/dev/null');\n      expect(script).toContain('cut -f1');\n      expect(script).toContain('COMPREPLY=');\n    });\n\n    it('should generate dynamic completion helper for specs', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show-spec',\n          description: 'Show a spec',\n          acceptsPositional: true,\n          positionalType: 'spec-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('_openspec_complete_specs() {');\n      expect(script).toContain('openspec __complete specs 2>/dev/null');\n      expect(script).toContain('cut -f1');\n    });\n\n    it('should generate dynamic completion helper for items (changes and specs)', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show',\n          description: 'Show an item',\n          acceptsPositional: true,\n          positionalType: 'change-or-spec-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('_openspec_complete_items() {');\n      expect(script).toContain('openspec __complete changes 2>/dev/null');\n      expect(script).toContain('openspec __complete specs 2>/dev/null');\n    });\n\n    it('should handle complex nested subcommands with flags', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'spec',\n          description: 'Manage specs',\n          flags: [],\n          subcommands: [\n            {\n              name: 'validate',\n              description: 'Validate a spec',\n              acceptsPositional: true,\n              positionalType: 'spec-id',\n              flags: [\n                {\n                  name: 'strict',\n                  description: 'Enable strict mode',\n                },\n                {\n                  name: 'json',\n                  description: 'Output as JSON',\n                },\n              ],\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('spec)');\n      expect(script).toContain('validate');\n      expect(script).toContain('--strict');\n      expect(script).toContain('--json');\n      expect(script).toContain('_openspec_complete_specs');\n    });\n\n    it('should generate script that ends with complete registration', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script.trim().endsWith('complete -F _openspec_completion openspec')).toBe(true);\n    });\n\n    it('should handle empty command list', () => {\n      const commands: CommandDefinition[] = [];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('# Bash completion script');\n      expect(script).toContain('_openspec_completion() {');\n      expect(script).toContain('complete -F _openspec_completion openspec');\n    });\n\n    it('should handle commands with no flags', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'view',\n          description: 'Display dashboard',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('view)');\n    });\n  });\n\n  describe('security - command injection prevention', () => {\n    it('should escape command names with shell metacharacters', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'test',\n          description: 'Test command',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Normal command name should be in the script\n      expect(script).toContain('test');\n    });\n\n    it('should escape dollar signs in command names', () => {\n      // This tests that if a command name somehow contained $, it would be escaped\n      // In practice, command names are validated, but the escaping provides defense in depth\n      const maliciousName = 'test$var';\n      const commands: CommandDefinition[] = [\n        {\n          name: maliciousName,\n          description: 'Test',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should escape the dollar sign\n      expect(script).toContain('test\\\\$var');\n    });\n\n    it('should escape backticks in command names', () => {\n      const maliciousName = 'test`cmd`';\n      const commands: CommandDefinition[] = [\n        {\n          name: maliciousName,\n          description: 'Test',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should escape backticks\n      expect(script).toContain('\\\\`');\n    });\n\n    it('should escape double quotes in command names', () => {\n      const maliciousName = 'test\"quoted\"';\n      const commands: CommandDefinition[] = [\n        {\n          name: maliciousName,\n          description: 'Test',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should escape double quotes\n      expect(script).toContain('\\\\\"');\n    });\n\n    it('should escape backslashes in command names', () => {\n      const maliciousName = 'test\\\\path';\n      const commands: CommandDefinition[] = [\n        {\n          name: maliciousName,\n          description: 'Test',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should escape backslashes\n      expect(script).toContain('\\\\\\\\');\n    });\n\n    it('should escape subcommand names with shell metacharacters', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'parent',\n          description: 'Parent command',\n          flags: [],\n          subcommands: [\n            {\n              name: 'sub$cmd',\n              description: 'Subcommand with metacharacter',\n              flags: [],\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should escape metacharacters in subcommand names\n      expect(script).toContain('sub\\\\$cmd');\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/completions/generators/fish-generator.test.ts",
    "content": "import { describe, it, expect, beforeEach } from 'vitest';\nimport { FishGenerator } from '../../../../src/core/completions/generators/fish-generator.js';\nimport { CommandDefinition } from '../../../../src/core/completions/types.js';\n\ndescribe('FishGenerator', () => {\n  let generator: FishGenerator;\n\n  beforeEach(() => {\n    generator = new FishGenerator();\n  });\n\n  describe('interface compliance', () => {\n    it('should have shell property set to \"fish\"', () => {\n      expect(generator.shell).toBe('fish');\n    });\n\n    it('should implement generate method', () => {\n      expect(typeof generator.generate).toBe('function');\n    });\n  });\n\n  describe('generate', () => {\n    it('should generate valid fish completion script with header', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize OpenSpec',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('# Fish completion script for OpenSpec CLI');\n      expect(script).toContain('function __fish_openspec');\n    });\n\n    it('should generate helper functions for Fish', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize OpenSpec',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('function __fish_openspec_using_subcommand');\n      expect(script).toContain('function __fish_openspec_no_subcommand');\n      expect(script).toContain('commandline -opc');\n    });\n\n    it('should include all commands with descriptions', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize OpenSpec',\n          flags: [],\n        },\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [],\n        },\n        {\n          name: 'show',\n          description: 'Show a spec',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"complete -c openspec\");\n      expect(script).toContain(\"-a 'init'\");\n      expect(script).toContain(\"'Initialize OpenSpec'\");\n      expect(script).toContain(\"-a 'validate'\");\n      expect(script).toContain(\"'Validate specs'\");\n      expect(script).toContain(\"-a 'show'\");\n      expect(script).toContain(\"'Show a spec'\");\n    });\n\n    it('should handle commands with flags without short options', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [\n            {\n              name: 'strict',\n              description: 'Enable strict mode',\n            },\n            {\n              name: 'json',\n              description: 'Output as JSON',\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"-l strict\");\n      expect(script).toContain(\"'Enable strict mode'\");\n      expect(script).toContain(\"-l json\");\n      expect(script).toContain(\"'Output as JSON'\");\n    });\n\n    it('should handle flags with short options', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show',\n          description: 'Show a spec',\n          flags: [\n            {\n              name: 'requirement',\n              short: 'r',\n              description: 'Show specific requirement',\n              takesValue: true,\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"-s r\");\n      expect(script).toContain(\"-l requirement\");\n      expect(script).toContain(\"'Show specific requirement'\");\n      expect(script).toContain(\"-r\");\n    });\n\n    it('should use -r flag for flags that require values', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [\n            {\n              name: 'output',\n              description: 'Output file',\n              takesValue: true,\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"-l output\");\n      expect(script).toContain(\"-r\");\n    });\n\n    it('should not use -r flag for boolean flags', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [\n            {\n              name: 'strict',\n              description: 'Enable strict mode',\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      const lines = script.split('\\n');\n      const strictLine = lines.find(line => line.includes('-l strict'));\n\n      expect(strictLine).toBeDefined();\n      expect(strictLine).not.toContain(' -r');\n    });\n\n    it('should handle flags with enum values', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [\n            {\n              name: 'type',\n              description: 'Specify item type',\n              takesValue: true,\n              values: ['change', 'spec'],\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"-l type\");\n      expect(script).toContain(\"change\");\n      expect(script).toContain(\"spec\");\n    });\n\n    it('should handle commands with subcommands', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'change',\n          description: 'Manage changes',\n          flags: [],\n          subcommands: [\n            {\n              name: 'show',\n              description: 'Show a change',\n              flags: [],\n            },\n            {\n              name: 'list',\n              description: 'List changes',\n              flags: [],\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"'change'\");\n      expect(script).toContain(\"'show'\");\n      expect(script).toContain(\"'list'\");\n      expect(script).toContain(\"__fish_openspec_using_subcommand change\");\n    });\n\n    it('should handle positional arguments for change-id', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'archive',\n          description: 'Archive a change',\n          acceptsPositional: true,\n          positionalType: 'change-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('__fish_openspec_changes');\n    });\n\n    it('should handle positional arguments for spec-id', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show-spec',\n          description: 'Show a spec',\n          acceptsPositional: true,\n          positionalType: 'spec-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('__fish_openspec_specs');\n    });\n\n    it('should handle positional arguments for change-or-spec-id', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show',\n          description: 'Show an item',\n          acceptsPositional: true,\n          positionalType: 'change-or-spec-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('__fish_openspec_items');\n    });\n\n    it('should handle positional arguments for shell with inline values', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'generate',\n          description: 'Generate completions',\n          acceptsPositional: true,\n          positionalType: 'shell',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('zsh');\n      expect(script).toContain('bash');\n      expect(script).toContain('fish');\n      expect(script).toContain('powershell');\n    });\n\n    it('should generate dynamic completion helper for changes', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'archive',\n          description: 'Archive a change',\n          acceptsPositional: true,\n          positionalType: 'change-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('function __fish_openspec_changes');\n      expect(script).toContain('openspec __complete changes 2>/dev/null');\n      expect(script).toContain('while read -l id desc');\n      expect(script).toContain('printf');\n    });\n\n    it('should generate dynamic completion helper for specs', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show-spec',\n          description: 'Show a spec',\n          acceptsPositional: true,\n          positionalType: 'spec-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('function __fish_openspec_specs');\n      expect(script).toContain('openspec __complete specs 2>/dev/null');\n    });\n\n    it('should generate dynamic completion helper for items', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show',\n          description: 'Show an item',\n          acceptsPositional: true,\n          positionalType: 'change-or-spec-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('function __fish_openspec_items');\n      expect(script).toContain('__fish_openspec_changes');\n      expect(script).toContain('__fish_openspec_specs');\n    });\n\n    it('should escape single quotes in descriptions', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'test',\n          description: \"Test with 'quotes'\",\n          flags: [\n            {\n              name: 'flag',\n              description: \"Special chars: 'quotes'\",\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"\\\\'quotes\\\\'\");\n    });\n\n    it('should handle complex nested subcommands with flags', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'spec',\n          description: 'Manage specs',\n          flags: [],\n          subcommands: [\n            {\n              name: 'validate',\n              description: 'Validate a spec',\n              acceptsPositional: true,\n              positionalType: 'spec-id',\n              flags: [\n                {\n                  name: 'strict',\n                  description: 'Enable strict mode',\n                },\n                {\n                  name: 'json',\n                  description: 'Output as JSON',\n                },\n              ],\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"'spec'\");\n      expect(script).toContain(\"'validate'\");\n      expect(script).toContain(\"-l strict\");\n      expect(script).toContain(\"-l json\");\n      expect(script).toContain('__fish_openspec_specs');\n    });\n\n    it('should handle empty command list', () => {\n      const commands: CommandDefinition[] = [];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('# Fish completion script');\n      expect(script).toContain('function __fish_openspec');\n    });\n\n    it('should handle commands with no flags', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'view',\n          description: 'Display dashboard',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"'view'\");\n      expect(script).toContain(\"'Display dashboard'\");\n    });\n  });\n\n  describe('security - command injection prevention', () => {\n    it('should escape $() command substitution in descriptions', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'test',\n          description: 'Test command $(curl evil.com)',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should contain escaped dollar signs to prevent command substitution\n      expect(script).toContain('\\\\$');\n      // Should have backslash before $( to escape it\n      expect(script).toMatch(/\\\\\\$\\(curl/);\n    });\n\n    it('should escape backticks in descriptions', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'test',\n          description: 'Test command `whoami`',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should not contain unescaped backticks\n      expect(script).not.toMatch(/`whoami`/);\n      // Should contain escaped version\n      expect(script).toContain('\\\\`');\n    });\n\n    it('should escape dollar signs in descriptions', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'test',\n          description: 'Test with $variable',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should escape dollar signs\n      expect(script).toContain('\\\\$');\n    });\n\n    it('should escape single quotes in descriptions', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'test',\n          description: \"Test with 'quotes'\",\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should escape single quotes\n      expect(script).toContain(\"\\\\'\");\n    });\n\n    it('should escape backslashes in descriptions', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'test',\n          description: 'Test with \\\\ backslash',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should contain escaped backslashes\n      expect(script).toContain('\\\\\\\\');\n    });\n\n    it('should handle multiple shell metacharacters together', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'test',\n          description: \"Dangerous: $(rm -rf /) `cat /etc/passwd` $HOME 'quoted'\",\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      // Should contain escaped versions of dangerous patterns\n      expect(script).toContain('\\\\$');  // Escaped dollar signs\n      expect(script).toContain('\\\\`');  // Escaped backticks\n      expect(script).toContain(\"\\\\'\");  // Escaped single quotes\n\n      // The escaped patterns should be present (backslash before dangerous chars)\n      expect(script).toMatch(/\\\\\\$\\(/);  // \\$( instead of $(\n      expect(script).toMatch(/\\\\\\`cat/);  // \\`cat instead of `cat\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/completions/generators/powershell-generator.test.ts",
    "content": "import { describe, it, expect, beforeEach } from 'vitest';\nimport { PowerShellGenerator } from '../../../../src/core/completions/generators/powershell-generator.js';\nimport { CommandDefinition } from '../../../../src/core/completions/types.js';\n\ndescribe('PowerShellGenerator', () => {\n\tlet generator: PowerShellGenerator;\n\n\tbeforeEach(() => {\n\t\tgenerator = new PowerShellGenerator();\n\t});\n\n\tdescribe('interface compliance', () => {\n\t\tit('should have shell property set to \"powershell\"', () => {\n\t\t\texpect(generator.shell).toBe('powershell');\n\t\t});\n\n\t\tit('should implement generate method', () => {\n\t\t\texpect(typeof generator.generate).toBe('function');\n\t\t});\n\t});\n\n\tdescribe('generate', () => {\n\t\tit('should generate valid PowerShell completion script with header', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'init',\n\t\t\t\t\tdescription: 'Initialize OpenSpec',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('# PowerShell completion script for OpenSpec CLI');\n\t\t\texpect(script).toContain('$openspecCompleter = {');\n\t\t\texpect(script).toContain('Register-ArgumentCompleter');\n\t\t});\n\n\t\tit('should register argument completer for openspec command', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'init',\n\t\t\t\t\tdescription: 'Initialize OpenSpec',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('Register-ArgumentCompleter -CommandName openspec');\n\t\t\texpect(script).toContain('-ScriptBlock $openspecCompleter');\n\t\t});\n\n\t\tit('should include all commands with descriptions', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'init',\n\t\t\t\t\tdescription: 'Initialize OpenSpec',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname: 'validate',\n\t\t\t\t\tdescription: 'Validate specs',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname: 'show',\n\t\t\t\t\tdescription: 'Show a spec',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('\"init\"');\n\t\t\texpect(script).toContain('Initialize OpenSpec');\n\t\t\texpect(script).toContain('\"validate\"');\n\t\t\texpect(script).toContain('Validate specs');\n\t\t\texpect(script).toContain('\"show\"');\n\t\t\texpect(script).toContain('Show a spec');\n\t\t});\n\n\t\tit('should use CompletionResult objects for completions', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'init',\n\t\t\t\t\tdescription: 'Initialize OpenSpec',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('[System.Management.Automation.CompletionResult]::new(');\n\t\t});\n\n\t\tit('should handle commands with flags without short options', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'validate',\n\t\t\t\t\tdescription: 'Validate specs',\n\t\t\t\t\tflags: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'strict',\n\t\t\t\t\t\t\tdescription: 'Enable strict mode',\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'json',\n\t\t\t\t\t\t\tdescription: 'Output as JSON',\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('--strict');\n\t\t\texpect(script).toContain('Enable strict mode');\n\t\t\texpect(script).toContain('--json');\n\t\t\texpect(script).toContain('Output as JSON');\n\t\t});\n\n\t\tit('should handle flags with short options', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'show',\n\t\t\t\t\tdescription: 'Show a spec',\n\t\t\t\t\tflags: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'requirement',\n\t\t\t\t\t\t\tshort: 'r',\n\t\t\t\t\t\t\tdescription: 'Show specific requirement',\n\t\t\t\t\t\t\ttakesValue: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('-r');\n\t\t\texpect(script).toContain('--requirement');\n\t\t\texpect(script).toContain('Show specific requirement');\n\t\t});\n\n\t\tit('should handle boolean flags vs value-taking flags', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'validate',\n\t\t\t\t\tdescription: 'Validate specs',\n\t\t\t\t\tflags: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'strict',\n\t\t\t\t\t\t\tdescription: 'Enable strict mode',\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'output',\n\t\t\t\t\t\t\tdescription: 'Output file',\n\t\t\t\t\t\t\ttakesValue: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('--strict');\n\t\t\texpect(script).toContain('--output');\n\t\t\texpect(script).toContain('Enable strict mode');\n\t\t\texpect(script).toContain('Output file');\n\t\t});\n\n\t\tit('should handle flags with enum values', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'validate',\n\t\t\t\t\tdescription: 'Validate specs',\n\t\t\t\t\tflags: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'type',\n\t\t\t\t\t\t\tdescription: 'Specify item type',\n\t\t\t\t\t\t\ttakesValue: true,\n\t\t\t\t\t\t\tvalues: ['change', 'spec'],\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('--type');\n\t\t\texpect(script).toContain('change');\n\t\t\texpect(script).toContain('spec');\n\t\t});\n\n\t\tit('should handle commands with subcommands', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'change',\n\t\t\t\t\tdescription: 'Manage changes',\n\t\t\t\t\tflags: [],\n\t\t\t\t\tsubcommands: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'show',\n\t\t\t\t\t\t\tdescription: 'Show a change',\n\t\t\t\t\t\t\tflags: [],\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'list',\n\t\t\t\t\t\t\tdescription: 'List changes',\n\t\t\t\t\t\t\tflags: [],\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('\"change\"');\n\t\t\texpect(script).toContain('\"show\"');\n\t\t\texpect(script).toContain('\"list\"');\n\t\t\texpect(script).toContain('Manage changes');\n\t\t\texpect(script).toContain('Show a change');\n\t\t\texpect(script).toContain('List changes');\n\t\t});\n\n\t\tit('should offer parent flags when command has both flags and subcommands', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'config',\n\t\t\t\t\tdescription: 'Manage configuration',\n\t\t\t\t\tflags: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'scope',\n\t\t\t\t\t\t\tshort: 's',\n\t\t\t\t\t\t\tdescription: 'Configuration scope',\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'json',\n\t\t\t\t\t\t\tdescription: 'Output as JSON',\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tsubcommands: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'set',\n\t\t\t\t\t\t\tdescription: 'Set a config value',\n\t\t\t\t\t\t\tflags: [],\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'get',\n\t\t\t\t\t\t\tdescription: 'Get a config value',\n\t\t\t\t\t\t\tflags: [],\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\t// Should check for flag prefix before offering subcommands\n\t\t\texpect(script).toContain('if ($wordToComplete -like \"-*\")');\n\t\t\t// Should include parent command flags\n\t\t\texpect(script).toContain('-s');\n\t\t\texpect(script).toContain('--scope');\n\t\t\texpect(script).toContain('--json');\n\t\t\t// Should also include subcommands\n\t\t\texpect(script).toContain('\"set\"');\n\t\t\texpect(script).toContain('\"get\"');\n\t\t});\n\n\t\tit('should handle positional arguments for change-id', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'archive',\n\t\t\t\t\tdescription: 'Archive a change',\n\t\t\t\t\tacceptsPositional: true,\n\t\t\t\t\tpositionalType: 'change-id',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('Get-OpenSpecChanges');\n\t\t});\n\n\t\tit('should handle positional arguments for spec-id', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'show-spec',\n\t\t\t\t\tdescription: 'Show a spec',\n\t\t\t\t\tacceptsPositional: true,\n\t\t\t\t\tpositionalType: 'spec-id',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('Get-OpenSpecSpecs');\n\t\t});\n\n\t\tit('should handle positional arguments for change-or-spec-id', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'show',\n\t\t\t\t\tdescription: 'Show an item',\n\t\t\t\t\tacceptsPositional: true,\n\t\t\t\t\tpositionalType: 'change-or-spec-id',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('Get-OpenSpecChanges');\n\t\t\texpect(script).toContain('Get-OpenSpecSpecs');\n\t\t});\n\n\t\tit('should handle positional arguments for shell with inline values', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'generate',\n\t\t\t\t\tdescription: 'Generate completions',\n\t\t\t\t\tacceptsPositional: true,\n\t\t\t\t\tpositionalType: 'shell',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('zsh');\n\t\t\texpect(script).toContain('bash');\n\t\t\texpect(script).toContain('fish');\n\t\t\texpect(script).toContain('powershell');\n\t\t});\n\n\t\tit('should not include path completion helpers (PowerShell handles natively)', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'init',\n\t\t\t\t\tdescription: 'Initialize OpenSpec',\n\t\t\t\t\tacceptsPositional: true,\n\t\t\t\t\tpositionalType: 'path',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\t// PowerShell handles path completion natively, so we just check the command is present\n\t\t\texpect(script).toContain('\"init\"');\n\t\t});\n\n\t\tit('should generate dynamic completion helper for changes', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'archive',\n\t\t\t\t\tdescription: 'Archive a change',\n\t\t\t\t\tacceptsPositional: true,\n\t\t\t\t\tpositionalType: 'change-id',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('function Get-OpenSpecChanges');\n\t\t\texpect(script).toContain('openspec __complete changes 2>$null');\n\t\t\texpect(script).toContain('-split');\n\t\t});\n\n\t\tit('should generate dynamic completion helper for specs', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'show-spec',\n\t\t\t\t\tdescription: 'Show a spec',\n\t\t\t\t\tacceptsPositional: true,\n\t\t\t\t\tpositionalType: 'spec-id',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('function Get-OpenSpecSpecs');\n\t\t\texpect(script).toContain('openspec __complete specs 2>$null');\n\t\t});\n\n\t\tit('should escape double quotes in descriptions', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'test',\n\t\t\t\t\tdescription: 'Test with \"quotes\"',\n\t\t\t\t\tflags: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'flag',\n\t\t\t\t\t\t\tdescription: 'Special chars: \"quotes\"',\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\t// PowerShell escapes double quotes by doubling them\n\t\t\texpect(script).toContain('\"\"quotes\"\"');\n\t\t});\n\n\t\tit('should handle complex nested subcommands with flags', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'spec',\n\t\t\t\t\tdescription: 'Manage specs',\n\t\t\t\t\tflags: [],\n\t\t\t\t\tsubcommands: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'validate',\n\t\t\t\t\t\t\tdescription: 'Validate a spec',\n\t\t\t\t\t\t\tacceptsPositional: true,\n\t\t\t\t\t\t\tpositionalType: 'spec-id',\n\t\t\t\t\t\t\tflags: [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tname: 'strict',\n\t\t\t\t\t\t\t\t\tdescription: 'Enable strict mode',\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tname: 'json',\n\t\t\t\t\t\t\t\t\tdescription: 'Output as JSON',\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('\"spec\"');\n\t\t\texpect(script).toContain('\"validate\"');\n\t\t\texpect(script).toContain('--strict');\n\t\t\texpect(script).toContain('--json');\n\t\t\texpect(script).toContain('Get-OpenSpecSpecs');\n\t\t});\n\n\t\tit('should not emit trailing commas in @() arrays', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'config',\n\t\t\t\t\tdescription: 'Manage configuration',\n\t\t\t\t\tflags: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'scope',\n\t\t\t\t\t\t\tshort: 's',\n\t\t\t\t\t\t\tdescription: 'Configuration scope',\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tsubcommands: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname: 'get',\n\t\t\t\t\t\t\tdescription: 'Get a config value',\n\t\t\t\t\t\t\tflags: [],\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\t// PowerShell array literals (@(...)) can't have a trailing comma on the last element.\n\t\t\texpect(script).not.toMatch(/\\},\\s*\\r?\\n\\s*\\)/);\n\t\t});\n\n\t\tit('should handle empty command list', () => {\n\t\t\tconst commands: CommandDefinition[] = [];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('# PowerShell completion script');\n\t\t\texpect(script).toContain('$openspecCompleter = {');\n\t\t\texpect(script).toContain('Register-ArgumentCompleter');\n\t\t});\n\n\t\tit('should handle commands with no flags', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'view',\n\t\t\t\t\tdescription: 'Display dashboard',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('\"view\"');\n\t\t\texpect(script).toContain('Display dashboard');\n\t\t});\n\n\t\tit('should generate helper function that splits on tab character', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'archive',\n\t\t\t\t\tdescription: 'Archive a change',\n\t\t\t\t\tacceptsPositional: true,\n\t\t\t\t\tpositionalType: 'change-id',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\texpect(script).toContain('function Get-OpenSpecChanges');\n\t\t\t// PowerShell uses -split with \\\\t for tab character\n\t\t\texpect(script).toContain('-split');\n\t\t\texpect(script).toContain('[0]');\n\t\t});\n\t});\n\n\tdescribe('security - command injection prevention', () => {\n\t\tit('should escape $() subexpressions in descriptions', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'test',\n\t\t\t\t\tdescription: 'Test command $(Get-Process)',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\t// Should contain escaped version (backtick before $)\n\t\t\texpect(script).toContain('`$');\n\t\t\t// Should have backtick before $( to escape it\n\t\t\texpect(script).toMatch(/`\\$\\(Get-Process\\)/);\n\t\t});\n\n\t\tit('should escape backticks in descriptions', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'test',\n\t\t\t\t\tdescription: 'Test with `n newline escape',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\t// Should escape backticks (PowerShell escape character)\n\t\t\texpect(script).toContain('``');\n\t\t});\n\n\t\tit('should escape dollar signs in descriptions', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'test',\n\t\t\t\t\tdescription: 'Test with $env:PATH variable',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\t// Should escape dollar signs\n\t\t\texpect(script).toContain('`$');\n\t\t});\n\n\t\tit('should escape double quotes in descriptions', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'test',\n\t\t\t\t\tdescription: 'Test with \"quotes\"',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\t// Should escape double quotes (PowerShell string delimiter)\n\t\t\texpect(script).toContain('\"\"');\n\t\t});\n\n\t\tit('should handle multiple PowerShell metacharacters together', () => {\n\t\t\tconst commands: CommandDefinition[] = [\n\t\t\t\t{\n\t\t\t\t\tname: 'test',\n\t\t\t\t\tdescription: 'Dangerous: $(Remove-Item -Force) `n $env:HOME \"quoted\"',\n\t\t\t\t\tflags: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst script = generator.generate(commands);\n\n\t\t\t// Should contain escaped versions of dangerous patterns\n\t\t\texpect(script).toContain('`$');  // Escaped dollar signs\n\t\t\texpect(script).toContain('``');  // Escaped backticks\n\t\t\texpect(script).toContain('\"\"');  // Escaped double quotes\n\n\t\t\t// The escaped patterns should be present (backtick before $ and n)\n\t\t\texpect(script).toMatch(/`\\$\\(/);  // `$( instead of $(\n\t\t\texpect(script).toMatch(/``n/);     // ``n instead of `n\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "test/core/completions/generators/zsh-generator.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { ZshGenerator } from '../../../../src/core/completions/generators/zsh-generator.js';\nimport { CommandDefinition } from '../../../../src/core/completions/types.js';\n\ndescribe('ZshGenerator', () => {\n  let generator: ZshGenerator;\n\n  beforeEach(() => {\n    generator = new ZshGenerator();\n  });\n\n  describe('interface compliance', () => {\n    it('should have shell property set to \"zsh\"', () => {\n      expect(generator.shell).toBe('zsh');\n    });\n\n    it('should implement generate method', () => {\n      expect(typeof generator.generate).toBe('function');\n    });\n  });\n\n  describe('generate', () => {\n    it('should generate valid zsh completion script with header', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize OpenSpec',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('#compdef openspec');\n      expect(script).toContain('# Zsh completion script for OpenSpec CLI');\n      expect(script).toContain('_openspec() {');\n    });\n\n    it('should include all commands in the command list', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize OpenSpec',\n          flags: [],\n        },\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [],\n        },\n        {\n          name: 'show',\n          description: 'Show a spec',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"'init:Initialize OpenSpec'\");\n      expect(script).toContain(\"'validate:Validate specs'\");\n      expect(script).toContain(\"'show:Show a spec'\");\n    });\n\n    it('should generate command completion functions', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize OpenSpec',\n          flags: [],\n        },\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('_openspec_init() {');\n      expect(script).toContain('_openspec_validate() {');\n    });\n\n    it('should handle commands with flags', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [\n            {\n              name: 'strict',\n              description: 'Enable strict mode',\n            },\n            {\n              name: 'json',\n              description: 'Output as JSON',\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('--strict');\n      expect(script).toContain('[Enable strict mode]');\n      expect(script).toContain('--json');\n      expect(script).toContain('[Output as JSON]');\n    });\n\n    it('should handle flags with short options', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show',\n          description: 'Show a spec',\n          flags: [\n            {\n              name: 'requirement',\n              short: 'r',\n              description: 'Show specific requirement',\n              takesValue: true,\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"'(-r --requirement)'{-r,--requirement}'[Show specific requirement]:value:'\");\n      expect(script).toContain('[Show specific requirement]');\n    });\n\n    it('should handle flags that take values', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [\n            {\n              name: 'type',\n              description: 'Specify item type',\n              takesValue: true,\n              values: ['change', 'spec'],\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('--type');\n      expect(script).toContain('[Specify item type]');\n      expect(script).toContain(':value:(change spec)');\n    });\n\n    it('should handle flags with takesValue but no specific values', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'validate',\n          description: 'Validate specs',\n          flags: [\n            {\n              name: 'concurrency',\n              description: 'Max concurrent validations',\n              takesValue: true,\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('--concurrency');\n      expect(script).toContain('[Max concurrent validations]');\n      expect(script).toContain(':value:');\n    });\n\n    it('should handle commands with subcommands', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'change',\n          description: 'Manage changes',\n          flags: [],\n          subcommands: [\n            {\n              name: 'show',\n              description: 'Show a change',\n              flags: [],\n            },\n            {\n              name: 'list',\n              description: 'List changes',\n              flags: [],\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"'show:Show a change'\");\n      expect(script).toContain(\"'list:List changes'\");\n      expect(script).toContain('_openspec_change_show() {');\n      expect(script).toContain('_openspec_change_list() {');\n    });\n\n    it('should handle positional arguments for change-id', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'archive',\n          description: 'Archive a change',\n          acceptsPositional: true,\n          positionalType: 'change-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"'*: :_openspec_complete_changes'\");\n    });\n\n    it('should handle positional arguments for spec-id', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show-spec',\n          description: 'Show a spec',\n          acceptsPositional: true,\n          positionalType: 'spec-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"'*: :_openspec_complete_specs'\");\n    });\n\n    it('should handle positional arguments for change-or-spec-id', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'show',\n          description: 'Show an item',\n          acceptsPositional: true,\n          positionalType: 'change-or-spec-id',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"'*: :_openspec_complete_items'\");\n    });\n\n    it('should handle positional arguments for paths', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize OpenSpec',\n          acceptsPositional: true,\n          positionalType: 'path',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"'*:path:_files'\");\n    });\n\n    it('should escape special characters in descriptions', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'test',\n          description: \"Test with 'quotes' and [brackets] and back\\\\slash and colon:\",\n          flags: [\n            {\n              name: 'flag',\n              description: \"Special chars: 'quotes' [brackets] back\\\\slash colon:\",\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain(\"\\\\'quotes\\\\'\");\n      expect(script).toContain('\\\\[brackets\\\\]');\n      expect(script).toContain('\\\\\\\\slash');\n      expect(script).toContain('\\\\:');\n    });\n\n    it('should sanitize command names with hyphens for function names', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'my-command',\n          description: 'A hyphenated command',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('_openspec_my_command() {');\n    });\n\n    it('should handle complex nested subcommands with flags', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'spec',\n          description: 'Manage specs',\n          flags: [],\n          subcommands: [\n            {\n              name: 'validate',\n              description: 'Validate a spec',\n              acceptsPositional: true,\n              positionalType: 'spec-id',\n              flags: [\n                {\n                  name: 'strict',\n                  description: 'Enable strict mode',\n                },\n                {\n                  name: 'json',\n                  description: 'Output as JSON',\n                },\n              ],\n            },\n          ],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('_openspec_spec() {');\n      expect(script).toContain('_openspec_spec_validate() {');\n      expect(script).toContain('--strict');\n      expect(script).toContain('--json');\n      expect(script).toContain(\"'*: :_openspec_complete_specs'\");\n    });\n\n    it('should generate script that ends with compdef registration', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'init',\n          description: 'Initialize',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script.trim().endsWith('compdef _openspec openspec')).toBe(true);\n    });\n\n    it('should handle empty command list', () => {\n      const commands: CommandDefinition[] = [];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('#compdef openspec');\n      expect(script).toContain('_openspec() {');\n    });\n\n    it('should handle commands with no flags', () => {\n      const commands: CommandDefinition[] = [\n        {\n          name: 'view',\n          description: 'Display dashboard',\n          flags: [],\n        },\n      ];\n\n      const script = generator.generate(commands);\n\n      expect(script).toContain('_openspec_view() {');\n      expect(script).toContain('_arguments');\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/completions/installers/bash-installer.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\nimport { BashInstaller } from '../../../../src/core/completions/installers/bash-installer.js';\n\ndescribe('BashInstaller', () => {\n  let testHomeDir: string;\n  let installer: BashInstaller;\n\n  beforeEach(async () => {\n    // Create a temporary home directory for testing\n    testHomeDir = path.join(os.tmpdir(), `openspec-bash-test-${randomUUID()}`);\n    await fs.mkdir(testHomeDir, { recursive: true });\n    installer = new BashInstaller(testHomeDir);\n  });\n\n  afterEach(async () => {\n    // Clean up test directory\n    await fs.rm(testHomeDir, { recursive: true, force: true });\n  });\n\n  describe('getInstallationPath', () => {\n    it('should return standard bash-completion path', async () => {\n      const result = await installer.getInstallationPath();\n\n      expect(result).toBe(path.join(testHomeDir, '.local', 'share', 'bash-completion', 'completions', 'openspec'));\n    });\n  });\n\n  describe('backupExistingFile', () => {\n    it('should return undefined when file does not exist', async () => {\n      const nonExistentPath = path.join(testHomeDir, 'nonexistent.txt');\n      const backupPath = await installer.backupExistingFile(nonExistentPath);\n\n      expect(backupPath).toBeUndefined();\n    });\n\n    it('should create backup when file exists', async () => {\n      const filePath = path.join(testHomeDir, 'test.txt');\n      await fs.writeFile(filePath, 'original content');\n\n      const backupPath = await installer.backupExistingFile(filePath);\n\n      expect(backupPath).toBeDefined();\n      expect(backupPath).toContain('.backup-');\n\n      // Verify backup file exists and has correct content\n      const backupContent = await fs.readFile(backupPath!, 'utf-8');\n      expect(backupContent).toBe('original content');\n    });\n\n    it('should create backup with timestamp in filename', async () => {\n      const filePath = path.join(testHomeDir, 'test.txt');\n      await fs.writeFile(filePath, 'content');\n\n      const backupPath = await installer.backupExistingFile(filePath);\n\n      expect(backupPath).toMatch(/\\.backup-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}/);\n    });\n  });\n\n  describe('install', () => {\n    const testScript = '# Bash completion script for OpenSpec CLI\\n_openspec_completion() {\\n  echo \"test\"\\n}\\n';\n\n    it('should install to bash-completion path', async () => {\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n      expect(result.installedPath).toBe(path.join(testHomeDir, '.local', 'share', 'bash-completion', 'completions', 'openspec'));\n\n      // Verify file was created with correct content\n      const content = await fs.readFile(result.installedPath!, 'utf-8');\n      expect(content).toBe(testScript);\n    });\n\n    it('should create necessary directories if they do not exist', async () => {\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n\n      // Verify directory structure was created\n      const completionsDir = path.dirname(result.installedPath!);\n      const stat = await fs.stat(completionsDir);\n      expect(stat.isDirectory()).toBe(true);\n    });\n\n    it('should backup existing file before overwriting', async () => {\n      const targetPath = path.join(testHomeDir, '.local', 'share', 'bash-completion', 'completions', 'openspec');\n      await fs.mkdir(path.dirname(targetPath), { recursive: true });\n      await fs.writeFile(targetPath, 'old script');\n\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n      expect(result.backupPath).toBeDefined();\n      expect(result.backupPath).toContain('.backup-');\n\n      // Verify backup has old content\n      const backupContent = await fs.readFile(result.backupPath!, 'utf-8');\n      expect(backupContent).toBe('old script');\n\n      // Verify new file has new content\n      const newContent = await fs.readFile(targetPath, 'utf-8');\n      expect(newContent).toBe(testScript);\n    });\n\n    it('should configure .bashrc when auto-config is enabled', async () => {\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n      expect(result.bashrcConfigured).toBe(true);\n\n      const bashrcPath = path.join(testHomeDir, '.bashrc');\n      const content = await fs.readFile(bashrcPath, 'utf-8');\n\n      expect(content).toContain('# OPENSPEC:START');\n      expect(content).toContain('# OPENSPEC:END');\n      expect(content).toContain('OpenSpec shell completions configuration');\n    });\n\n    it('should include instructions when auto-config is disabled', async () => {\n      const originalEnv = process.env.OPENSPEC_NO_AUTO_CONFIG;\n      process.env.OPENSPEC_NO_AUTO_CONFIG = '1';\n\n      const result = await installer.install(testScript);\n\n      expect(result.instructions).toBeDefined();\n      expect(result.instructions!.join('\\n')).toContain('.bashrc');\n      expect(result.bashrcConfigured).toBe(false);\n\n      // Restore env\n      if (originalEnv === undefined) {\n        delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      } else {\n        process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;\n      }\n    });\n\n    it('should handle installation errors gracefully', async () => {\n      // Create a temporary file and use its path as homeDir\n      // This guarantees ENOTDIR when trying to create subdirectories (cross-platform)\n      const blockingFile = path.join(testHomeDir, 'blocking-file');\n      await fs.writeFile(blockingFile, 'blocking content');\n      const invalidInstaller = new BashInstaller(blockingFile);\n\n      const result = await invalidInstaller.install(testScript);\n\n      expect(result.success).toBe(false);\n      expect(result.message).toContain('Failed to install');\n    });\n\n    it('should detect already-installed completion with identical content', async () => {\n      // First installation\n      const firstResult = await installer.install(testScript);\n      expect(firstResult.success).toBe(true);\n\n      // Second installation with same script\n      const secondResult = await installer.install(testScript);\n\n      expect(secondResult.success).toBe(true);\n      expect(secondResult.message).toContain('already installed');\n      expect(secondResult.message).toContain('up to date');\n      expect(secondResult.backupPath).toBeUndefined();\n    });\n\n    it('should update completion when content differs', async () => {\n      // First installation\n      const firstScript = '# Bash completion v1\\n_openspec_completion() {\\n  echo \"version 1\"\\n}\\n';\n      const firstResult = await installer.install(firstScript);\n      expect(firstResult.success).toBe(true);\n\n      // Second installation with different script\n      const secondScript = '# Bash completion v2\\n_openspec_completion() {\\n  echo \"version 2\"\\n}\\n';\n      const secondResult = await installer.install(secondScript);\n\n      expect(secondResult.success).toBe(true);\n      expect(secondResult.message).toContain('updated successfully');\n      expect(secondResult.backupPath).toBeDefined();\n\n      // Verify new content was written\n      const content = await fs.readFile(secondResult.installedPath!, 'utf-8');\n      expect(content).toBe(secondScript);\n\n      // Verify backup has old content\n      const backupContent = await fs.readFile(secondResult.backupPath!, 'utf-8');\n      expect(backupContent).toBe(firstScript);\n    });\n\n    it('should handle paths with spaces in .bashrc config', async () => {\n      // Create a test home directory with spaces\n      const testHomeDirWithSpaces = path.join(os.tmpdir(), `openspec bash test ${randomUUID()}`);\n      await fs.mkdir(testHomeDirWithSpaces, { recursive: true });\n      const installerWithSpaces = new BashInstaller(testHomeDirWithSpaces);\n\n      try {\n        const result = await installerWithSpaces.install(testScript);\n        expect(result.success).toBe(true);\n\n        // Check if .bashrc was created (when auto-config is enabled)\n        const bashrcPath = path.join(testHomeDirWithSpaces, '.bashrc');\n        try {\n          const bashrcContent = await fs.readFile(bashrcPath, 'utf-8');\n          // Verify the path is quoted in config\n          const completionsDir = path.dirname(result.installedPath!);\n          expect(bashrcContent).toContain(completionsDir);\n        } catch {\n          // .bashrc might not exist if auto-config was disabled\n        }\n      } finally {\n        // Clean up\n        await fs.rm(testHomeDirWithSpaces, { recursive: true, force: true });\n      }\n    });\n  });\n\n  describe('uninstall', () => {\n    const testScript = '# Bash completion script\\n_openspec_completion() {}\\n';\n\n    it('should remove installed completion script', async () => {\n      // Install first\n      await installer.install(testScript);\n\n      // Uninstall\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(true);\n      expect(result.message).toContain('uninstalled successfully');\n\n      // Verify file is gone\n      const targetPath = await installer.getInstallationPath();\n      const exists = await fs.access(targetPath).then(() => true).catch(() => false);\n      expect(exists).toBe(false);\n    });\n\n    it('should return failure when not installed', async () => {\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(false);\n      expect(result.message).toContain('not installed');\n    });\n\n    it('should remove .bashrc configuration', async () => {\n      await installer.install(testScript);\n\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(true);\n\n      // Verify .bashrc markers are removed\n      const bashrcPath = path.join(testHomeDir, '.bashrc');\n      const exists = await fs.access(bashrcPath).then(() => true).catch(() => false);\n\n      if (exists) {\n        const content = await fs.readFile(bashrcPath, 'utf-8');\n        expect(content).not.toContain('# OPENSPEC:START');\n        expect(content).not.toContain('# OPENSPEC:END');\n      }\n    });\n  });\n\n  describe('configureBashrc', () => {\n    const completionsDir = '/test/.local/share/bash-completion/completions';\n\n    it('should create .bashrc with markers and config when file does not exist', async () => {\n      const result = await installer.configureBashrc(completionsDir);\n\n      expect(result).toBe(true);\n\n      const bashrcPath = path.join(testHomeDir, '.bashrc');\n      const content = await fs.readFile(bashrcPath, 'utf-8');\n\n      expect(content).toContain('# OPENSPEC:START');\n      expect(content).toContain('# OPENSPEC:END');\n      expect(content).toContain('# OpenSpec shell completions configuration');\n      expect(content).toContain(completionsDir);\n    });\n\n    it('should prepend markers and config when .bashrc exists without markers', async () => {\n      const bashrcPath = path.join(testHomeDir, '.bashrc');\n      await fs.writeFile(bashrcPath, '# My custom bash config\\nalias ll=\"ls -la\"\\n');\n\n      const result = await installer.configureBashrc(completionsDir);\n\n      expect(result).toBe(true);\n\n      const content = await fs.readFile(bashrcPath, 'utf-8');\n\n      expect(content).toContain('# OPENSPEC:START');\n      expect(content).toContain('# OPENSPEC:END');\n      expect(content).toContain('# My custom bash config');\n      expect(content).toContain('alias ll=\"ls -la\"');\n\n      // Config should be before existing content\n      const configIndex = content.indexOf('# OPENSPEC:START');\n      const aliasIndex = content.indexOf('alias ll');\n      expect(configIndex).toBeLessThan(aliasIndex);\n    });\n\n    it('should update config between markers when .bashrc has existing markers', async () => {\n      const bashrcPath = path.join(testHomeDir, '.bashrc');\n      const initialContent = [\n        '# OPENSPEC:START',\n        '# Old config',\n        'if [ -d \"/old/path\" ]; then',\n        '  . \"/old/path\"',\n        'fi',\n        '# OPENSPEC:END',\n        '',\n        '# My custom config',\n      ].join('\\n');\n\n      await fs.writeFile(bashrcPath, initialContent);\n\n      const result = await installer.configureBashrc(completionsDir);\n\n      expect(result).toBe(true);\n\n      const content = await fs.readFile(bashrcPath, 'utf-8');\n\n      expect(content).toContain('# OPENSPEC:START');\n      expect(content).toContain('# OPENSPEC:END');\n      expect(content).toContain(completionsDir);\n      expect(content).not.toContain('# Old config');\n      expect(content).not.toContain('/old/path');\n      expect(content).toContain('# My custom config');\n    });\n\n    it('should preserve user content outside markers', async () => {\n      const bashrcPath = path.join(testHomeDir, '.bashrc');\n      const userContent = [\n        '# My bash config',\n        'export PATH=\"/custom/path:$PATH\"',\n        '',\n        '# OPENSPEC:START',\n        '# Old OpenSpec config',\n        '# OPENSPEC:END',\n        '',\n        'alias ls=\"ls -G\"',\n      ].join('\\n');\n\n      await fs.writeFile(bashrcPath, userContent);\n\n      const result = await installer.configureBashrc(completionsDir);\n\n      expect(result).toBe(true);\n\n      const content = await fs.readFile(bashrcPath, 'utf-8');\n\n      expect(content).toContain('# My bash config');\n      expect(content).toContain('export PATH=\"/custom/path:$PATH\"');\n      expect(content).toContain('alias ls=\"ls -G\"');\n      expect(content).toContain(completionsDir);\n      expect(content).not.toContain('# Old OpenSpec config');\n    });\n\n    it('should return false when OPENSPEC_NO_AUTO_CONFIG is set', async () => {\n      const originalEnv = process.env.OPENSPEC_NO_AUTO_CONFIG;\n      process.env.OPENSPEC_NO_AUTO_CONFIG = '1';\n\n      const result = await installer.configureBashrc(completionsDir);\n\n      expect(result).toBe(false);\n\n      const bashrcPath = path.join(testHomeDir, '.bashrc');\n      const exists = await fs.access(bashrcPath).then(() => true).catch(() => false);\n      expect(exists).toBe(false);\n\n      // Restore env\n      if (originalEnv === undefined) {\n        delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      } else {\n        process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;\n      }\n    });\n\n    it('should handle write permission errors gracefully', async () => {\n      // Create a temporary file and use its path as homeDir\n      // This guarantees ENOTDIR when trying to write .bashrc (cross-platform)\n      const blockingFile = path.join(testHomeDir, 'blocking-file');\n      await fs.writeFile(blockingFile, 'blocking content');\n      const invalidInstaller = new BashInstaller(blockingFile);\n\n      const result = await invalidInstaller.configureBashrc(completionsDir);\n\n      expect(result).toBe(false);\n    });\n  });\n\n  describe('removeBashrcConfig', () => {\n    it('should return true when .bashrc does not exist', async () => {\n      const result = await installer.removeBashrcConfig();\n      expect(result).toBe(true);\n    });\n\n    it('should return true when .bashrc exists but has no markers', async () => {\n      const bashrcPath = path.join(testHomeDir, '.bashrc');\n      await fs.writeFile(bashrcPath, '# My custom config\\nalias ll=\"ls -la\"\\n');\n\n      const result = await installer.removeBashrcConfig();\n\n      expect(result).toBe(true);\n\n      // Content should be unchanged\n      const content = await fs.readFile(bashrcPath, 'utf-8');\n      expect(content).toBe('# My custom config\\nalias ll=\"ls -la\"\\n');\n    });\n\n    it('should remove markers and config when present', async () => {\n      const bashrcPath = path.join(testHomeDir, '.bashrc');\n      const content = [\n        '# My config',\n        '',\n        '# OPENSPEC:START',\n        '# OpenSpec shell completions configuration',\n        'if [ -d ~/.local/share/bash-completion/completions ]; then',\n        '  . ~/.local/share/bash-completion/completions/openspec',\n        'fi',\n        '# OPENSPEC:END',\n        '',\n        'alias ll=\"ls -la\"',\n      ].join('\\n');\n\n      await fs.writeFile(bashrcPath, content);\n\n      const result = await installer.removeBashrcConfig();\n\n      expect(result).toBe(true);\n\n      const newContent = await fs.readFile(bashrcPath, 'utf-8');\n\n      expect(newContent).not.toContain('# OPENSPEC:START');\n      expect(newContent).not.toContain('# OPENSPEC:END');\n      expect(newContent).not.toContain('OpenSpec shell completions configuration');\n      expect(newContent).toContain('# My config');\n      expect(newContent).toContain('alias ll=\"ls -la\"');\n    });\n\n    it('should preserve user content when removing markers', async () => {\n      const bashrcPath = path.join(testHomeDir, '.bashrc');\n      const content = [\n        'export PATH=\"/custom:$PATH\"',\n        '',\n        '# OPENSPEC:START',\n        '# Config',\n        '# OPENSPEC:END',\n        '',\n        'alias g=\"git\"',\n      ].join('\\n');\n\n      await fs.writeFile(bashrcPath, content);\n\n      const result = await installer.removeBashrcConfig();\n\n      expect(result).toBe(true);\n\n      const newContent = await fs.readFile(bashrcPath, 'utf-8');\n\n      expect(newContent).toContain('export PATH=\"/custom:$PATH\"');\n      expect(newContent).toContain('alias g=\"git\"');\n      expect(newContent).not.toContain('# OPENSPEC:START');\n    });\n\n    it('should handle permission errors gracefully', async () => {\n      const invalidInstaller = new BashInstaller('/root/invalid/path');\n      const result = await invalidInstaller.removeBashrcConfig();\n\n      expect(result).toBe(true);\n    });\n  });\n\n  describe('constructor', () => {\n    it('should use provided home directory', () => {\n      const customInstaller = new BashInstaller('/custom/home');\n      expect(customInstaller).toBeDefined();\n    });\n\n    it('should use os.homedir() by default', () => {\n      const defaultInstaller = new BashInstaller();\n      expect(defaultInstaller).toBeDefined();\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/completions/installers/fish-installer.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { FishInstaller } from '../../../../src/core/completions/installers/fish-installer.js';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\n\ndescribe('FishInstaller', () => {\n  let testHomeDir: string;\n  let installer: FishInstaller;\n\n  beforeEach(async () => {\n    testHomeDir = path.join(os.tmpdir(), `openspec-fish-test-${randomUUID()}`);\n    await fs.mkdir(testHomeDir, { recursive: true });\n    installer = new FishInstaller(testHomeDir);\n  });\n\n  afterEach(async () => {\n    await fs.rm(testHomeDir, { recursive: true, force: true });\n  });\n\n  describe('getInstallationPath', () => {\n    it('should return standard fish completions path', () => {\n      const result = installer.getInstallationPath();\n      expect(result).toBe(path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish'));\n    });\n\n    it('should use homeDir from constructor', () => {\n      const customHome = '/custom/home';\n      const customInstaller = new FishInstaller(customHome);\n      const result = customInstaller.getInstallationPath();\n      expect(result).toBe(path.join(customHome, '.config', 'fish', 'completions', 'openspec.fish'));\n    });\n  });\n\n  describe('backupExistingFile', () => {\n    it('should return undefined when file does not exist', async () => {\n      const nonExistentPath = path.join(testHomeDir, 'does-not-exist.fish');\n      const backupPath = await installer.backupExistingFile(nonExistentPath);\n      expect(backupPath).toBeUndefined();\n    });\n\n    it('should create backup with timestamp in filename', async () => {\n      const filePath = path.join(testHomeDir, 'test.fish');\n      await fs.writeFile(filePath, 'test content');\n\n      const backupPath = await installer.backupExistingFile(filePath);\n\n      expect(backupPath).toBeDefined();\n      expect(backupPath).toMatch(/\\.backup-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}/);\n    });\n\n    it('should copy file content to backup', async () => {\n      const filePath = path.join(testHomeDir, 'test.fish');\n      const originalContent = '# Original fish completion script\\nfunction test_func\\nend';\n      await fs.writeFile(filePath, originalContent);\n\n      const backupPath = await installer.backupExistingFile(filePath);\n\n      expect(backupPath).toBeDefined();\n      const backupContent = await fs.readFile(backupPath!, 'utf-8');\n      expect(backupContent).toBe(originalContent);\n    });\n\n    it('should create backup next to original file', async () => {\n      const filePath = path.join(testHomeDir, 'subdir', 'test.fish');\n      await fs.mkdir(path.dirname(filePath), { recursive: true });\n      await fs.writeFile(filePath, 'content');\n\n      const backupPath = await installer.backupExistingFile(filePath);\n\n      expect(backupPath).toBeDefined();\n      expect(path.dirname(backupPath!)).toBe(path.dirname(filePath));\n    });\n  });\n\n  describe('install', () => {\n    const mockCompletionScript = `# Fish completion script for OpenSpec CLI\nfunction __fish_openspec\n    echo \"test\"\nend\n\ncomplete -c openspec -a 'init' -d 'Initialize OpenSpec'\n`;\n\n    it('should install completion script for the first time', async () => {\n      const result = await installer.install(mockCompletionScript);\n\n      expect(result.success).toBe(true);\n      expect(result.message).toBe('Completion script installed successfully for Fish');\n      expect(result.installedPath).toBe(path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish'));\n      expect(result.backupPath).toBeUndefined();\n      expect(result.instructions).toHaveLength(2);\n      expect(result.instructions![0]).toContain('Fish automatically loads completions');\n      expect(result.instructions![1]).toContain('Completions are available immediately');\n    });\n\n    it('should create parent directories if they do not exist', async () => {\n      const result = await installer.install(mockCompletionScript);\n\n      expect(result.success).toBe(true);\n      const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');\n      const dirExists = await fs.access(path.dirname(targetPath)).then(() => true).catch(() => false);\n      expect(dirExists).toBe(true);\n    });\n\n    it('should write completion script content correctly', async () => {\n      await installer.install(mockCompletionScript);\n\n      const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');\n      const content = await fs.readFile(targetPath, 'utf-8');\n      expect(content).toBe(mockCompletionScript);\n    });\n\n    it('should detect when already installed with same content', async () => {\n      // First installation\n      await installer.install(mockCompletionScript);\n\n      // Second installation with same content\n      const result = await installer.install(mockCompletionScript);\n\n      expect(result.success).toBe(true);\n      expect(result.message).toBe('Completion script is already installed (up to date)');\n      expect(result.instructions![0]).toContain('already installed and up to date');\n      expect(result.backupPath).toBeUndefined();\n    });\n\n    it('should update when content is different', async () => {\n      // Initial installation\n      await installer.install(mockCompletionScript);\n\n      // Update with different content\n      const updatedScript = `# Fish completion script for OpenSpec CLI\nfunction __fish_openspec_new\n    echo \"updated\"\nend\n\ncomplete -c openspec -a 'init' -d 'Initialize OpenSpec'\ncomplete -c openspec -a 'validate' -d 'Validate specs'\n`;\n\n      const result = await installer.install(updatedScript);\n\n      expect(result.success).toBe(true);\n      expect(result.message).toContain('updated successfully');\n      expect(result.backupPath).toBeDefined();\n      expect(result.backupPath).toMatch(/\\.backup-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}/);\n    });\n\n    it('should create backup when updating existing installation', async () => {\n      const originalScript = mockCompletionScript;\n      await installer.install(originalScript);\n\n      const updatedScript = originalScript + '\\n# Updated version';\n      const result = await installer.install(updatedScript);\n\n      expect(result.success).toBe(true);\n      expect(result.backupPath).toBeDefined();\n\n      // Verify backup contains original content\n      const backupContent = await fs.readFile(result.backupPath!, 'utf-8');\n      expect(backupContent).toBe(originalScript);\n\n      // Verify current file has updated content\n      const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');\n      const currentContent = await fs.readFile(targetPath, 'utf-8');\n      expect(currentContent).toBe(updatedScript);\n    });\n\n    it('should include backup path in message when updating', async () => {\n      await installer.install(mockCompletionScript);\n\n      const updatedScript = mockCompletionScript + '\\n# Updated';\n      const result = await installer.install(updatedScript);\n\n      expect(result.success).toBe(true);\n      expect(result.message).toBe('Completion script updated successfully (previous version backed up)');\n      expect(result.backupPath).toBeDefined();\n    });\n\n    it('should handle installation with paths containing spaces', async () => {\n      const spacedHomeDir = path.join(os.tmpdir(), `openspec fish test ${randomUUID()}`);\n      await fs.mkdir(spacedHomeDir, { recursive: true });\n\n      const spacedInstaller = new FishInstaller(spacedHomeDir);\n      const result = await spacedInstaller.install(mockCompletionScript);\n\n      expect(result.success).toBe(true);\n      expect(result.installedPath).toContain('openspec fish test');\n\n      // Cleanup\n      await fs.rm(spacedHomeDir, { recursive: true, force: true });\n    });\n\n    // Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows\n    // Windows uses ACLs which Node.js chmod doesn't control\n    it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {\n      // Create a read-only directory to simulate permission error\n      const restrictedDir = path.join(testHomeDir, '.config', 'fish', 'completions');\n      await fs.mkdir(restrictedDir, { recursive: true });\n      await fs.chmod(restrictedDir, 0o444); // Read-only\n\n      const result = await installer.install(mockCompletionScript);\n\n      // Cleanup - restore permissions before asserting\n      await fs.chmod(restrictedDir, 0o755);\n\n      expect(result.success).toBe(false);\n      expect(result.message).toContain('Failed to install completion script');\n    });\n\n    it('should provide appropriate instructions for Fish', async () => {\n      const result = await installer.install(mockCompletionScript);\n\n      expect(result.success).toBe(true);\n      expect(result.instructions).toBeDefined();\n      expect(result.instructions).toHaveLength(2);\n      expect(result.instructions![0]).toContain('~/.config/fish/completions/');\n      expect(result.instructions![1]).toContain('no shell restart needed');\n    });\n\n    it('should handle empty completion script', async () => {\n      const result = await installer.install('');\n\n      expect(result.success).toBe(true);\n      const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');\n      const content = await fs.readFile(targetPath, 'utf-8');\n      expect(content).toBe('');\n    });\n\n    it('should handle completion script with special characters', async () => {\n      const specialScript = `# Fish completion script with special chars: ' \" \\` $ \\\\\nfunction __fish_openspec\n    echo \"test's \\\\\"quoted\\\\\" text\"\nend\n`;\n\n      const result = await installer.install(specialScript);\n\n      expect(result.success).toBe(true);\n      const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');\n      const content = await fs.readFile(targetPath, 'utf-8');\n      expect(content).toBe(specialScript);\n    });\n  });\n\n  describe('uninstall', () => {\n    const mockCompletionScript = `# Fish completion script\ncomplete -c openspec -a 'init'\n`;\n\n    it('should successfully uninstall when completion script exists', async () => {\n      // First install\n      await installer.install(mockCompletionScript);\n\n      // Then uninstall\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(true);\n      expect(result.message).toBe('Completion script uninstalled successfully');\n    });\n\n    it('should remove the completion file', async () => {\n      await installer.install(mockCompletionScript);\n      const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');\n\n      await installer.uninstall();\n\n      const fileExists = await fs.access(targetPath).then(() => true).catch(() => false);\n      expect(fileExists).toBe(false);\n    });\n\n    it('should return failure when completion script is not installed', async () => {\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(false);\n      expect(result.message).toBe('Completion script is not installed');\n    });\n\n    it('should accept yes option parameter', async () => {\n      await installer.install(mockCompletionScript);\n\n      const result = await installer.uninstall({ yes: true });\n\n      expect(result.success).toBe(true);\n      expect(result.message).toBe('Completion script uninstalled successfully');\n    });\n\n    // Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows\n    // Windows uses ACLs which Node.js chmod doesn't control\n    it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {\n      await installer.install(mockCompletionScript);\n      const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');\n      const parentDir = path.dirname(targetPath);\n\n      // Make parent directory read-only to simulate permission error\n      await fs.chmod(parentDir, 0o444);\n      const result = await installer.uninstall();\n\n      // Restore permissions for cleanup\n      await fs.chmod(parentDir, 0o755);\n\n      // On some systems, the access check fails with permission error\n      // which returns \"not installed\" rather than \"failed to uninstall\"\n      expect(result.success).toBe(false);\n      expect(\n        result.message === 'Completion script is not installed' ||\n        result.message.includes('Failed to uninstall completion script')\n      ).toBe(true);\n    });\n\n    it('should handle uninstall when parent directory does not exist', async () => {\n      // Don't install anything, so directory doesn't exist\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(false);\n      expect(result.message).toBe('Completion script is not installed');\n    });\n  });\n\n});\n"
  },
  {
    "path": "test/core/completions/installers/powershell-installer.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { PowerShellInstaller } from '../../../../src/core/completions/installers/powershell-installer.js';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\n\ndescribe('PowerShellInstaller', () => {\n  let testHomeDir: string;\n  let installer: PowerShellInstaller;\n  let originalPlatform: NodeJS.Platform;\n  let originalEnv: NodeJS.ProcessEnv;\n\n  beforeEach(async () => {\n    testHomeDir = path.join(os.tmpdir(), `openspec-powershell-test-${randomUUID()}`);\n    await fs.mkdir(testHomeDir, { recursive: true });\n    installer = new PowerShellInstaller(testHomeDir);\n    originalPlatform = process.platform;\n    originalEnv = { ...process.env };\n  });\n\n  afterEach(async () => {\n    await fs.rm(testHomeDir, { recursive: true, force: true });\n    // Restore platform and environment\n    Object.defineProperty(process, 'platform', {\n      value: originalPlatform,\n    });\n    process.env = originalEnv;\n  });\n\n  describe('getProfilePath', () => {\n    it('should prefer PROFILE environment variable when set', () => {\n      process.env.PROFILE = '/custom/profile/path.ps1';\n      const result = installer.getProfilePath();\n      expect(result).toBe('/custom/profile/path.ps1');\n    });\n\n    it('should return Windows default path when on win32 platform', () => {\n      delete process.env.PROFILE;\n      Object.defineProperty(process, 'platform', {\n        value: 'win32',\n      });\n\n      const result = installer.getProfilePath();\n      expect(result).toBe(path.join(testHomeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'));\n    });\n\n    it('should return Unix default path when on darwin platform', () => {\n      delete process.env.PROFILE;\n      Object.defineProperty(process, 'platform', {\n        value: 'darwin',\n      });\n\n      const result = installer.getProfilePath();\n      expect(result).toBe(path.join(testHomeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1'));\n    });\n\n    it('should return Unix default path when on linux platform', () => {\n      delete process.env.PROFILE;\n      Object.defineProperty(process, 'platform', {\n        value: 'linux',\n      });\n\n      const result = installer.getProfilePath();\n      expect(result).toBe(path.join(testHomeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1'));\n    });\n  });\n\n  describe('getInstallationPath', () => {\n    it('should return path relative to profile directory', () => {\n      delete process.env.PROFILE;\n      Object.defineProperty(process, 'platform', {\n        value: 'darwin',\n      });\n\n      const result = installer.getInstallationPath();\n      expect(result).toBe(path.join(testHomeDir, '.config', 'powershell', 'OpenSpecCompletion.ps1'));\n    });\n\n    it('should work with custom PROFILE environment variable', () => {\n      process.env.PROFILE = path.join(testHomeDir, 'custom', 'profile.ps1');\n      const result = installer.getInstallationPath();\n      expect(result).toBe(path.join(testHomeDir, 'custom', 'OpenSpecCompletion.ps1'));\n    });\n\n    it('should return Windows path when on Windows platform', () => {\n      delete process.env.PROFILE;\n      Object.defineProperty(process, 'platform', {\n        value: 'win32',\n      });\n\n      const result = installer.getInstallationPath();\n      expect(result).toBe(path.join(testHomeDir, 'Documents', 'PowerShell', 'OpenSpecCompletion.ps1'));\n    });\n  });\n\n  describe('backupExistingFile', () => {\n    it('should return undefined when file does not exist', async () => {\n      const nonExistentPath = path.join(testHomeDir, 'does-not-exist.ps1');\n      const backupPath = await installer.backupExistingFile(nonExistentPath);\n      expect(backupPath).toBeUndefined();\n    });\n\n    it('should create backup with timestamp in filename', async () => {\n      const filePath = path.join(testHomeDir, 'test.ps1');\n      await fs.writeFile(filePath, 'test content');\n\n      const backupPath = await installer.backupExistingFile(filePath);\n\n      expect(backupPath).toBeDefined();\n      expect(backupPath).toMatch(/\\.backup-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}/);\n    });\n\n    it('should copy file content to backup', async () => {\n      const filePath = path.join(testHomeDir, 'test.ps1');\n      const originalContent = '# Original PowerShell completion script\\n$completer = {}';\n      await fs.writeFile(filePath, originalContent);\n\n      const backupPath = await installer.backupExistingFile(filePath);\n\n      expect(backupPath).toBeDefined();\n      const backupContent = await fs.readFile(backupPath!, 'utf-8');\n      expect(backupContent).toBe(originalContent);\n    });\n\n    it('should create backup next to original file', async () => {\n      const filePath = path.join(testHomeDir, 'subdir', 'test.ps1');\n      await fs.mkdir(path.dirname(filePath), { recursive: true });\n      await fs.writeFile(filePath, 'content');\n\n      const backupPath = await installer.backupExistingFile(filePath);\n\n      expect(backupPath).toBeDefined();\n      expect(path.dirname(backupPath!)).toBe(path.dirname(filePath));\n    });\n  });\n\n  describe('configureProfile', () => {\n    const mockScriptPath = '/path/to/OpenSpecCompletion.ps1';\n\n    // Note: OPENSPEC_NO_AUTO_CONFIG check is now handled in the install() method,\n    // not in configureProfile() itself\n\n    it('should create profile with markers when file does not exist', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      const profilePath = installer.getProfilePath();\n\n      const result = await installer.configureProfile(mockScriptPath);\n\n      expect(result).toBe(true);\n      const content = await fs.readFile(profilePath, 'utf-8');\n      expect(content).toContain('# OPENSPEC:START');\n      expect(content).toContain('# OPENSPEC:END');\n      expect(content).toContain(`. \"${mockScriptPath}\"`);\n    });\n\n    it('should prepend markers and config when file exists without markers', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      const profilePath = installer.getProfilePath();\n      await fs.mkdir(path.dirname(profilePath), { recursive: true });\n      await fs.writeFile(profilePath, '# My custom PowerShell config\\nWrite-Host \"Hello\"');\n\n      const result = await installer.configureProfile(mockScriptPath);\n\n      expect(result).toBe(true);\n      const content = await fs.readFile(profilePath, 'utf-8');\n      expect(content).toContain('# OPENSPEC:START');\n      expect(content).toContain('# OPENSPEC:END');\n      expect(content).toContain(mockScriptPath);\n      expect(content).toContain('# My custom PowerShell config');\n      expect(content).toContain('Write-Host \"Hello\"');\n    });\n\n    // Skip on Windows: Windows has dual profile paths (PowerShell Core + Windows PowerShell 5.1),\n    // so even if one profile is already configured, the second one will be configured and return true\n    it.skipIf(process.platform === 'win32')('should skip configuration when script line already exists', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      const profilePath = installer.getProfilePath();\n      await fs.mkdir(path.dirname(profilePath), { recursive: true });\n\n      const initialContent = [\n        '# OPENSPEC:START - OpenSpec completion (managed block, do not edit manually)',\n        `. \"${mockScriptPath}\"`,\n        '# OPENSPEC:END',\n        '',\n        '# My custom config',\n        'Write-Host \"Custom\"',\n      ].join('\\n');\n\n      await fs.writeFile(profilePath, initialContent);\n\n      const result = await installer.configureProfile(mockScriptPath);\n\n      // Should return false because already configured (anyConfigured = false)\n      expect(result).toBe(false);\n      const content = await fs.readFile(profilePath, 'utf-8');\n      // Content should be unchanged\n      expect(content).toBe(initialContent);\n    });\n\n    it('should preserve user content outside markers', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      const profilePath = installer.getProfilePath();\n      await fs.mkdir(path.dirname(profilePath), { recursive: true });\n\n      const initialContent = [\n        '# User config before',\n        'Set-Variable -Name \"test\" -Value \"before\"',\n        '',\n        '# OPENSPEC:START',\n        '# Old config',\n        '# OPENSPEC:END',\n        '',\n        '# User config after',\n        'Set-Variable -Name \"test\" -Value \"after\"',\n      ].join('\\n');\n\n      await fs.writeFile(profilePath, initialContent);\n\n      const result = await installer.configureProfile(mockScriptPath);\n\n      expect(result).toBe(true);\n      const content = await fs.readFile(profilePath, 'utf-8');\n      expect(content).toContain('# User config before');\n      expect(content).toContain('Set-Variable -Name \"test\" -Value \"before\"');\n      expect(content).toContain('# User config after');\n      expect(content).toContain('Set-Variable -Name \"test\" -Value \"after\"');\n    });\n\n    it('should generate correct PowerShell syntax in config', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      const profilePath = installer.getProfilePath();\n\n      await installer.configureProfile(mockScriptPath);\n\n      const content = await fs.readFile(profilePath, 'utf-8');\n      expect(content).toContain('# OPENSPEC:START');\n      expect(content).toContain(`. \"${mockScriptPath}\"`);\n      expect(content).toContain('# OPENSPEC:END');\n    });\n\n    // Skip on Windows: fs.chmod() doesn't reliably restrict write access on Windows\n    // (admin users can bypass read-only attribute, and CI runners often have elevated privileges)\n    it.skipIf(process.platform === 'win32')('should return false on write permission error', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      const profilePath = installer.getProfilePath();\n      await fs.mkdir(path.dirname(profilePath), { recursive: true });\n      await fs.writeFile(profilePath, '# Test');\n\n      // Make file read-only\n      await fs.chmod(profilePath, 0o444);\n\n      const result = await installer.configureProfile(mockScriptPath);\n\n      // Restore permissions for cleanup\n      await fs.chmod(profilePath, 0o644);\n\n      expect(result).toBe(false);\n    });\n  });\n\n  describe('removeProfileConfig', () => {\n    it('should return false when profile does not exist', async () => {\n      const result = await installer.removeProfileConfig();\n      expect(result).toBe(false);\n    });\n\n    it('should return false when profile exists but has no markers', async () => {\n      const profilePath = installer.getProfilePath();\n      await fs.mkdir(path.dirname(profilePath), { recursive: true });\n      await fs.writeFile(profilePath, '# My custom config\\nWrite-Host \"Hello\"');\n\n      const result = await installer.removeProfileConfig();\n\n      expect(result).toBe(false);\n      const content = await fs.readFile(profilePath, 'utf-8');\n      expect(content).toBe('# My custom config\\nWrite-Host \"Hello\"');\n    });\n\n    it('should remove content between markers', async () => {\n      const profilePath = installer.getProfilePath();\n      await fs.mkdir(path.dirname(profilePath), { recursive: true });\n\n      const initialContent = [\n        '# OPENSPEC:START',\n        '# OpenSpec completions',\n        'if (Test-Path \"/path\") {',\n        '    . \"/path\"',\n        '}',\n        '# OPENSPEC:END',\n        '',\n        '# My config',\n      ].join('\\n');\n\n      await fs.writeFile(profilePath, initialContent);\n\n      const result = await installer.removeProfileConfig();\n\n      expect(result).toBe(true);\n      const content = await fs.readFile(profilePath, 'utf-8');\n      expect(content).not.toContain('# OPENSPEC:START');\n      expect(content).not.toContain('# OPENSPEC:END');\n      expect(content).not.toContain('# OpenSpec completions');\n      expect(content).toContain('# My config');\n    });\n\n    it('should remove trailing empty lines after removal', async () => {\n      const profilePath = installer.getProfilePath();\n      await fs.mkdir(path.dirname(profilePath), { recursive: true });\n\n      const initialContent = [\n        '# User config',\n        '# OPENSPEC:START',\n        '# Config',\n        '# OPENSPEC:END',\n        '',\n        '',\n      ].join('\\n');\n\n      await fs.writeFile(profilePath, initialContent);\n\n      const result = await installer.removeProfileConfig();\n\n      expect(result).toBe(true);\n      const content = await fs.readFile(profilePath, 'utf-8');\n      expect(content).toBe('# User config\\n');\n    });\n\n    it('should preserve user content outside markers', async () => {\n      const profilePath = installer.getProfilePath();\n      await fs.mkdir(path.dirname(profilePath), { recursive: true });\n\n      const initialContent = [\n        '# Before',\n        '# OPENSPEC:START',\n        '# OpenSpec',\n        '# OPENSPEC:END',\n        '# After',\n      ].join('\\n');\n\n      await fs.writeFile(profilePath, initialContent);\n\n      const result = await installer.removeProfileConfig();\n\n      expect(result).toBe(true);\n      const content = await fs.readFile(profilePath, 'utf-8');\n      expect(content).toContain('# Before');\n      expect(content).toContain('# After');\n    });\n\n    it('should return false on invalid marker placement', async () => {\n      const profilePath = installer.getProfilePath();\n      await fs.mkdir(path.dirname(profilePath), { recursive: true });\n\n      const initialContent = [\n        '# OPENSPEC:END',\n        '# Config',\n        '# OPENSPEC:START',\n      ].join('\\n');\n\n      await fs.writeFile(profilePath, initialContent);\n\n      const result = await installer.removeProfileConfig();\n\n      expect(result).toBe(false);\n    });\n  });\n\n  describe('install', () => {\n    const mockCompletionScript = `# PowerShell completion script for OpenSpec\n$openspecCompleter = {\n    param($wordToComplete, $commandAst, $cursorPosition)\n    # Completion logic here\n}\nRegister-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter\n`;\n\n    it('should install completion script for the first time', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      const result = await installer.install(mockCompletionScript);\n\n      expect(result.success).toBe(true);\n      expect(result.message).toContain('installed');\n      expect(result.installedPath).toContain('OpenSpecCompletion.ps1');\n      expect(result.backupPath).toBeUndefined();\n    });\n\n    it('should create parent directories if they do not exist', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      const result = await installer.install(mockCompletionScript);\n\n      expect(result.success).toBe(true);\n      const targetPath = installer.getInstallationPath();\n      const fileExists = await fs.access(targetPath).then(() => true).catch(() => false);\n      expect(fileExists).toBe(true);\n    });\n\n    it('should write completion script content correctly', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      await installer.install(mockCompletionScript);\n\n      const targetPath = installer.getInstallationPath();\n      const content = await fs.readFile(targetPath, 'utf-8');\n      expect(content).toBe(mockCompletionScript);\n    });\n\n    it('should detect when already installed with same content', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      await installer.install(mockCompletionScript);\n\n      const result = await installer.install(mockCompletionScript);\n\n      expect(result.success).toBe(true);\n      expect(result.message).toBe('Completion script is already installed (up to date)');\n      expect(result.backupPath).toBeUndefined();\n    });\n\n    it('should update when content is different', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      await installer.install(mockCompletionScript);\n\n      const updatedScript = mockCompletionScript + '\\n# Updated version';\n      const result = await installer.install(updatedScript);\n\n      expect(result.success).toBe(true);\n      expect(result.message).toContain('updated successfully');\n      expect(result.backupPath).toBeDefined();\n    });\n\n    it('should create backup when updating existing installation', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      await installer.install(mockCompletionScript);\n\n      const updatedScript = mockCompletionScript + '\\n# Updated';\n      const result = await installer.install(updatedScript);\n\n      expect(result.success).toBe(true);\n      expect(result.backupPath).toBeDefined();\n\n      // Verify backup contains original content\n      const backupContent = await fs.readFile(result.backupPath!, 'utf-8');\n      expect(backupContent).toBe(mockCompletionScript);\n    });\n\n    it('should configure PowerShell profile when not disabled', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      const result = await installer.install(mockCompletionScript);\n\n      expect(result.success).toBe(true);\n      expect(result.profileConfigured).toBe(true);\n      expect(result.message).toContain('profile configured');\n      expect(result.instructions).toBeUndefined();\n    });\n\n    // Note: OPENSPEC_NO_AUTO_CONFIG support was removed from PowerShell installer\n    // Profile is now always auto-configured if possible\n\n    // Skip on Windows: fs.chmod() doesn't reliably restrict write access on Windows\n    // (admin users can bypass read-only attribute, and CI runners often have elevated privileges)\n    it.skipIf(process.platform === 'win32')('should provide instructions when profile cannot be configured', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      // Make profile directory read-only to prevent configuration\n      const profilePath = installer.getProfilePath();\n      await fs.mkdir(path.dirname(profilePath), { recursive: true });\n      await fs.writeFile(profilePath, '# Test');\n      await fs.chmod(profilePath, 0o444);\n\n      const result = await installer.install(mockCompletionScript);\n\n      // Restore permissions\n      await fs.chmod(profilePath, 0o644);\n\n      expect(result.success).toBe(true);\n      expect(result.profileConfigured).toBe(false);\n      expect(result.instructions).toBeDefined();\n      expect(result.instructions!.some(i => i.includes('Test-Path'))).toBe(true);\n    });\n\n    it('should include backup path in message when updating', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      await installer.install(mockCompletionScript);\n\n      const updatedScript = mockCompletionScript + '\\n# Updated';\n      const result = await installer.install(updatedScript);\n\n      expect(result.success).toBe(true);\n      expect(result.message).toContain('backed up');\n      expect(result.backupPath).toBeDefined();\n    });\n\n    it('should handle installation with paths containing spaces', async () => {\n      const spacedHomeDir = path.join(os.tmpdir(), `openspec powershell test ${randomUUID()}`);\n      await fs.mkdir(spacedHomeDir, { recursive: true });\n\n      const spacedInstaller = new PowerShellInstaller(spacedHomeDir);\n      const result = await spacedInstaller.install(mockCompletionScript);\n\n      expect(result.success).toBe(true);\n      expect(result.installedPath).toContain('openspec powershell test');\n\n      // Cleanup\n      await fs.rm(spacedHomeDir, { recursive: true, force: true });\n    });\n\n    // Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows\n    // Windows uses ACLs which Node.js chmod doesn't control\n    it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {\n      const targetPath = installer.getInstallationPath();\n      const targetDir = path.dirname(targetPath);\n      await fs.mkdir(targetDir, { recursive: true });\n\n      // Make target directory read-only to simulate permission error\n      await fs.chmod(targetDir, 0o444);\n\n      const result = await installer.install(mockCompletionScript);\n\n      // Restore permissions for cleanup\n      await fs.chmod(targetDir, 0o755);\n\n      expect(result.success).toBe(false);\n      expect(result.message).toContain('Failed to install completion script');\n    });\n\n    it('should handle empty completion script', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      const result = await installer.install('');\n\n      expect(result.success).toBe(true);\n      const targetPath = installer.getInstallationPath();\n      const content = await fs.readFile(targetPath, 'utf-8');\n      expect(content).toBe('');\n    });\n\n    it('should handle completion script with special characters', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      const specialScript = `# PowerShell with special chars: ' \" \\` $ @\\n$test = \"value\"`;\n\n      const result = await installer.install(specialScript);\n\n      expect(result.success).toBe(true);\n      const targetPath = installer.getInstallationPath();\n      const content = await fs.readFile(targetPath, 'utf-8');\n      expect(content).toBe(specialScript);\n    });\n  });\n\n  describe('uninstall', () => {\n    const mockCompletionScript = `# PowerShell completion script\n$openspecCompleter = {}\nRegister-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter\n`;\n\n    it('should successfully uninstall when completion script exists', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      await installer.install(mockCompletionScript);\n\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(true);\n      expect(result.message).toBe('Completion script uninstalled successfully');\n    });\n\n    it('should remove the completion file', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      await installer.install(mockCompletionScript);\n      const targetPath = installer.getInstallationPath();\n\n      await installer.uninstall();\n\n      const fileExists = await fs.access(targetPath).then(() => true).catch(() => false);\n      expect(fileExists).toBe(false);\n    });\n\n    it('should remove profile configuration', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      await installer.install(mockCompletionScript);\n      const profilePath = installer.getProfilePath();\n\n      await installer.uninstall();\n\n      const content = await fs.readFile(profilePath, 'utf-8');\n      expect(content).not.toContain('# OPENSPEC:START');\n      expect(content).not.toContain('# OPENSPEC:END');\n    });\n\n    it('should return failure when completion script is not installed', async () => {\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(false);\n      expect(result.message).toBe('Completion script is not installed');\n    });\n\n    it('should accept yes option parameter', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      await installer.install(mockCompletionScript);\n\n      const result = await installer.uninstall({ yes: true });\n\n      expect(result.success).toBe(true);\n      expect(result.message).toBe('Completion script uninstalled successfully');\n    });\n\n    it('should handle both script and config removal', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      await installer.install(mockCompletionScript);\n\n      const targetPath = installer.getInstallationPath();\n      const profilePath = installer.getProfilePath();\n\n      // Verify both exist\n      const scriptExists = await fs.access(targetPath).then(() => true).catch(() => false);\n      const profileContent = await fs.readFile(profilePath, 'utf-8');\n      expect(scriptExists).toBe(true);\n      expect(profileContent).toContain('# OPENSPEC:START');\n\n      await installer.uninstall();\n\n      // Verify both are removed/cleaned\n      const scriptExistsAfter = await fs.access(targetPath).then(() => true).catch(() => false);\n      const profileContentAfter = await fs.readFile(profilePath, 'utf-8');\n      expect(scriptExistsAfter).toBe(false);\n      expect(profileContentAfter).not.toContain('# OPENSPEC:START');\n    });\n\n    // Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows\n    // Windows uses ACLs which Node.js chmod doesn't control\n    it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {\n      delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      await installer.install(mockCompletionScript);\n      const targetPath = installer.getInstallationPath();\n      const parentDir = path.dirname(targetPath);\n\n      // Make parent directory read-only\n      await fs.chmod(parentDir, 0o444);\n      const result = await installer.uninstall();\n\n      // Restore permissions\n      await fs.chmod(parentDir, 0o755);\n\n      // On some systems, the access check fails which returns \"not installed\"\n      // On others, the unlink fails which returns \"Failed to uninstall\"\n      expect(result.success).toBe(false);\n      expect(\n        result.message === 'Completion script is not installed' ||\n        result.message.includes('Failed to uninstall completion script')\n      ).toBe(true);\n    });\n\n    it('should handle uninstall when parent directory does not exist', async () => {\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(false);\n      expect(result.message).toBe('Completion script is not installed');\n    });\n  });\n\n});\n"
  },
  {
    "path": "test/core/completions/installers/zsh-installer.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\nimport { ZshInstaller } from '../../../../src/core/completions/installers/zsh-installer.js';\n\ndescribe('ZshInstaller', () => {\n  let testHomeDir: string;\n  let installer: ZshInstaller;\n\n  beforeEach(async () => {\n    // Create a temporary home directory for testing\n    testHomeDir = path.join(os.tmpdir(), `openspec-zsh-test-${randomUUID()}`);\n    await fs.mkdir(testHomeDir, { recursive: true });\n    installer = new ZshInstaller(testHomeDir);\n  });\n\n  afterEach(async () => {\n    // Clean up test directory\n    await fs.rm(testHomeDir, { recursive: true, force: true });\n  });\n\n  describe('isOhMyZshInstalled', () => {\n    it('should return false when Oh My Zsh is not installed', async () => {\n      const isInstalled = await installer.isOhMyZshInstalled();\n      expect(isInstalled).toBe(false);\n    });\n\n    it('should return true when Oh My Zsh directory exists', async () => {\n      // Create .oh-my-zsh directory\n      const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');\n      await fs.mkdir(ohMyZshPath, { recursive: true });\n\n      const isInstalled = await installer.isOhMyZshInstalled();\n      expect(isInstalled).toBe(true);\n    });\n\n    it('should return false when .oh-my-zsh exists but is a file', async () => {\n      // Create .oh-my-zsh as a file instead of directory\n      const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');\n      await fs.writeFile(ohMyZshPath, 'not a directory');\n\n      const isInstalled = await installer.isOhMyZshInstalled();\n      expect(isInstalled).toBe(false);\n    });\n  });\n\n  describe('getInstallationPath', () => {\n    it('should return Oh My Zsh path when Oh My Zsh is installed', async () => {\n      // Create .oh-my-zsh directory\n      const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');\n      await fs.mkdir(ohMyZshPath, { recursive: true });\n\n      const result = await installer.getInstallationPath();\n\n      expect(result.isOhMyZsh).toBe(true);\n      expect(result.path).toBe(path.join(testHomeDir, '.oh-my-zsh', 'custom', 'completions', '_openspec'));\n    });\n\n    it('should return standard Zsh path when Oh My Zsh is not installed', async () => {\n      const result = await installer.getInstallationPath();\n\n      expect(result.isOhMyZsh).toBe(false);\n      expect(result.path).toBe(path.join(testHomeDir, '.zsh', 'completions', '_openspec'));\n    });\n  });\n\n  describe('backupExistingFile', () => {\n    it('should return undefined when file does not exist', async () => {\n      const nonExistentPath = path.join(testHomeDir, 'nonexistent.txt');\n      const backupPath = await installer.backupExistingFile(nonExistentPath);\n\n      expect(backupPath).toBeUndefined();\n    });\n\n    it('should create backup when file exists', async () => {\n      const filePath = path.join(testHomeDir, 'test.txt');\n      await fs.writeFile(filePath, 'original content');\n\n      const backupPath = await installer.backupExistingFile(filePath);\n\n      expect(backupPath).toBeDefined();\n      expect(backupPath).toContain('.backup-');\n\n      // Verify backup file exists and has correct content\n      const backupContent = await fs.readFile(backupPath!, 'utf-8');\n      expect(backupContent).toBe('original content');\n    });\n\n    it('should create backup with timestamp in filename', async () => {\n      const filePath = path.join(testHomeDir, 'test.txt');\n      await fs.writeFile(filePath, 'content');\n\n      const backupPath = await installer.backupExistingFile(filePath);\n\n      expect(backupPath).toMatch(/\\.backup-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}/);\n    });\n  });\n\n  describe('install', () => {\n    const testScript = '#compdef openspec\\n_openspec() {\\n  echo \"test\"\\n}\\n';\n\n    it('should install to Oh My Zsh path when Oh My Zsh is present', async () => {\n      // Create .oh-my-zsh directory\n      const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');\n      await fs.mkdir(ohMyZshPath, { recursive: true });\n\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n      expect(result.isOhMyZsh).toBe(true);\n      expect(result.installedPath).toBe(path.join(ohMyZshPath, 'custom', 'completions', '_openspec'));\n      expect(result.message).toContain('Oh My Zsh');\n\n      // Verify file was created with correct content\n      const content = await fs.readFile(result.installedPath!, 'utf-8');\n      expect(content).toBe(testScript);\n    });\n\n    it('should install to standard Zsh path when Oh My Zsh is not present', async () => {\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n      expect(result.isOhMyZsh).toBe(false);\n      expect(result.installedPath).toBe(path.join(testHomeDir, '.zsh', 'completions', '_openspec'));\n\n      // Verify file was created\n      const content = await fs.readFile(result.installedPath!, 'utf-8');\n      expect(content).toBe(testScript);\n    });\n\n    it('should create necessary directories if they do not exist', async () => {\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n\n      // Verify directory structure was created\n      const completionsDir = path.dirname(result.installedPath!);\n      const stat = await fs.stat(completionsDir);\n      expect(stat.isDirectory()).toBe(true);\n    });\n\n    it('should backup existing file before overwriting', async () => {\n      const targetPath = path.join(testHomeDir, '.zsh', 'completions', '_openspec');\n      await fs.mkdir(path.dirname(targetPath), { recursive: true });\n      await fs.writeFile(targetPath, 'old script');\n\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n      expect(result.backupPath).toBeDefined();\n      expect(result.backupPath).toContain('.backup-');\n\n      // Verify backup has old content\n      const backupContent = await fs.readFile(result.backupPath!, 'utf-8');\n      expect(backupContent).toBe('old script');\n\n      // Verify new file has new content\n      const newContent = await fs.readFile(targetPath, 'utf-8');\n      expect(newContent).toBe(testScript);\n    });\n\n    it('should include fpath verification guidance for Oh My Zsh', async () => {\n      const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');\n      await fs.mkdir(ohMyZshPath, { recursive: true });\n\n      const result = await installer.install(testScript);\n\n      expect(result.instructions).toBeDefined();\n      expect(result.instructions!.length).toBeGreaterThan(0);\n      // Should include guidance about verifying fpath for Oh My Zsh\n      expect(result.instructions!.join(' ')).toContain('fpath');\n      expect(result.instructions!.join(' ')).toContain('custom/completions');\n    });\n\n    it('should include fpath instructions for standard Zsh when auto-config is disabled', async () => {\n      const originalEnv = process.env.OPENSPEC_NO_AUTO_CONFIG;\n      process.env.OPENSPEC_NO_AUTO_CONFIG = '1';\n\n      const result = await installer.install(testScript);\n\n      expect(result.instructions).toBeDefined();\n      expect(result.instructions!.join('\\n')).toContain('fpath');\n      expect(result.instructions!.join('\\n')).toContain('.zshrc');\n      expect(result.instructions!.join('\\n')).toContain('compinit');\n\n      // Restore env\n      if (originalEnv === undefined) {\n        delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      } else {\n        process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;\n      }\n    });\n\n    it('should handle installation errors gracefully', async () => {\n      // Create installer with non-existent/invalid home directory\n      // Use a path that will fail on both Unix and Windows\n      const invalidPath = process.platform === 'win32'\n        ? 'Z:\\\\nonexistent\\\\invalid\\\\path'  // Non-existent drive letter on Windows\n        : '/root/invalid/nonexistent/path';  // Permission-denied path on Unix\n      const invalidInstaller = new ZshInstaller(invalidPath);\n\n      const result = await invalidInstaller.install(testScript);\n\n      expect(result.success).toBe(false);\n      expect(result.message).toContain('Failed to install');\n    });\n\n    it('should detect already-installed completion with identical content', async () => {\n      // First installation\n      const firstResult = await installer.install(testScript);\n      expect(firstResult.success).toBe(true);\n\n      // Second installation with same script\n      const secondResult = await installer.install(testScript);\n\n      expect(secondResult.success).toBe(true);\n      expect(secondResult.message).toContain('already installed');\n      expect(secondResult.message).toContain('up to date');\n      expect(secondResult.backupPath).toBeUndefined();\n      expect(secondResult.instructions).toBeDefined();\n      expect(secondResult.instructions!.join(' ')).toContain('already installed');\n    });\n\n    it('should update completion when content differs', async () => {\n      // First installation\n      const firstScript = '#compdef openspec\\n_openspec() {\\n  echo \"version 1\"\\n}\\n';\n      const firstResult = await installer.install(firstScript);\n      expect(firstResult.success).toBe(true);\n\n      // Second installation with different script\n      const secondScript = '#compdef openspec\\n_openspec() {\\n  echo \"version 2\"\\n}\\n';\n      const secondResult = await installer.install(secondScript);\n\n      expect(secondResult.success).toBe(true);\n      expect(secondResult.message).toContain('updated successfully');\n      expect(secondResult.message).toContain('backed up');\n      expect(secondResult.backupPath).toBeDefined();\n\n      // Verify new content was written\n      const content = await fs.readFile(secondResult.installedPath!, 'utf-8');\n      expect(content).toBe(secondScript);\n\n      // Verify backup has old content\n      const backupContent = await fs.readFile(secondResult.backupPath!, 'utf-8');\n      expect(backupContent).toBe(firstScript);\n    });\n\n    it('should handle paths with spaces in .zshrc config', async () => {\n      // Create a test home directory with spaces\n      const testHomeDirWithSpaces = path.join(os.tmpdir(), `openspec zsh test ${randomUUID()}`);\n      await fs.mkdir(testHomeDirWithSpaces, { recursive: true });\n      const installerWithSpaces = new ZshInstaller(testHomeDirWithSpaces);\n\n      try {\n        const result = await installerWithSpaces.install(testScript);\n        expect(result.success).toBe(true);\n\n        // Check if .zshrc was created (when auto-config is enabled)\n        const zshrcPath = path.join(testHomeDirWithSpaces, '.zshrc');\n        try {\n          const zshrcContent = await fs.readFile(zshrcPath, 'utf-8');\n          // Verify the path is quoted in fpath\n          expect(zshrcContent).toContain(`fpath=(\"${path.dirname(result.installedPath!)}\" $fpath)`);\n        } catch {\n          // .zshrc might not exist if auto-config was disabled\n        }\n      } finally {\n        // Clean up\n        await fs.rm(testHomeDirWithSpaces, { recursive: true, force: true });\n      }\n    });\n  });\n\n  describe('uninstall', () => {\n    const testScript = '#compdef openspec\\n_openspec() {}\\n';\n\n    it('should remove installed completion script', async () => {\n      // Install first\n      await installer.install(testScript);\n\n      // Verify it's installed\n      const beforeUninstall = await installer.isInstalled();\n      expect(beforeUninstall).toBe(true);\n\n      // Uninstall\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(true);\n      expect(result.message).toContain('removed');\n\n      // Verify it's gone\n      const afterUninstall = await installer.isInstalled();\n      expect(afterUninstall).toBe(false);\n    });\n\n    it('should return failure when script and .zshrc config are not installed', async () => {\n      // Don't create .zshrc or completion script - nothing to remove\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(false);\n      expect(result.message).toContain('not installed');\n    });\n\n    it('should remove from correct location for Oh My Zsh', async () => {\n      const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');\n      await fs.mkdir(ohMyZshPath, { recursive: true });\n\n      await installer.install(testScript);\n\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(true);\n      expect(result.message).toContain(path.join('.oh-my-zsh', 'custom', 'completions', '_openspec'));\n    });\n  });\n\n  describe('isInstalled', () => {\n    const testScript = '#compdef openspec\\n_openspec() {}\\n';\n\n    it('should return false when not installed', async () => {\n      const isInstalled = await installer.isInstalled();\n      expect(isInstalled).toBe(false);\n    });\n\n    it('should return true when installed', async () => {\n      await installer.install(testScript);\n\n      const isInstalled = await installer.isInstalled();\n      expect(isInstalled).toBe(true);\n    });\n\n    it('should check correct location for Oh My Zsh', async () => {\n      const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');\n      await fs.mkdir(ohMyZshPath, { recursive: true });\n\n      await installer.install(testScript);\n\n      const isInstalled = await installer.isInstalled();\n      expect(isInstalled).toBe(true);\n    });\n  });\n\n  describe('getInstallationInfo', () => {\n    const testScript = '#compdef openspec\\n_openspec() {}\\n';\n\n    it('should return not installed when script does not exist', async () => {\n      const info = await installer.getInstallationInfo();\n\n      expect(info.installed).toBe(false);\n      expect(info.path).toBeUndefined();\n      expect(info.isOhMyZsh).toBeUndefined();\n    });\n\n    it('should return installation info when installed', async () => {\n      await installer.install(testScript);\n\n      const info = await installer.getInstallationInfo();\n\n      expect(info.installed).toBe(true);\n      expect(info.path).toBeDefined();\n      expect(info.path).toContain('_openspec');\n      expect(info.isOhMyZsh).toBe(false);\n    });\n\n    it('should indicate Oh My Zsh when installed there', async () => {\n      const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');\n      await fs.mkdir(ohMyZshPath, { recursive: true });\n\n      await installer.install(testScript);\n\n      const info = await installer.getInstallationInfo();\n\n      expect(info.installed).toBe(true);\n      expect(info.isOhMyZsh).toBe(true);\n      expect(info.path).toContain('.oh-my-zsh');\n    });\n  });\n\n  describe('constructor', () => {\n    it('should use provided home directory', () => {\n      const customInstaller = new ZshInstaller('/custom/home');\n      expect(customInstaller).toBeDefined();\n    });\n\n    it('should use os.homedir() by default', () => {\n      const defaultInstaller = new ZshInstaller();\n      expect(defaultInstaller).toBeDefined();\n    });\n  });\n\n  describe('configureZshrc', () => {\n    const completionsDir = '/test/.zsh/completions';\n\n    it('should create .zshrc with markers and config when file does not exist', async () => {\n      const result = await installer.configureZshrc(completionsDir);\n\n      expect(result).toBe(true);\n\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      const content = await fs.readFile(zshrcPath, 'utf-8');\n\n      expect(content).toContain('# OPENSPEC:START');\n      expect(content).toContain('# OPENSPEC:END');\n      expect(content).toContain('# OpenSpec shell completions configuration');\n      expect(content).toContain(`fpath=(\"${completionsDir}\" $fpath)`);\n      expect(content).toContain('autoload -Uz compinit');\n      expect(content).toContain('compinit');\n    });\n\n    it('should prepend markers and config when .zshrc exists without markers', async () => {\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      await fs.writeFile(zshrcPath, '# My custom zsh config\\nalias ll=\"ls -la\"\\n');\n\n      const result = await installer.configureZshrc(completionsDir);\n\n      expect(result).toBe(true);\n\n      const content = await fs.readFile(zshrcPath, 'utf-8');\n\n      expect(content).toContain('# OPENSPEC:START');\n      expect(content).toContain('# OPENSPEC:END');\n      expect(content).toContain('# My custom zsh config');\n      expect(content).toContain('alias ll=\"ls -la\"');\n\n      // Config should be before existing content\n      const configIndex = content.indexOf('# OPENSPEC:START');\n      const aliasIndex = content.indexOf('alias ll');\n      expect(configIndex).toBeLessThan(aliasIndex);\n    });\n\n    it('should update config between markers when .zshrc has existing markers', async () => {\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      const initialContent = [\n        '# OPENSPEC:START',\n        '# Old config',\n        'fpath=(/old/path $fpath)',\n        '# OPENSPEC:END',\n        '',\n        '# My custom config',\n      ].join('\\n');\n\n      await fs.writeFile(zshrcPath, initialContent);\n\n      const result = await installer.configureZshrc(completionsDir);\n\n      expect(result).toBe(true);\n\n      const content = await fs.readFile(zshrcPath, 'utf-8');\n\n      expect(content).toContain('# OPENSPEC:START');\n      expect(content).toContain('# OPENSPEC:END');\n      expect(content).toContain(`fpath=(\"${completionsDir}\" $fpath)`);\n      expect(content).not.toContain('# Old config');\n      expect(content).not.toContain('/old/path');\n      expect(content).toContain('# My custom config');\n    });\n\n    it('should preserve user content outside markers', async () => {\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      const userContent = [\n        '# My zsh config',\n        'export PATH=\"/custom/path:$PATH\"',\n        '',\n        '# OPENSPEC:START',\n        '# Old OpenSpec config',\n        '# OPENSPEC:END',\n        '',\n        'alias ls=\"ls -G\"',\n      ].join('\\n');\n\n      await fs.writeFile(zshrcPath, userContent);\n\n      const result = await installer.configureZshrc(completionsDir);\n\n      expect(result).toBe(true);\n\n      const content = await fs.readFile(zshrcPath, 'utf-8');\n\n      expect(content).toContain('# My zsh config');\n      expect(content).toContain('export PATH=\"/custom/path:$PATH\"');\n      expect(content).toContain('alias ls=\"ls -G\"');\n      expect(content).toContain(`fpath=(\"${completionsDir}\" $fpath)`);\n      expect(content).not.toContain('# Old OpenSpec config');\n    });\n\n    it('should return false when OPENSPEC_NO_AUTO_CONFIG is set', async () => {\n      const originalEnv = process.env.OPENSPEC_NO_AUTO_CONFIG;\n      process.env.OPENSPEC_NO_AUTO_CONFIG = '1';\n\n      const result = await installer.configureZshrc(completionsDir);\n\n      expect(result).toBe(false);\n\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      const exists = await fs.access(zshrcPath).then(() => true).catch(() => false);\n      expect(exists).toBe(false);\n\n      // Restore env\n      if (originalEnv === undefined) {\n        delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      } else {\n        process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;\n      }\n    });\n\n    it('should handle write permission errors gracefully', async () => {\n      // Create installer with path that can't be written\n      // Use a path that will fail on both Unix and Windows\n      const invalidPath = process.platform === 'win32'\n        ? 'Z:\\\\nonexistent\\\\invalid\\\\path'  // Non-existent drive letter on Windows\n        : '/root/invalid/path';  // Permission-denied path on Unix\n      const invalidInstaller = new ZshInstaller(invalidPath);\n\n      const result = await invalidInstaller.configureZshrc(completionsDir);\n\n      expect(result).toBe(false);\n    });\n  });\n\n  describe('removeZshrcConfig', () => {\n    it('should return true when .zshrc does not exist', async () => {\n      const result = await installer.removeZshrcConfig();\n      expect(result).toBe(true);\n    });\n\n    it('should return true when .zshrc exists but has no markers', async () => {\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      await fs.writeFile(zshrcPath, '# My custom config\\nalias ll=\"ls -la\"\\n');\n\n      const result = await installer.removeZshrcConfig();\n\n      expect(result).toBe(true);\n\n      // Content should be unchanged\n      const content = await fs.readFile(zshrcPath, 'utf-8');\n      expect(content).toBe('# My custom config\\nalias ll=\"ls -la\"\\n');\n    });\n\n    it('should remove markers and config when present', async () => {\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      const content = [\n        '# My config',\n        '',\n        '# OPENSPEC:START',\n        '# OpenSpec shell completions configuration',\n        'fpath=(~/.zsh/completions $fpath)',\n        'autoload -Uz compinit',\n        'compinit',\n        '# OPENSPEC:END',\n        '',\n        'alias ll=\"ls -la\"',\n      ].join('\\n');\n\n      await fs.writeFile(zshrcPath, content);\n\n      const result = await installer.removeZshrcConfig();\n\n      expect(result).toBe(true);\n\n      const newContent = await fs.readFile(zshrcPath, 'utf-8');\n\n      expect(newContent).not.toContain('# OPENSPEC:START');\n      expect(newContent).not.toContain('# OPENSPEC:END');\n      expect(newContent).not.toContain('OpenSpec shell completions');\n      expect(newContent).toContain('# My config');\n      expect(newContent).toContain('alias ll=\"ls -la\"');\n    });\n\n    it('should remove leading empty lines when markers were at top', async () => {\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      const content = [\n        '# OPENSPEC:START',\n        '# OpenSpec config',\n        '# OPENSPEC:END',\n        '',\n        '# User config below',\n      ].join('\\n');\n\n      await fs.writeFile(zshrcPath, content);\n\n      const result = await installer.removeZshrcConfig();\n\n      expect(result).toBe(true);\n\n      const newContent = await fs.readFile(zshrcPath, 'utf-8');\n\n      // Should not start with empty lines\n      expect(newContent).toBe('# User config below');\n    });\n\n    it('should handle invalid marker placement gracefully', async () => {\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n\n      // End marker before start marker\n      await fs.writeFile(zshrcPath, '# OPENSPEC:END\\n# OPENSPEC:START\\n');\n\n      const result = await installer.removeZshrcConfig();\n\n      expect(result).toBe(false);\n    });\n\n    it('should return true when only one marker is present', async () => {\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      await fs.writeFile(zshrcPath, '# OPENSPEC:START\\nsome config\\n');\n\n      const result = await installer.removeZshrcConfig();\n\n      // Should return true (markers don't exist as a pair)\n      expect(result).toBe(true);\n    });\n  });\n\n  describe('install with .zshrc auto-configuration', () => {\n    const testScript = '#compdef openspec\\n_openspec() {}\\n';\n\n    it('should auto-configure .zshrc for standard Zsh', async () => {\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n      expect(result.zshrcConfigured).toBe(true);\n\n      // Verify .zshrc was created\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      const content = await fs.readFile(zshrcPath, 'utf-8');\n\n      expect(content).toContain('# OPENSPEC:START');\n      expect(content).toContain('fpath=');\n      expect(content).toContain('compinit');\n    });\n\n    it('should configure .zshrc for Oh My Zsh when fpath is missing', async () => {\n      const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');\n      await fs.mkdir(ohMyZshPath, { recursive: true });\n\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n      expect(result.isOhMyZsh).toBe(true);\n      // Should configure .zshrc if fpath doesn't already include the directory\n      expect(result.zshrcConfigured).toBe(true);\n\n      // Verify .zshrc was created with fpath configuration\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      const exists = await fs.access(zshrcPath).then(() => true).catch(() => false);\n      expect(exists).toBe(true);\n\n      if (exists) {\n        const content = await fs.readFile(zshrcPath, 'utf-8');\n        expect(content).toContain('fpath=');\n        // Check for custom/completions or custom\\completions (Windows path separator)\n        expect(content).toMatch(/custom[/\\\\]completions/);\n      }\n    });\n\n    it('should not include manual instructions when .zshrc was auto-configured', async () => {\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n      expect(result.zshrcConfigured).toBe(true);\n      expect(result.instructions).toBeUndefined();\n    });\n\n    it('should include instructions when .zshrc auto-config fails', async () => {\n      const originalEnv = process.env.OPENSPEC_NO_AUTO_CONFIG;\n      process.env.OPENSPEC_NO_AUTO_CONFIG = '1';\n\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n      expect(result.zshrcConfigured).toBe(false);\n      expect(result.instructions).toBeDefined();\n      expect(result.instructions!.join('\\n')).toContain('fpath');\n\n      // Restore env\n      if (originalEnv === undefined) {\n        delete process.env.OPENSPEC_NO_AUTO_CONFIG;\n      } else {\n        process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;\n      }\n    });\n\n    it('should update success message when .zshrc is configured', async () => {\n      const result = await installer.install(testScript);\n\n      expect(result.success).toBe(true);\n      expect(result.message).toContain('.zshrc configured');\n    });\n  });\n\n  describe('uninstall with .zshrc cleanup', () => {\n    const testScript = '#compdef openspec\\n_openspec() {}\\n';\n\n    it('should remove .zshrc config when uninstalling', async () => {\n      // Install first (which creates .zshrc config)\n      await installer.install(testScript);\n\n      // Verify .zshrc was configured\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      let content = await fs.readFile(zshrcPath, 'utf-8');\n      expect(content).toContain('# OPENSPEC:START');\n\n      // Uninstall\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(true);\n      expect(result.message).toContain('Removed OpenSpec configuration from ~/.zshrc');\n\n      // Verify .zshrc config was removed\n      content = await fs.readFile(zshrcPath, 'utf-8');\n      expect(content).not.toContain('# OPENSPEC:START');\n    });\n\n    it('should not remove .zshrc config for Oh My Zsh users', async () => {\n      const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');\n      await fs.mkdir(ohMyZshPath, { recursive: true });\n\n      await installer.install(testScript);\n\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(true);\n      expect(result.message).not.toContain('.zshrc');\n    });\n\n    it('should succeed even if only .zshrc config is removed', async () => {\n      // Manually create .zshrc config without installing completion script\n      const zshrcPath = path.join(testHomeDir, '.zshrc');\n      await fs.writeFile(zshrcPath, '# OPENSPEC:START\\nconfig\\n# OPENSPEC:END\\n');\n\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(true);\n      expect(result.message).toContain('Removed OpenSpec configuration from ~/.zshrc');\n    });\n\n    it('should include both messages when removing script and .zshrc', async () => {\n      await installer.install(testScript);\n\n      const result = await installer.uninstall();\n\n      expect(result.success).toBe(true);\n      expect(result.message).toContain('Completion script removed');\n      expect(result.message).toContain('Removed OpenSpec configuration from ~/.zshrc');\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/config-schema.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\n\nimport {\n  getNestedValue,\n  setNestedValue,\n  deleteNestedValue,\n  coerceValue,\n  formatValueYaml,\n  validateConfig,\n  GlobalConfigSchema,\n  DEFAULT_CONFIG,\n} from '../../src/core/config-schema.js';\n\ndescribe('config-schema', () => {\n  describe('getNestedValue', () => {\n    it('should get a top-level value', () => {\n      const obj = { foo: 'bar' };\n      expect(getNestedValue(obj, 'foo')).toBe('bar');\n    });\n\n    it('should get a nested value with dot notation', () => {\n      const obj = { a: { b: { c: 'deep' } } };\n      expect(getNestedValue(obj, 'a.b.c')).toBe('deep');\n    });\n\n    it('should return undefined for non-existent path', () => {\n      const obj = { foo: 'bar' };\n      expect(getNestedValue(obj, 'baz')).toBeUndefined();\n    });\n\n    it('should return undefined for non-existent nested path', () => {\n      const obj = { a: { b: 'value' } };\n      expect(getNestedValue(obj, 'a.b.c')).toBeUndefined();\n    });\n\n    it('should return undefined when traversing through null', () => {\n      const obj = { a: null };\n      expect(getNestedValue(obj as Record<string, unknown>, 'a.b')).toBeUndefined();\n    });\n\n    it('should return undefined when traversing through primitive', () => {\n      const obj = { a: 'string' };\n      expect(getNestedValue(obj, 'a.b')).toBeUndefined();\n    });\n\n    it('should get object values', () => {\n      const obj = { a: { b: 'value' } };\n      expect(getNestedValue(obj, 'a')).toEqual({ b: 'value' });\n    });\n\n    it('should handle array values', () => {\n      const obj = { arr: [1, 2, 3] };\n      expect(getNestedValue(obj, 'arr')).toEqual([1, 2, 3]);\n    });\n  });\n\n  describe('setNestedValue', () => {\n    it('should set a top-level value', () => {\n      const obj: Record<string, unknown> = {};\n      setNestedValue(obj, 'foo', 'bar');\n      expect(obj.foo).toBe('bar');\n    });\n\n    it('should set a nested value', () => {\n      const obj: Record<string, unknown> = {};\n      setNestedValue(obj, 'a.b.c', 'deep');\n      expect((obj.a as Record<string, unknown>).b).toEqual({ c: 'deep' });\n    });\n\n    it('should create intermediate objects', () => {\n      const obj: Record<string, unknown> = {};\n      setNestedValue(obj, 'x.y.z', 'value');\n      expect(obj).toEqual({ x: { y: { z: 'value' } } });\n    });\n\n    it('should overwrite existing values', () => {\n      const obj: Record<string, unknown> = { a: 'old' };\n      setNestedValue(obj, 'a', 'new');\n      expect(obj.a).toBe('new');\n    });\n\n    it('should overwrite primitive with object when setting nested path', () => {\n      const obj: Record<string, unknown> = { a: 'string' };\n      setNestedValue(obj, 'a.b', 'value');\n      expect(obj.a).toEqual({ b: 'value' });\n    });\n\n    it('should preserve other keys when setting nested value', () => {\n      const obj: Record<string, unknown> = { a: { existing: 'keep' } };\n      setNestedValue(obj, 'a.new', 'added');\n      expect(obj.a).toEqual({ existing: 'keep', new: 'added' });\n    });\n  });\n\n  describe('deleteNestedValue', () => {\n    it('should delete a top-level key', () => {\n      const obj: Record<string, unknown> = { foo: 'bar', baz: 'qux' };\n      const result = deleteNestedValue(obj, 'foo');\n      expect(result).toBe(true);\n      expect(obj).toEqual({ baz: 'qux' });\n    });\n\n    it('should delete a nested key', () => {\n      const obj: Record<string, unknown> = { a: { b: 'value', c: 'keep' } };\n      const result = deleteNestedValue(obj, 'a.b');\n      expect(result).toBe(true);\n      expect(obj.a).toEqual({ c: 'keep' });\n    });\n\n    it('should return false for non-existent key', () => {\n      const obj: Record<string, unknown> = { foo: 'bar' };\n      const result = deleteNestedValue(obj, 'baz');\n      expect(result).toBe(false);\n    });\n\n    it('should return false for non-existent nested path', () => {\n      const obj: Record<string, unknown> = { a: { b: 'value' } };\n      const result = deleteNestedValue(obj, 'a.c');\n      expect(result).toBe(false);\n    });\n\n    it('should return false when intermediate path does not exist', () => {\n      const obj: Record<string, unknown> = { a: 'string' };\n      const result = deleteNestedValue(obj, 'a.b.c');\n      expect(result).toBe(false);\n    });\n  });\n\n  describe('coerceValue', () => {\n    it('should coerce \"true\" to boolean true', () => {\n      expect(coerceValue('true')).toBe(true);\n    });\n\n    it('should coerce \"false\" to boolean false', () => {\n      expect(coerceValue('false')).toBe(false);\n    });\n\n    it('should coerce integer string to number', () => {\n      expect(coerceValue('42')).toBe(42);\n    });\n\n    it('should coerce float string to number', () => {\n      expect(coerceValue('3.14')).toBe(3.14);\n    });\n\n    it('should coerce negative number string to number', () => {\n      expect(coerceValue('-10')).toBe(-10);\n    });\n\n    it('should keep regular strings as strings', () => {\n      expect(coerceValue('hello')).toBe('hello');\n    });\n\n    it('should keep strings that start with numbers but are not numbers', () => {\n      expect(coerceValue('123abc')).toBe('123abc');\n    });\n\n    it('should keep empty string as string', () => {\n      expect(coerceValue('')).toBe('');\n    });\n\n    it('should keep whitespace-only string as string', () => {\n      expect(coerceValue('   ')).toBe('   ');\n    });\n\n    it('should force string when forceString is true', () => {\n      expect(coerceValue('true', true)).toBe('true');\n      expect(coerceValue('42', true)).toBe('42');\n      expect(coerceValue('hello', true)).toBe('hello');\n    });\n\n    it('should not coerce Infinity to number (not finite)', () => {\n      // Infinity is not a useful config value, so we keep it as string\n      expect(coerceValue('Infinity')).toBe('Infinity');\n    });\n\n    it('should handle scientific notation', () => {\n      expect(coerceValue('1e10')).toBe(1e10);\n    });\n  });\n\n  describe('formatValueYaml', () => {\n    it('should format null as \"null\"', () => {\n      expect(formatValueYaml(null)).toBe('null');\n    });\n\n    it('should format undefined as \"null\"', () => {\n      expect(formatValueYaml(undefined)).toBe('null');\n    });\n\n    it('should format boolean as string', () => {\n      expect(formatValueYaml(true)).toBe('true');\n      expect(formatValueYaml(false)).toBe('false');\n    });\n\n    it('should format number as string', () => {\n      expect(formatValueYaml(42)).toBe('42');\n      expect(formatValueYaml(3.14)).toBe('3.14');\n    });\n\n    it('should format string as-is', () => {\n      expect(formatValueYaml('hello')).toBe('hello');\n    });\n\n    it('should format empty array as \"[]\"', () => {\n      expect(formatValueYaml([])).toBe('[]');\n    });\n\n    it('should format empty object as \"{}\"', () => {\n      expect(formatValueYaml({})).toBe('{}');\n    });\n\n    it('should format object with key-value pairs', () => {\n      const result = formatValueYaml({ foo: 'bar' });\n      expect(result).toBe('foo: bar');\n    });\n\n    it('should format nested objects with indentation', () => {\n      const result = formatValueYaml({ a: { b: 'value' } });\n      expect(result).toContain('a:');\n      expect(result).toContain('b: value');\n    });\n  });\n\n  describe('validateConfig', () => {\n    it('should accept valid config with featureFlags', () => {\n      const result = validateConfig({ featureFlags: { test: true } });\n      expect(result.success).toBe(true);\n    });\n\n    it('should accept empty featureFlags', () => {\n      const result = validateConfig({ featureFlags: {} });\n      expect(result.success).toBe(true);\n    });\n\n    it('should accept config without featureFlags (uses default)', () => {\n      const result = validateConfig({});\n      expect(result.success).toBe(true);\n    });\n\n    it('should accept unknown fields (passthrough)', () => {\n      const result = validateConfig({ featureFlags: {}, unknownField: 'value' });\n      expect(result.success).toBe(true);\n    });\n\n    it('should accept unknown fields with various types', () => {\n      const result = validateConfig({\n        featureFlags: {},\n        futureStringField: 'value',\n        futureNumberField: 123,\n        futureObjectField: { nested: 'data' },\n      });\n      expect(result.success).toBe(true);\n    });\n\n    it('should reject non-boolean values in featureFlags', () => {\n      const result = validateConfig({ featureFlags: { test: 'string' } });\n      expect(result.success).toBe(false);\n      expect(result.error).toBeDefined();\n    });\n\n    it('should include path in error message for invalid featureFlags', () => {\n      const result = validateConfig({ featureFlags: { someFlag: 'notABoolean' } });\n      expect(result.success).toBe(false);\n      expect(result.error).toContain('featureFlags');\n    });\n\n    it('should reject non-object featureFlags', () => {\n      const result = validateConfig({ featureFlags: 'string' });\n      expect(result.success).toBe(false);\n    });\n\n    it('should reject number values in featureFlags', () => {\n      const result = validateConfig({ featureFlags: { flag: 123 } });\n      expect(result.success).toBe(false);\n    });\n  });\n\n  describe('config set simulation', () => {\n    // These tests simulate the full config set flow: coerce value → set nested → validate\n\n    it('should accept setting unknown top-level key (forward compatibility)', () => {\n      const config: Record<string, unknown> = { featureFlags: {} };\n      const value = coerceValue('123');\n      setNestedValue(config, 'someFutureKey', value);\n\n      const result = validateConfig(config);\n      expect(result.success).toBe(true);\n      expect(config.someFutureKey).toBe(123);\n    });\n\n    it('should reject setting non-boolean to featureFlags', () => {\n      const config: Record<string, unknown> = { featureFlags: {} };\n      const value = coerceValue('notABoolean'); // stays as string\n      setNestedValue(config, 'featureFlags.someFlag', value);\n\n      const result = validateConfig(config);\n      expect(result.success).toBe(false);\n      expect(result.error).toContain('featureFlags');\n    });\n\n    it('should accept setting boolean to featureFlags', () => {\n      const config: Record<string, unknown> = { featureFlags: {} };\n      const value = coerceValue('true'); // coerces to boolean\n      setNestedValue(config, 'featureFlags.newFlag', value);\n\n      const result = validateConfig(config);\n      expect(result.success).toBe(true);\n      expect((config.featureFlags as Record<string, unknown>).newFlag).toBe(true);\n    });\n\n    it('should create featureFlags object when setting nested flag', () => {\n      const config: Record<string, unknown> = {};\n      const value = coerceValue('false');\n      setNestedValue(config, 'featureFlags.experimental', value);\n\n      const result = validateConfig(config);\n      expect(result.success).toBe(true);\n      expect((config.featureFlags as Record<string, unknown>).experimental).toBe(false);\n    });\n  });\n\n  describe('GlobalConfigSchema', () => {\n    it('should parse valid config', () => {\n      const result = GlobalConfigSchema.safeParse({ featureFlags: { test: true } });\n      expect(result.success).toBe(true);\n    });\n\n    it('should provide defaults for missing featureFlags', () => {\n      const result = GlobalConfigSchema.parse({});\n      expect(result.featureFlags).toEqual({});\n    });\n  });\n\n  describe('DEFAULT_CONFIG', () => {\n    it('should have empty featureFlags', () => {\n      expect(DEFAULT_CONFIG.featureFlags).toEqual({});\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/converters/json-converter.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { JsonConverter } from '../../../src/core/converters/json-converter.js';\n\ndescribe('JsonConverter', () => {\n  const testDir = path.join(process.cwd(), 'test-json-converter-tmp');\n  const converter = new JsonConverter();\n  \n  beforeEach(async () => {\n    await fs.mkdir(testDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('convertSpecToJson', () => {\n    it('should convert a spec to JSON format', async () => {\n      const specContent = `# User Authentication Spec\n\n## Purpose\nThis specification defines the requirements for user authentication.\n\n## Requirements\n\n### The system SHALL provide secure user authentication\nUsers need to be able to log in securely.\n\n#### Scenario: Successful login\nGiven a user with valid credentials\nWhen they submit the login form\nThen they are authenticated`;\n\n      const specPath = path.join(testDir, 'spec.md');\n      await fs.writeFile(specPath, specContent);\n      \n      const json = converter.convertSpecToJson(specPath);\n      const parsed = JSON.parse(json);\n      \n      expect(parsed.name).toBe('spec');\n      expect(parsed.overview).toContain('user authentication');\n      expect(parsed.requirements).toHaveLength(1);\n      expect(parsed.requirements[0].scenarios).toHaveLength(1);\n      expect(parsed.metadata).toBeDefined();\n      expect(parsed.metadata.format).toBe('openspec');\n      expect(parsed.metadata.sourcePath).toBe(specPath);\n    });\n\n    it('should extract spec name from directory structure', async () => {\n      const specsDir = path.join(testDir, 'specs', 'user-auth');\n      await fs.mkdir(specsDir, { recursive: true });\n      \n      const specContent = `# User Auth\n\n## Purpose\nAuth spec overview\n\n## Requirements\n\n### The system SHALL authenticate users\n\n#### Scenario: Login\nGiven a user\nWhen they login\nThen authenticated`;\n\n      const specPath = path.join(specsDir, 'spec.md');\n      await fs.writeFile(specPath, specContent);\n      \n      const json = converter.convertSpecToJson(specPath);\n      const parsed = JSON.parse(json);\n      \n      expect(parsed.name).toBe('user-auth');\n    });\n  });\n\n  describe('convertChangeToJson', () => {\n    it('should convert a change to JSON format', async () => {\n      const changeContent = `# Add User Authentication\n\n## Why\nWe need to implement user authentication to secure the application and protect user data from unauthorized access.\n\n## What Changes\n- **user-auth:** Add new user authentication specification\n- **api-endpoints:** Modify to include authentication endpoints`;\n\n      const changePath = path.join(testDir, 'change.md');\n      await fs.writeFile(changePath, changeContent);\n      \n      const json = await converter.convertChangeToJson(changePath);\n      const parsed = JSON.parse(json);\n      \n      expect(parsed.name).toBe('change');\n      expect(parsed.why).toContain('secure the application');\n      expect(parsed.deltas).toHaveLength(2);\n      expect(parsed.deltas[0].spec).toBe('user-auth');\n      expect(parsed.deltas[0].operation).toBe('ADDED');\n      expect(parsed.metadata).toBeDefined();\n      expect(parsed.metadata.format).toBe('openspec-change');\n      expect(parsed.metadata.sourcePath).toBe(changePath);\n    });\n\n    it('should extract change name from directory structure', async () => {\n      const changesDir = path.join(testDir, 'changes', 'add-auth');\n      await fs.mkdir(changesDir, { recursive: true });\n      \n      const changeContent = `# Add Auth\n\n## Why\nWe need authentication for security reasons and to protect user data properly.\n\n## What Changes\n- **auth:** Add authentication`;\n\n      const changePath = path.join(changesDir, 'proposal.md');\n      await fs.writeFile(changePath, changeContent);\n      \n      const json = await converter.convertChangeToJson(changePath);\n      const parsed = JSON.parse(json);\n      \n      expect(parsed.name).toBe('add-auth');\n    });\n  });\n\n  describe('JSON formatting', () => {\n    it('should produce properly formatted JSON with indentation', async () => {\n      const specContent = `# Test\n\n## Purpose\nTest overview\n\n## Requirements\n\n### The system SHALL test\n\n#### Scenario: Test\nGiven test\nWhen action\nThen result`;\n\n      const specPath = path.join(testDir, 'spec.md');\n      await fs.writeFile(specPath, specContent);\n      \n      const json = converter.convertSpecToJson(specPath);\n      \n      // Check for proper indentation (2 spaces)\n      expect(json).toContain('  \"name\"');\n      expect(json).toContain('  \"overview\"');\n      expect(json).toContain('  \"requirements\"');\n      \n      // Check it's valid JSON\n      expect(() => JSON.parse(json)).not.toThrow();\n    });\n\n    it('should handle special characters in content', async () => {\n      const specContent = `# Test\n\n## Purpose\nThis has \"quotes\" and \\\\ backslashes and\nnewlines\n\n## Requirements\n\n### The system SHALL handle \"special\" characters\n\n#### Scenario: Special chars\nGiven a string with \"quotes\"\nWhen processing \\\\ backslash\nThen handle correctly`;\n\n      const specPath = path.join(testDir, 'spec.md');\n      await fs.writeFile(specPath, specContent);\n      \n      const json = converter.convertSpecToJson(specPath);\n      const parsed = JSON.parse(json);\n      \n      expect(parsed.overview).toContain('\"quotes\"');\n      expect(parsed.overview).toContain('\\\\');\n      expect(parsed.requirements[0].text).toContain('\"special\"');\n    });\n  });\n});"
  },
  {
    "path": "test/core/global-config.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\n\nimport {\n  getGlobalConfigDir,\n  getGlobalConfigPath,\n  getGlobalConfig,\n  saveGlobalConfig,\n  GLOBAL_CONFIG_DIR_NAME,\n  GLOBAL_CONFIG_FILE_NAME\n} from '../../src/core/global-config.js';\nimport type { Profile, Delivery } from '../../src/core/global-config.js';\n\ndescribe('global-config', () => {\n  let tempDir: string;\n  let originalEnv: NodeJS.ProcessEnv;\n  let consoleErrorSpy: ReturnType<typeof vi.spyOn>;\n\n  beforeEach(() => {\n    // Create temp directory for tests\n    tempDir = path.join(os.tmpdir(), `openspec-global-config-test-${Date.now()}`);\n    fs.mkdirSync(tempDir, { recursive: true });\n\n    // Save original env\n    originalEnv = { ...process.env };\n\n    // Spy on console.error for warning tests\n    consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n  });\n\n  afterEach(() => {\n    // Restore original env\n    process.env = originalEnv;\n\n    // Clean up temp directory\n    fs.rmSync(tempDir, { recursive: true, force: true });\n\n    // Restore console.error\n    consoleErrorSpy.mockRestore();\n  });\n\n  describe('constants', () => {\n    it('should export correct directory name', () => {\n      expect(GLOBAL_CONFIG_DIR_NAME).toBe('openspec');\n    });\n\n    it('should export correct file name', () => {\n      expect(GLOBAL_CONFIG_FILE_NAME).toBe('config.json');\n    });\n  });\n\n  describe('getGlobalConfigDir', () => {\n    it('should use XDG_CONFIG_HOME when set', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n\n      const result = getGlobalConfigDir();\n\n      expect(result).toBe(path.join(tempDir, 'openspec'));\n    });\n\n    it('should fall back to ~/.config on Unix/macOS without XDG_CONFIG_HOME', () => {\n      delete process.env.XDG_CONFIG_HOME;\n\n      const result = getGlobalConfigDir();\n\n      // On non-Windows, should use ~/.config/openspec\n      if (os.platform() !== 'win32') {\n        expect(result).toBe(path.join(os.homedir(), '.config', 'openspec'));\n      }\n    });\n\n    it('should use APPDATA on Windows when XDG_CONFIG_HOME is not set', () => {\n      // This test only makes sense conceptually - we can't change os.platform()\n      // But we can verify the APPDATA logic by checking the code path\n      if (os.platform() === 'win32') {\n        delete process.env.XDG_CONFIG_HOME;\n        const appData = process.env.APPDATA;\n        if (appData) {\n          const result = getGlobalConfigDir();\n          expect(result).toBe(path.join(appData, 'openspec'));\n        }\n      }\n    });\n  });\n\n  describe('getGlobalConfigPath', () => {\n    it('should return path to config.json in config directory', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n\n      const result = getGlobalConfigPath();\n\n      expect(result).toBe(path.join(tempDir, 'openspec', 'config.json'));\n    });\n  });\n\n  describe('getGlobalConfig', () => {\n    it('should return defaults when config file does not exist', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n\n      const config = getGlobalConfig();\n\n      expect(config).toEqual({ featureFlags: {}, profile: 'core', delivery: 'both' });\n    });\n\n    it('should not create directory when reading non-existent config', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n      const configDir = path.join(tempDir, 'openspec');\n\n      getGlobalConfig();\n\n      expect(fs.existsSync(configDir)).toBe(false);\n    });\n\n    it('should load valid config from file', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n      const configDir = path.join(tempDir, 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, JSON.stringify({\n        featureFlags: { testFlag: true, anotherFlag: false }\n      }));\n\n      const config = getGlobalConfig();\n\n      expect(config.featureFlags).toEqual({ testFlag: true, anotherFlag: false });\n    });\n\n    it('should return defaults for invalid JSON', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n      const configDir = path.join(tempDir, 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, '{ invalid json }');\n\n      const config = getGlobalConfig();\n\n      expect(config).toEqual({ featureFlags: {}, profile: 'core', delivery: 'both' });\n    });\n\n    it('should log warning for invalid JSON', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n      const configDir = path.join(tempDir, 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, '{ invalid json }');\n\n      getGlobalConfig();\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Invalid JSON')\n      );\n    });\n\n    it('should preserve unknown fields from config file', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n      const configDir = path.join(tempDir, 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, JSON.stringify({\n        featureFlags: { x: true },\n        unknownField: 'preserved',\n        futureOption: 123\n      }));\n\n      const config = getGlobalConfig();\n\n      expect((config as any).unknownField).toBe('preserved');\n      expect((config as any).futureOption).toBe(123);\n    });\n\n    it('should merge loaded config with defaults', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n      const configDir = path.join(tempDir, 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      // Config with only some fields\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, JSON.stringify({\n        featureFlags: { customFlag: true }\n      }));\n\n      const config = getGlobalConfig();\n\n      // Should have the custom flag\n      expect(config.featureFlags?.customFlag).toBe(true);\n    });\n\n    describe('schema evolution', () => {\n      it('should add default profile and delivery when loading old config without them', () => {\n        process.env.XDG_CONFIG_HOME = tempDir;\n        const configDir = path.join(tempDir, 'openspec');\n        const configPath = path.join(configDir, 'config.json');\n\n        // Simulate a pre-existing config that only has featureFlags\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(configPath, JSON.stringify({\n          featureFlags: { existingFlag: true }\n        }));\n\n        const config = getGlobalConfig();\n\n        expect(config.profile).toBe('core');\n        expect(config.delivery).toBe('both');\n        expect(config.workflows).toBeUndefined();\n        expect(config.featureFlags?.existingFlag).toBe(true);\n      });\n\n      it('should preserve explicit profile and delivery values from config', () => {\n        process.env.XDG_CONFIG_HOME = tempDir;\n        const configDir = path.join(tempDir, 'openspec');\n        const configPath = path.join(configDir, 'config.json');\n\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(configPath, JSON.stringify({\n          featureFlags: {},\n          profile: 'custom',\n          delivery: 'skills',\n          workflows: ['propose', 'review']\n        }));\n\n        const config = getGlobalConfig();\n\n        expect(config.profile).toBe('custom');\n        expect(config.delivery).toBe('skills');\n        expect(config.workflows).toEqual(['propose', 'review']);\n      });\n\n      it('should round-trip new fields correctly', () => {\n        process.env.XDG_CONFIG_HOME = tempDir;\n        const originalConfig = {\n          featureFlags: { flag1: true },\n          profile: 'custom' as Profile,\n          delivery: 'commands' as Delivery,\n          workflows: ['propose']\n        };\n\n        saveGlobalConfig(originalConfig);\n        const loadedConfig = getGlobalConfig();\n\n        expect(loadedConfig.profile).toBe('custom');\n        expect(loadedConfig.delivery).toBe('commands');\n        expect(loadedConfig.workflows).toEqual(['propose']);\n      });\n\n      it('should default workflows to undefined when not in config', () => {\n        process.env.XDG_CONFIG_HOME = tempDir;\n        const configDir = path.join(tempDir, 'openspec');\n        const configPath = path.join(configDir, 'config.json');\n\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(configPath, JSON.stringify({\n          featureFlags: {},\n          profile: 'core',\n          delivery: 'both'\n        }));\n\n        const config = getGlobalConfig();\n\n        expect(config.workflows).toBeUndefined();\n      });\n    });\n  });\n\n  describe('saveGlobalConfig', () => {\n    it('should create directory if it does not exist', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n      const configDir = path.join(tempDir, 'openspec');\n\n      saveGlobalConfig({ featureFlags: { test: true } });\n\n      expect(fs.existsSync(configDir)).toBe(true);\n    });\n\n    it('should write config to file', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n      const configPath = path.join(tempDir, 'openspec', 'config.json');\n\n      saveGlobalConfig({ featureFlags: { myFlag: true } });\n\n      const content = fs.readFileSync(configPath, 'utf-8');\n      const parsed = JSON.parse(content);\n      expect(parsed.featureFlags.myFlag).toBe(true);\n    });\n\n    it('should overwrite existing config file', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n      const configDir = path.join(tempDir, 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      // Create initial config\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, JSON.stringify({ featureFlags: { old: true } }));\n\n      // Overwrite\n      saveGlobalConfig({ featureFlags: { new: true } });\n\n      const content = fs.readFileSync(configPath, 'utf-8');\n      const parsed = JSON.parse(content);\n      expect(parsed.featureFlags.new).toBe(true);\n      expect(parsed.featureFlags.old).toBeUndefined();\n    });\n\n    it('should write formatted JSON with trailing newline', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n      const configPath = path.join(tempDir, 'openspec', 'config.json');\n\n      saveGlobalConfig({ featureFlags: {} });\n\n      const content = fs.readFileSync(configPath, 'utf-8');\n      expect(content).toContain('\\n');\n      expect(content.endsWith('\\n')).toBe(true);\n    });\n\n    it('should round-trip config correctly', () => {\n      process.env.XDG_CONFIG_HOME = tempDir;\n      const originalConfig = {\n        featureFlags: { flag1: true, flag2: false }\n      };\n\n      saveGlobalConfig(originalConfig);\n      const loadedConfig = getGlobalConfig();\n\n      expect(loadedConfig.featureFlags).toEqual(originalConfig.featureFlags);\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/init.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { InitCommand } from '../../src/core/init.js';\nimport { saveGlobalConfig, getGlobalConfig } from '../../src/core/global-config.js';\n\nconst { confirmMock, showWelcomeScreenMock, searchableMultiSelectMock } = vi.hoisted(() => ({\n  confirmMock: vi.fn(),\n  showWelcomeScreenMock: vi.fn().mockResolvedValue(undefined),\n  searchableMultiSelectMock: vi.fn(),\n}));\n\nvi.mock('@inquirer/prompts', () => ({\n  confirm: confirmMock,\n}));\n\nvi.mock('../../src/ui/welcome-screen.js', () => ({\n  showWelcomeScreen: showWelcomeScreenMock,\n}));\n\nvi.mock('../../src/prompts/searchable-multi-select.js', () => ({\n  searchableMultiSelect: searchableMultiSelectMock,\n}));\n\ndescribe('InitCommand', () => {\n  let testDir: string;\n  let configTempDir: string;\n  let originalEnv: NodeJS.ProcessEnv;\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-init-test-${Date.now()}`);\n    await fs.mkdir(testDir, { recursive: true });\n    originalEnv = { ...process.env };\n    // Use a temp dir for global config to avoid reading real config\n    configTempDir = path.join(os.tmpdir(), `openspec-config-init-${Date.now()}`);\n    await fs.mkdir(configTempDir, { recursive: true });\n    process.env.XDG_CONFIG_HOME = configTempDir;\n\n    // Mock console.log to suppress output during tests\n    vi.spyOn(console, 'log').mockImplementation(() => { });\n    confirmMock.mockReset();\n    confirmMock.mockResolvedValue(true);\n    showWelcomeScreenMock.mockClear();\n    searchableMultiSelectMock.mockReset();\n  });\n\n  afterEach(async () => {\n    process.env = originalEnv;\n    await fs.rm(testDir, { recursive: true, force: true });\n    await fs.rm(configTempDir, { recursive: true, force: true });\n    vi.restoreAllMocks();\n  });\n\n  describe('execute with --tools flag', () => {\n    it('should create OpenSpec directory structure', async () => {\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n\n      await initCommand.execute(testDir);\n\n      const openspecPath = path.join(testDir, 'openspec');\n      expect(await directoryExists(openspecPath)).toBe(true);\n      expect(await directoryExists(path.join(openspecPath, 'specs'))).toBe(true);\n      expect(await directoryExists(path.join(openspecPath, 'changes'))).toBe(true);\n      expect(await directoryExists(path.join(openspecPath, 'changes', 'archive'))).toBe(true);\n    });\n\n    it('should create config.yaml with default schema', async () => {\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n\n      await initCommand.execute(testDir);\n\n      const configPath = path.join(testDir, 'openspec', 'config.yaml');\n      expect(await fileExists(configPath)).toBe(true);\n\n      const content = await fs.readFile(configPath, 'utf-8');\n      expect(content).toContain('schema: spec-driven');\n    });\n\n    it('should create core profile skills for Claude Code by default', async () => {\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n\n      await initCommand.execute(testDir);\n\n      // Core profile: propose, explore, apply, archive\n      const coreSkillNames = [\n        'openspec-propose',\n        'openspec-explore',\n        'openspec-apply-change',\n        'openspec-archive-change',\n      ];\n\n      for (const skillName of coreSkillNames) {\n        const skillFile = path.join(testDir, '.claude', 'skills', skillName, 'SKILL.md');\n        expect(await fileExists(skillFile)).toBe(true);\n\n        const content = await fs.readFile(skillFile, 'utf-8');\n        expect(content).toContain('---');\n        expect(content).toContain('name:');\n        expect(content).toContain('description:');\n      }\n\n      // Non-core skills should NOT be created\n      const nonCoreSkillNames = [\n        'openspec-new-change',\n        'openspec-continue-change',\n        'openspec-ff-change',\n        'openspec-sync-specs',\n        'openspec-bulk-archive-change',\n        'openspec-verify-change',\n      ];\n\n      for (const skillName of nonCoreSkillNames) {\n        const skillFile = path.join(testDir, '.claude', 'skills', skillName, 'SKILL.md');\n        expect(await fileExists(skillFile)).toBe(false);\n      }\n    });\n\n    it('should create core profile commands for Claude Code by default', async () => {\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n\n      await initCommand.execute(testDir);\n\n      // Core profile: propose, explore, apply, archive\n      const coreCommandNames = [\n        'opsx/propose.md',\n        'opsx/explore.md',\n        'opsx/apply.md',\n        'opsx/archive.md',\n      ];\n\n      for (const cmdName of coreCommandNames) {\n        const cmdFile = path.join(testDir, '.claude', 'commands', cmdName);\n        expect(await fileExists(cmdFile)).toBe(true);\n      }\n\n      // Non-core commands should NOT be created\n      const nonCoreCommandNames = [\n        'opsx/new.md',\n        'opsx/continue.md',\n        'opsx/ff.md',\n        'opsx/sync.md',\n        'opsx/bulk-archive.md',\n        'opsx/verify.md',\n      ];\n\n      for (const cmdName of nonCoreCommandNames) {\n        const cmdFile = path.join(testDir, '.claude', 'commands', cmdName);\n        expect(await fileExists(cmdFile)).toBe(false);\n      }\n    });\n\n    it('should create skills in Cursor skills directory', async () => {\n      const initCommand = new InitCommand({ tools: 'cursor', force: true });\n\n      await initCommand.execute(testDir);\n\n      const skillFile = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');\n      expect(await fileExists(skillFile)).toBe(true);\n    });\n\n    it('should create skills in Windsurf skills directory', async () => {\n      const initCommand = new InitCommand({ tools: 'windsurf', force: true });\n\n      await initCommand.execute(testDir);\n\n      const skillFile = path.join(testDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md');\n      expect(await fileExists(skillFile)).toBe(true);\n    });\n\n    it('should create skills for multiple tools at once', async () => {\n      const initCommand = new InitCommand({ tools: 'claude,cursor', force: true });\n\n      await initCommand.execute(testDir);\n\n      const claudeSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const cursorSkill = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');\n\n      expect(await fileExists(claudeSkill)).toBe(true);\n      expect(await fileExists(cursorSkill)).toBe(true);\n    });\n\n    it('should select all tools with --tools all option', async () => {\n      const initCommand = new InitCommand({ tools: 'all', force: true });\n\n      await initCommand.execute(testDir);\n\n      // Check a few representative tools\n      const claudeSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const cursorSkill = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');\n      const windsurfSkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md');\n\n      expect(await fileExists(claudeSkill)).toBe(true);\n      expect(await fileExists(cursorSkill)).toBe(true);\n      expect(await fileExists(windsurfSkill)).toBe(true);\n    });\n\n    it('should skip tool configuration with --tools none option', async () => {\n      const initCommand = new InitCommand({ tools: 'none', force: true });\n\n      await initCommand.execute(testDir);\n\n      // Should create OpenSpec structure but no skills\n      const openspecPath = path.join(testDir, 'openspec');\n      expect(await directoryExists(openspecPath)).toBe(true);\n\n      // No tool-specific directories should be created\n      const claudeSkillsDir = path.join(testDir, '.claude', 'skills');\n      expect(await directoryExists(claudeSkillsDir)).toBe(false);\n    });\n\n    it('should throw error for invalid tool names', async () => {\n      const initCommand = new InitCommand({ tools: 'invalid-tool', force: true });\n\n      await expect(initCommand.execute(testDir)).rejects.toThrow(/Invalid tool\\(s\\): invalid-tool/);\n    });\n\n    it('should handle comma-separated tool names with spaces', async () => {\n      const initCommand = new InitCommand({ tools: 'claude, cursor', force: true });\n\n      await initCommand.execute(testDir);\n\n      const claudeSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const cursorSkill = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');\n\n      expect(await fileExists(claudeSkill)).toBe(true);\n      expect(await fileExists(cursorSkill)).toBe(true);\n    });\n\n    it('should reject combining reserved keywords with explicit tool ids', async () => {\n      const initCommand = new InitCommand({ tools: 'all,claude', force: true });\n\n      await expect(initCommand.execute(testDir)).rejects.toThrow(\n        /Cannot combine reserved values \"all\" or \"none\" with specific tool IDs/\n      );\n    });\n\n    it('should not create config.yaml if it already exists', async () => {\n      // Pre-create config.yaml\n      const openspecDir = path.join(testDir, 'openspec');\n      await fs.mkdir(openspecDir, { recursive: true });\n      const configPath = path.join(openspecDir, 'config.yaml');\n      const existingContent = 'schema: custom-schema\\n';\n      await fs.writeFile(configPath, existingContent);\n\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n      await initCommand.execute(testDir);\n\n      const content = await fs.readFile(configPath, 'utf-8');\n      expect(content).toBe(existingContent);\n    });\n\n    it('should handle non-existent target directory', async () => {\n      const newDir = path.join(testDir, 'new-project');\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n\n      await initCommand.execute(newDir);\n\n      const openspecPath = path.join(newDir, 'openspec');\n      expect(await directoryExists(openspecPath)).toBe(true);\n    });\n\n    it('should work in extend mode (re-running init)', async () => {\n      const initCommand1 = new InitCommand({ tools: 'claude', force: true });\n      await initCommand1.execute(testDir);\n\n      // Run init again with a different tool\n      const initCommand2 = new InitCommand({ tools: 'cursor', force: true });\n      await initCommand2.execute(testDir);\n\n      // Both tools should have skills\n      const claudeSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const cursorSkill = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');\n\n      expect(await fileExists(claudeSkill)).toBe(true);\n      expect(await fileExists(cursorSkill)).toBe(true);\n    });\n\n    it('should refresh skills on re-run for the same tool', async () => {\n      const initCommand1 = new InitCommand({ tools: 'claude', force: true });\n      await initCommand1.execute(testDir);\n\n      const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const originalContent = await fs.readFile(skillFile, 'utf-8');\n\n      // Modify the file\n      await fs.writeFile(skillFile, '# Modified content\\n');\n\n      // Run init again\n      const initCommand2 = new InitCommand({ tools: 'claude', force: true });\n      await initCommand2.execute(testDir);\n\n      const newContent = await fs.readFile(skillFile, 'utf-8');\n      expect(newContent).toBe(originalContent);\n    });\n  });\n\n  describe('skill content validation', () => {\n    it('should generate valid SKILL.md with YAML frontmatter', async () => {\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n      await initCommand.execute(testDir);\n\n      const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const content = await fs.readFile(skillFile, 'utf-8');\n\n      // Should have YAML frontmatter\n      expect(content).toMatch(/^---\\n/);\n      expect(content).toContain('name: openspec-explore');\n      expect(content).toContain('description:');\n      expect(content).toContain('license:');\n      expect(content).toContain('compatibility:');\n      expect(content).toContain('metadata:');\n      expect(content).toMatch(/---\\n\\n/); // End of frontmatter\n    });\n\n    it('should include explore mode instructions', async () => {\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n      await initCommand.execute(testDir);\n\n      const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const content = await fs.readFile(skillFile, 'utf-8');\n\n      expect(content).toContain('Enter explore mode');\n      expect(content).toContain('thinking partner');\n    });\n\n    it('should include propose skill instructions', async () => {\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n      await initCommand.execute(testDir);\n\n      const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md');\n      const content = await fs.readFile(skillFile, 'utf-8');\n\n      expect(content).toContain('name: openspec-propose');\n    });\n\n    it('should include apply-change skill instructions', async () => {\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n      await initCommand.execute(testDir);\n\n      const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-apply-change', 'SKILL.md');\n      const content = await fs.readFile(skillFile, 'utf-8');\n\n      expect(content).toContain('name: openspec-apply-change');\n    });\n\n    it('should embed generatedBy version in skill files', async () => {\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n      await initCommand.execute(testDir);\n\n      const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const content = await fs.readFile(skillFile, 'utf-8');\n\n      // Should contain generatedBy field with a version string\n      expect(content).toMatch(/generatedBy:\\s*[\"']?\\d+\\.\\d+\\.\\d+[\"']?/);\n    });\n  });\n\n  describe('command generation', () => {\n    it('should generate Claude Code commands with correct format', async () => {\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n      await initCommand.execute(testDir);\n\n      const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md');\n      const content = await fs.readFile(cmdFile, 'utf-8');\n\n      // Claude commands use YAML frontmatter\n      expect(content).toMatch(/^---\\n/);\n      expect(content).toContain('name:');\n      expect(content).toContain('description:');\n    });\n\n    it('should generate Cursor commands with correct format', async () => {\n      const initCommand = new InitCommand({ tools: 'cursor', force: true });\n      await initCommand.execute(testDir);\n\n      const cmdFile = path.join(testDir, '.cursor', 'commands', 'opsx-explore.md');\n      expect(await fileExists(cmdFile)).toBe(true);\n\n      const content = await fs.readFile(cmdFile, 'utf-8');\n      expect(content).toMatch(/^---\\n/);\n    });\n  });\n\n  describe('error handling', () => {\n    it('should provide helpful error for insufficient permissions', async () => {\n      // Mock the permission check to fail\n      const readOnlyDir = path.join(testDir, 'readonly');\n      await fs.mkdir(readOnlyDir);\n\n      const originalWriteFile = fs.writeFile;\n      vi.spyOn(fs, 'writeFile').mockImplementation(\n        async (filePath: any, ...args: any[]) => {\n          if (\n            typeof filePath === 'string' &&\n            filePath.includes('.openspec-test-')\n          ) {\n            throw new Error('EACCES: permission denied');\n          }\n          return originalWriteFile.call(fs, filePath, ...args);\n        }\n      );\n\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n      await expect(initCommand.execute(readOnlyDir)).rejects.toThrow(/Insufficient permissions/);\n    });\n\n    it('should throw error in non-interactive mode without --tools flag and no detected tools', async () => {\n      const initCommand = new InitCommand({ interactive: false });\n\n      await expect(initCommand.execute(testDir)).rejects.toThrow(/No tools detected and no --tools flag/);\n    });\n  });\n\n  describe('tool-specific adapters', () => {\n    it('should generate Gemini CLI commands as TOML files', async () => {\n      const initCommand = new InitCommand({ tools: 'gemini', force: true });\n      await initCommand.execute(testDir);\n\n      const cmdFile = path.join(testDir, '.gemini', 'commands', 'opsx', 'explore.toml');\n      expect(await fileExists(cmdFile)).toBe(true);\n\n      const content = await fs.readFile(cmdFile, 'utf-8');\n      expect(content).toContain('description =');\n      expect(content).toContain('prompt =');\n    });\n\n    it('should generate Windsurf commands', async () => {\n      const initCommand = new InitCommand({ tools: 'windsurf', force: true });\n      await initCommand.execute(testDir);\n\n      const cmdFile = path.join(testDir, '.windsurf', 'workflows', 'opsx-explore.md');\n      expect(await fileExists(cmdFile)).toBe(true);\n    });\n\n    it('should generate Continue prompt files', async () => {\n      const initCommand = new InitCommand({ tools: 'continue', force: true });\n      await initCommand.execute(testDir);\n\n      const cmdFile = path.join(testDir, '.continue', 'prompts', 'opsx-explore.prompt');\n      expect(await fileExists(cmdFile)).toBe(true);\n\n      const content = await fs.readFile(cmdFile, 'utf-8');\n      expect(content).toContain('name: opsx-explore');\n      expect(content).toContain('invokable: true');\n    });\n\n    it('should generate Cline workflow files', async () => {\n      const initCommand = new InitCommand({ tools: 'cline', force: true });\n      await initCommand.execute(testDir);\n\n      const cmdFile = path.join(testDir, '.clinerules', 'workflows', 'opsx-explore.md');\n      expect(await fileExists(cmdFile)).toBe(true);\n    });\n\n    it('should generate GitHub Copilot prompt files', async () => {\n      const initCommand = new InitCommand({ tools: 'github-copilot', force: true });\n      await initCommand.execute(testDir);\n\n      const cmdFile = path.join(testDir, '.github', 'prompts', 'opsx-explore.prompt.md');\n      expect(await fileExists(cmdFile)).toBe(true);\n    });\n  });\n});\n\ndescribe('InitCommand - profile and detection features', () => {\n  let testDir: string;\n  let configTempDir: string;\n  let originalEnv: NodeJS.ProcessEnv;\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-init-profile-test-${Date.now()}`);\n    await fs.mkdir(testDir, { recursive: true });\n    originalEnv = { ...process.env };\n    // Use a temp dir for global config to avoid polluting real config\n    configTempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}`);\n    await fs.mkdir(configTempDir, { recursive: true });\n    process.env.XDG_CONFIG_HOME = configTempDir;\n    vi.spyOn(console, 'log').mockImplementation(() => {});\n    confirmMock.mockReset();\n    confirmMock.mockResolvedValue(true);\n    showWelcomeScreenMock.mockClear();\n    searchableMultiSelectMock.mockReset();\n  });\n\n  afterEach(async () => {\n    process.env = originalEnv;\n    await fs.rm(testDir, { recursive: true, force: true });\n    await fs.rm(configTempDir, { recursive: true, force: true });\n    vi.restoreAllMocks();\n  });\n\n  it('should use --profile flag to override global config', async () => {\n    // Set global config to custom profile\n    saveGlobalConfig({\n      featureFlags: {},\n      profile: 'custom',\n      delivery: 'both',\n      workflows: ['explore', 'new', 'apply'],\n    });\n\n    // Override with --profile core\n    const initCommand = new InitCommand({ tools: 'claude', force: true, profile: 'core' });\n    await initCommand.execute(testDir);\n\n    // Core profile skills should be created\n    const proposeSkill = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md');\n    expect(await fileExists(proposeSkill)).toBe(true);\n\n    // Non-core skills (from the custom profile) should NOT be created\n    const newChangeSkill = path.join(testDir, '.claude', 'skills', 'openspec-new-change', 'SKILL.md');\n    expect(await fileExists(newChangeSkill)).toBe(false);\n  });\n\n  it('should reject invalid --profile values', async () => {\n    const initCommand = new InitCommand({\n      tools: 'claude',\n      force: true,\n      profile: 'invalid-profile',\n    });\n\n    await expect(initCommand.execute(testDir)).rejects.toThrow(\n      /Invalid profile \"invalid-profile\"/\n    );\n  });\n\n  it('should use detected tools in non-interactive mode when no --tools flag', async () => {\n    // Create a .claude directory to simulate detected tool\n    await fs.mkdir(path.join(testDir, '.claude'), { recursive: true });\n\n    const initCommand = new InitCommand({ interactive: false, force: true });\n    await initCommand.execute(testDir);\n\n    // Should have used claude (detected)\n    const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n    expect(await fileExists(skillFile)).toBe(true);\n  });\n\n  it('should auto-cleanup legacy artifacts in non-interactive mode without --force', async () => {\n    // Create legacy OpenCode command files (singular 'command' path)\n    const legacyDir = path.join(testDir, '.opencode', 'command');\n    await fs.mkdir(legacyDir, { recursive: true });\n    await fs.writeFile(path.join(legacyDir, 'opsx-propose.md'), 'legacy content');\n\n    // Run init in non-interactive mode without --force\n    const initCommand = new InitCommand({ tools: 'opencode' });\n    await initCommand.execute(testDir);\n\n    // Legacy files should be cleaned up automatically\n    expect(await fileExists(path.join(legacyDir, 'opsx-propose.md'))).toBe(false);\n\n    // New commands should be at the correct plural path\n    const newCommandsDir = path.join(testDir, '.opencode', 'commands');\n    expect(await directoryExists(newCommandsDir)).toBe(true);\n  });\n\n  it('should preselect configured tools but not directory-detected tools in extend mode', async () => {\n    // Simulate existing OpenSpec project (extend mode).\n    await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true });\n\n    // Configured with OpenSpec\n    const claudeSkillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');\n    await fs.mkdir(claudeSkillDir, { recursive: true });\n    await fs.writeFile(path.join(claudeSkillDir, 'SKILL.md'), 'configured');\n\n    // Directory detected only (not configured with OpenSpec)\n    await fs.mkdir(path.join(testDir, '.github'), { recursive: true });\n\n    searchableMultiSelectMock.mockResolvedValue(['claude']);\n\n    const initCommand = new InitCommand({ force: true });\n    vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true);\n\n    await initCommand.execute(testDir);\n\n    expect(searchableMultiSelectMock).toHaveBeenCalledTimes(1);\n    const [{ choices }] = searchableMultiSelectMock.mock.calls[0] as [{ choices: Array<{ value: string; preSelected?: boolean; detected?: boolean }> }];\n\n    const claude = choices.find((choice) => choice.value === 'claude');\n    const githubCopilot = choices.find((choice) => choice.value === 'github-copilot');\n\n    expect(claude?.preSelected).toBe(true);\n    expect(githubCopilot?.preSelected).toBe(false);\n    expect(githubCopilot?.detected).toBe(true);\n  });\n\n  it('should preselect detected tools for first-time interactive setup', async () => {\n    // First-time init: no openspec/ directory and no configured OpenSpec skills.\n    await fs.mkdir(path.join(testDir, '.github'), { recursive: true });\n\n    searchableMultiSelectMock.mockResolvedValue(['github-copilot']);\n\n    const initCommand = new InitCommand({ force: true });\n    vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true);\n\n    await initCommand.execute(testDir);\n\n    expect(searchableMultiSelectMock).toHaveBeenCalledTimes(1);\n    const [{ choices }] = searchableMultiSelectMock.mock.calls[0] as [{ choices: Array<{ value: string; preSelected?: boolean }> }];\n    const githubCopilot = choices.find((choice) => choice.value === 'github-copilot');\n\n    expect(githubCopilot?.preSelected).toBe(true);\n  });\n\n  it('should respect custom profile from global config', async () => {\n    saveGlobalConfig({\n      featureFlags: {},\n      profile: 'custom',\n      delivery: 'both',\n      workflows: ['explore', 'new'],\n    });\n\n    const initCommand = new InitCommand({ tools: 'claude', force: true });\n    await initCommand.execute(testDir);\n\n    // Custom profile skills should be created\n    const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n    const newChangeSkill = path.join(testDir, '.claude', 'skills', 'openspec-new-change', 'SKILL.md');\n    expect(await fileExists(exploreSkill)).toBe(true);\n    expect(await fileExists(newChangeSkill)).toBe(true);\n\n    // Non-selected skills should NOT be created\n    const proposeSkill = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md');\n    expect(await fileExists(proposeSkill)).toBe(false);\n  });\n\n  it('should migrate commands-only extend mode to custom profile without injecting propose', async () => {\n    await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true });\n    await fs.mkdir(path.join(testDir, '.claude', 'commands', 'opsx'), { recursive: true });\n    await fs.writeFile(path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md'), '# explore\\n');\n\n    const initCommand = new InitCommand({ tools: 'claude', force: true });\n    await initCommand.execute(testDir);\n\n    const config = getGlobalConfig();\n    expect(config.profile).toBe('custom');\n    expect(config.delivery).toBe('commands');\n    expect(config.workflows).toEqual(['explore']);\n\n    const exploreCommand = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md');\n    const proposeCommand = path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md');\n    expect(await fileExists(exploreCommand)).toBe(true);\n    expect(await fileExists(proposeCommand)).toBe(false);\n\n    const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n    const proposeSkill = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md');\n    expect(await fileExists(exploreSkill)).toBe(false);\n    expect(await fileExists(proposeSkill)).toBe(false);\n  });\n\n  it('should not prompt for confirmation when applying custom profile in interactive init', async () => {\n    saveGlobalConfig({\n      featureFlags: {},\n      profile: 'custom',\n      delivery: 'both',\n      workflows: ['explore', 'new'],\n    });\n\n    const initCommand = new InitCommand({ force: true });\n    vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true);\n    vi.spyOn(initCommand as any, 'getSelectedTools').mockResolvedValue(['claude']);\n\n    await initCommand.execute(testDir);\n\n    expect(showWelcomeScreenMock).toHaveBeenCalled();\n    expect(confirmMock).not.toHaveBeenCalled();\n\n    const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n    const newChangeSkill = path.join(testDir, '.claude', 'skills', 'openspec-new-change', 'SKILL.md');\n    expect(await fileExists(exploreSkill)).toBe(true);\n    expect(await fileExists(newChangeSkill)).toBe(true);\n\n    const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);\n    expect(logCalls.some((entry) => entry.includes('Applying custom profile'))).toBe(false);\n  });\n\n  it('should respect delivery=skills setting (no commands)', async () => {\n    saveGlobalConfig({\n      featureFlags: {},\n      profile: 'core',\n      delivery: 'skills',\n    });\n\n    const initCommand = new InitCommand({ tools: 'claude', force: true });\n    await initCommand.execute(testDir);\n\n    // Skills should exist\n    const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n    expect(await fileExists(skillFile)).toBe(true);\n\n    // Commands should NOT exist\n    const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md');\n    expect(await fileExists(cmdFile)).toBe(false);\n  });\n\n  it('should respect delivery=commands setting (no skills)', async () => {\n    saveGlobalConfig({\n      featureFlags: {},\n      profile: 'core',\n      delivery: 'commands',\n    });\n\n    const initCommand = new InitCommand({ tools: 'claude', force: true });\n    await initCommand.execute(testDir);\n\n    // Skills should NOT exist\n    const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n    expect(await fileExists(skillFile)).toBe(false);\n\n    // Commands should exist\n    const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md');\n    expect(await fileExists(cmdFile)).toBe(true);\n  });\n\n  it('should remove commands on re-init when delivery changes to skills', async () => {\n    saveGlobalConfig({\n      featureFlags: {},\n      profile: 'core',\n      delivery: 'both',\n    });\n\n    const initCommand1 = new InitCommand({ tools: 'claude', force: true });\n    await initCommand1.execute(testDir);\n\n    const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md');\n    expect(await fileExists(cmdFile)).toBe(true);\n\n    saveGlobalConfig({\n      featureFlags: {},\n      profile: 'core',\n      delivery: 'skills',\n    });\n\n    const initCommand2 = new InitCommand({ tools: 'claude', force: true });\n    await initCommand2.execute(testDir);\n\n    expect(await fileExists(cmdFile)).toBe(false);\n\n    const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n    expect(await fileExists(skillFile)).toBe(true);\n  });\n});\n\nasync function fileExists(filePath: string): Promise<boolean> {\n  try {\n    await fs.access(filePath);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nasync function directoryExists(dirPath: string): Promise<boolean> {\n  try {\n    const stats = await fs.stat(dirPath);\n    return stats.isDirectory();\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "test/core/legacy-cleanup.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\nimport {\n  detectLegacyArtifacts,\n  detectLegacyConfigFiles,\n  detectLegacySlashCommands,\n  detectLegacyStructureFiles,\n  hasOpenSpecMarkers,\n  isOnlyOpenSpecContent,\n  removeMarkerBlock,\n  cleanupLegacyArtifacts,\n  formatCleanupSummary,\n  formatDetectionSummary,\n  formatProjectMdMigrationHint,\n  getToolsFromLegacyArtifacts,\n  LEGACY_CONFIG_FILES,\n  LEGACY_SLASH_COMMAND_PATHS,\n} from '../../src/core/legacy-cleanup.js';\nimport { OPENSPEC_MARKERS } from '../../src/core/config.js';\nimport { CommandAdapterRegistry } from '../../src/core/command-generation/registry.js';\n\ndescribe('legacy-cleanup', () => {\n  let testDir: string;\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-legacy-test-${randomUUID()}`);\n    await fs.mkdir(testDir, { recursive: true });\n    // Create openspec directory structure\n    await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('hasOpenSpecMarkers', () => {\n    it('should return true when both markers are present', () => {\n      const content = `Some content\n${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}\nMore content`;\n      expect(hasOpenSpecMarkers(content)).toBe(true);\n    });\n\n    it('should return false when start marker is missing', () => {\n      const content = `Some content\nOpenSpec content\n${OPENSPEC_MARKERS.end}`;\n      expect(hasOpenSpecMarkers(content)).toBe(false);\n    });\n\n    it('should return false when end marker is missing', () => {\n      const content = `${OPENSPEC_MARKERS.start}\nOpenSpec content\nSome content`;\n      expect(hasOpenSpecMarkers(content)).toBe(false);\n    });\n\n    it('should return false when no markers are present', () => {\n      const content = 'Plain content without markers';\n      expect(hasOpenSpecMarkers(content)).toBe(false);\n    });\n  });\n\n  describe('isOnlyOpenSpecContent', () => {\n    it('should return true when content is only markers and whitespace outside', () => {\n      const content = `${OPENSPEC_MARKERS.start}\nOpenSpec content here\n${OPENSPEC_MARKERS.end}`;\n      expect(isOnlyOpenSpecContent(content)).toBe(true);\n    });\n\n    it('should return true with whitespace before and after markers', () => {\n      const content = `\n\n${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}\n\n`;\n      expect(isOnlyOpenSpecContent(content)).toBe(true);\n    });\n\n    it('should return false when content exists before markers', () => {\n      const content = `User content here\n${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}`;\n      expect(isOnlyOpenSpecContent(content)).toBe(false);\n    });\n\n    it('should return false when content exists after markers', () => {\n      const content = `${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}\nUser content here`;\n      expect(isOnlyOpenSpecContent(content)).toBe(false);\n    });\n\n    it('should return false when markers are missing', () => {\n      const content = 'Plain content without markers';\n      expect(isOnlyOpenSpecContent(content)).toBe(false);\n    });\n\n    it('should return false when end marker comes before start marker', () => {\n      const content = `${OPENSPEC_MARKERS.end}\nContent\n${OPENSPEC_MARKERS.start}`;\n      expect(isOnlyOpenSpecContent(content)).toBe(false);\n    });\n  });\n\n  describe('removeMarkerBlock', () => {\n    it('should remove marker block and preserve content before', () => {\n      const content = `User content before\n${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}`;\n      const result = removeMarkerBlock(content);\n      expect(result).toBe('User content before\\n');\n      expect(result).not.toContain(OPENSPEC_MARKERS.start);\n      expect(result).not.toContain(OPENSPEC_MARKERS.end);\n    });\n\n    it('should remove marker block and preserve content after', () => {\n      const content = `${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}\nUser content after`;\n      const result = removeMarkerBlock(content);\n      expect(result).toBe('User content after\\n');\n    });\n\n    it('should remove marker block and preserve content before and after', () => {\n      const content = `User content before\n${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}\nUser content after`;\n      const result = removeMarkerBlock(content);\n      expect(result).toContain('User content before');\n      expect(result).toContain('User content after');\n      expect(result).not.toContain(OPENSPEC_MARKERS.start);\n    });\n\n    it('should clean up double blank lines', () => {\n      const content = `Line 1\n\n\n${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}\n\n\nLine 2`;\n      const result = removeMarkerBlock(content);\n      expect(result).not.toMatch(/\\n{3,}/);\n    });\n\n    it('should return empty string when only markers remain', () => {\n      const content = `${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}`;\n      const result = removeMarkerBlock(content);\n      expect(result).toBe('');\n    });\n\n    it('should return original content when markers are missing', () => {\n      const content = 'Plain content without markers';\n      const result = removeMarkerBlock(content);\n      // When no markers found, content is returned trimmed (no trailing newline added)\n      expect(result).toBe('Plain content without markers');\n    });\n\n    it('should return original content when markers are in wrong order', () => {\n      const content = `${OPENSPEC_MARKERS.end}\nContent\n${OPENSPEC_MARKERS.start}`;\n      const result = removeMarkerBlock(content);\n      expect(result).toContain(OPENSPEC_MARKERS.end);\n      expect(result).toContain(OPENSPEC_MARKERS.start);\n    });\n\n    it('should ignore inline mentions of markers and only remove actual block', () => {\n      const content = `Intro referencing ${OPENSPEC_MARKERS.start} and ${OPENSPEC_MARKERS.end} inline.\n\n${OPENSPEC_MARKERS.start}\nManaged content here\n${OPENSPEC_MARKERS.end}\nAfter content`;\n      const result = removeMarkerBlock(content);\n      // Inline mentions preserved\n      expect(result).toContain('Intro referencing');\n      expect(result).toContain(OPENSPEC_MARKERS.start);\n      expect(result).toContain(OPENSPEC_MARKERS.end);\n      // Managed content removed\n      expect(result).not.toContain('Managed content here');\n      expect(result).toContain('After content');\n    });\n  });\n\n  describe('detectLegacyConfigFiles', () => {\n    it('should detect CLAUDE.md with OpenSpec markers and put in update list', async () => {\n      const claudePath = path.join(testDir, 'CLAUDE.md');\n      await fs.writeFile(claudePath, `${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}`);\n\n      const result = await detectLegacyConfigFiles(testDir);\n      expect(result.allFiles).toContain('CLAUDE.md');\n      // Config files are NEVER deleted, always updated (markers removed)\n      expect(result.filesToUpdate).toContain('CLAUDE.md');\n    });\n\n    it('should detect files with mixed content and put in update list', async () => {\n      const claudePath = path.join(testDir, 'CLAUDE.md');\n      await fs.writeFile(claudePath, `User instructions here\n${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}`);\n\n      const result = await detectLegacyConfigFiles(testDir);\n      expect(result.allFiles).toContain('CLAUDE.md');\n      expect(result.filesToUpdate).toContain('CLAUDE.md');\n    });\n\n    it('should not detect files without OpenSpec markers', async () => {\n      const claudePath = path.join(testDir, 'CLAUDE.md');\n      await fs.writeFile(claudePath, 'Plain instructions without markers');\n\n      const result = await detectLegacyConfigFiles(testDir);\n      expect(result.allFiles).not.toContain('CLAUDE.md');\n    });\n\n    it('should detect multiple config files', async () => {\n      // Create multiple config files with markers\n      await fs.writeFile(path.join(testDir, 'CLAUDE.md'), `${OPENSPEC_MARKERS.start}\\nContent\\n${OPENSPEC_MARKERS.end}`);\n      await fs.writeFile(path.join(testDir, 'CLINE.md'), `${OPENSPEC_MARKERS.start}\\nContent\\n${OPENSPEC_MARKERS.end}`);\n      await fs.writeFile(path.join(testDir, 'QODER.md'), `${OPENSPEC_MARKERS.start}\\nContent\\n${OPENSPEC_MARKERS.end}`);\n\n      const result = await detectLegacyConfigFiles(testDir);\n      expect(result.allFiles).toHaveLength(3);\n      expect(result.allFiles).toContain('CLAUDE.md');\n      expect(result.allFiles).toContain('CLINE.md');\n      expect(result.allFiles).toContain('QODER.md');\n      // All should be in update list, none deleted\n      expect(result.filesToUpdate).toHaveLength(3);\n    });\n\n    it('should handle non-existent files gracefully', async () => {\n      const result = await detectLegacyConfigFiles(testDir);\n      expect(result.allFiles).toHaveLength(0);\n      expect(result.filesToUpdate).toHaveLength(0);\n    });\n  });\n\n  describe('detectLegacySlashCommands', () => {\n    it('should detect legacy Claude slash command directory', async () => {\n      const dirPath = path.join(testDir, '.claude', 'commands', 'openspec');\n      await fs.mkdir(dirPath, { recursive: true });\n      await fs.writeFile(path.join(dirPath, 'proposal.md'), 'content');\n\n      const result = await detectLegacySlashCommands(testDir);\n      expect(result.directories).toContain('.claude/commands/openspec');\n    });\n\n    it('should detect legacy Cursor slash command files', async () => {\n      const dirPath = path.join(testDir, '.cursor', 'commands');\n      await fs.mkdir(dirPath, { recursive: true });\n      await fs.writeFile(path.join(dirPath, 'openspec-proposal.md'), 'content');\n      await fs.writeFile(path.join(dirPath, 'openspec-apply.md'), 'content');\n\n      const result = await detectLegacySlashCommands(testDir);\n      expect(result.files).toContain('.cursor/commands/openspec-proposal.md');\n      expect(result.files).toContain('.cursor/commands/openspec-apply.md');\n    });\n\n    it('should detect legacy Windsurf workflow files', async () => {\n      const dirPath = path.join(testDir, '.windsurf', 'workflows');\n      await fs.mkdir(dirPath, { recursive: true });\n      await fs.writeFile(path.join(dirPath, 'openspec-archive.md'), 'content');\n\n      const result = await detectLegacySlashCommands(testDir);\n      expect(result.files).toContain('.windsurf/workflows/openspec-archive.md');\n    });\n\n    it('should detect multiple tool directories and files', async () => {\n      // Create directory-based\n      await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });\n      await fs.mkdir(path.join(testDir, '.qoder', 'commands', 'openspec'), { recursive: true });\n\n      // Create file-based\n      await fs.mkdir(path.join(testDir, '.cursor', 'commands'), { recursive: true });\n      await fs.writeFile(path.join(testDir, '.cursor', 'commands', 'openspec-proposal.md'), 'content');\n\n      const result = await detectLegacySlashCommands(testDir);\n      expect(result.directories).toContain('.claude/commands/openspec');\n      expect(result.directories).toContain('.qoder/commands/openspec');\n      expect(result.files).toContain('.cursor/commands/openspec-proposal.md');\n    });\n\n    it('should not detect non-openspec files', async () => {\n      const dirPath = path.join(testDir, '.cursor', 'commands');\n      await fs.mkdir(dirPath, { recursive: true });\n      await fs.writeFile(path.join(dirPath, 'other-command.md'), 'content');\n\n      const result = await detectLegacySlashCommands(testDir);\n      expect(result.files).not.toContain('.cursor/commands/other-command.md');\n    });\n\n    it('should handle non-existent directories gracefully', async () => {\n      const result = await detectLegacySlashCommands(testDir);\n      expect(result.directories).toHaveLength(0);\n      expect(result.files).toHaveLength(0);\n    });\n\n    it('should detect TOML-based slash commands for Qwen', async () => {\n      const dirPath = path.join(testDir, '.qwen', 'commands');\n      await fs.mkdir(dirPath, { recursive: true });\n      await fs.writeFile(path.join(dirPath, 'openspec-proposal.toml'), 'content');\n\n      const result = await detectLegacySlashCommands(testDir);\n      expect(result.files).toContain('.qwen/commands/openspec-proposal.toml');\n    });\n\n    it('should detect Continue prompt files', async () => {\n      const dirPath = path.join(testDir, '.continue', 'prompts');\n      await fs.mkdir(dirPath, { recursive: true });\n      await fs.writeFile(path.join(dirPath, 'openspec-apply.prompt'), 'content');\n\n      const result = await detectLegacySlashCommands(testDir);\n      expect(result.files).toContain('.continue/prompts/openspec-apply.prompt');\n    });\n\n    it('should detect legacy OpenCode opsx-* command files', async () => {\n      const dirPath = path.join(testDir, '.opencode', 'command');\n      await fs.mkdir(dirPath, { recursive: true });\n      await fs.writeFile(path.join(dirPath, 'opsx-propose.md'), 'content');\n\n      const result = await detectLegacySlashCommands(testDir);\n      expect(result.files).toContain('.opencode/command/opsx-propose.md');\n    });\n\n    it('should detect legacy OpenCode openspec-* command files', async () => {\n      const dirPath = path.join(testDir, '.opencode', 'command');\n      await fs.mkdir(dirPath, { recursive: true });\n      await fs.writeFile(path.join(dirPath, 'openspec-new.md'), 'content');\n\n      const result = await detectLegacySlashCommands(testDir);\n      expect(result.files).toContain('.opencode/command/openspec-new.md');\n    });\n\n    it('should detect both opsx-* and openspec-* OpenCode command files', async () => {\n      const dirPath = path.join(testDir, '.opencode', 'command');\n      await fs.mkdir(dirPath, { recursive: true });\n      await fs.writeFile(path.join(dirPath, 'opsx-propose.md'), 'content');\n      await fs.writeFile(path.join(dirPath, 'openspec-new.md'), 'content');\n\n      const result = await detectLegacySlashCommands(testDir);\n      expect(result.files).toContain('.opencode/command/opsx-propose.md');\n      expect(result.files).toContain('.opencode/command/openspec-new.md');\n    });\n  });\n\n  describe('detectLegacyStructureFiles', () => {\n    it('should detect openspec/AGENTS.md', async () => {\n      const agentsPath = path.join(testDir, 'openspec', 'AGENTS.md');\n      await fs.writeFile(agentsPath, '# AGENTS.md content');\n\n      const result = await detectLegacyStructureFiles(testDir);\n      expect(result.hasOpenspecAgents).toBe(true);\n    });\n\n    it('should detect openspec/project.md', async () => {\n      const projectPath = path.join(testDir, 'openspec', 'project.md');\n      await fs.writeFile(projectPath, '# Project content');\n\n      const result = await detectLegacyStructureFiles(testDir);\n      expect(result.hasProjectMd).toBe(true);\n    });\n\n    it('should detect root AGENTS.md with OpenSpec markers', async () => {\n      const agentsPath = path.join(testDir, 'AGENTS.md');\n      await fs.writeFile(agentsPath, `${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}`);\n\n      const result = await detectLegacyStructureFiles(testDir);\n      expect(result.hasRootAgentsWithMarkers).toBe(true);\n    });\n\n    it('should not detect root AGENTS.md without markers', async () => {\n      const agentsPath = path.join(testDir, 'AGENTS.md');\n      await fs.writeFile(agentsPath, 'Plain content without markers');\n\n      const result = await detectLegacyStructureFiles(testDir);\n      expect(result.hasRootAgentsWithMarkers).toBe(false);\n    });\n\n    it('should handle non-existent files gracefully', async () => {\n      const result = await detectLegacyStructureFiles(testDir);\n      expect(result.hasOpenspecAgents).toBe(false);\n      expect(result.hasProjectMd).toBe(false);\n      expect(result.hasRootAgentsWithMarkers).toBe(false);\n    });\n  });\n\n  describe('detectLegacyArtifacts', () => {\n    it('should return hasLegacyArtifacts: false when nothing is found', async () => {\n      const result = await detectLegacyArtifacts(testDir);\n      expect(result.hasLegacyArtifacts).toBe(false);\n    });\n\n    it('should return hasLegacyArtifacts: true when config files are found', async () => {\n      await fs.writeFile(path.join(testDir, 'CLAUDE.md'), `${OPENSPEC_MARKERS.start}\\nContent\\n${OPENSPEC_MARKERS.end}`);\n\n      const result = await detectLegacyArtifacts(testDir);\n      expect(result.hasLegacyArtifacts).toBe(true);\n      expect(result.configFiles).toContain('CLAUDE.md');\n    });\n\n    it('should return hasLegacyArtifacts: true when slash commands are found', async () => {\n      await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });\n\n      const result = await detectLegacyArtifacts(testDir);\n      expect(result.hasLegacyArtifacts).toBe(true);\n      expect(result.slashCommandDirs).toContain('.claude/commands/openspec');\n    });\n\n    it('should return hasLegacyArtifacts: true when openspec/AGENTS.md is found', async () => {\n      await fs.writeFile(path.join(testDir, 'openspec', 'AGENTS.md'), 'content');\n\n      const result = await detectLegacyArtifacts(testDir);\n      expect(result.hasLegacyArtifacts).toBe(true);\n      expect(result.hasOpenspecAgents).toBe(true);\n    });\n\n    it('should detect project.md for migration hint (it is preserved, not deleted)', async () => {\n      await fs.writeFile(path.join(testDir, 'openspec', 'project.md'), 'content');\n\n      const result = await detectLegacyArtifacts(testDir);\n      // project.md triggers hasLegacyArtifacts to show migration hint\n      expect(result.hasLegacyArtifacts).toBe(true);\n      expect(result.hasProjectMd).toBe(true);\n    });\n\n    it('should combine all detection results', async () => {\n      // Create various legacy artifacts\n      await fs.writeFile(path.join(testDir, 'CLAUDE.md'), `${OPENSPEC_MARKERS.start}\\nContent\\n${OPENSPEC_MARKERS.end}`);\n      await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });\n      await fs.writeFile(path.join(testDir, 'openspec', 'AGENTS.md'), 'content');\n      await fs.writeFile(path.join(testDir, 'openspec', 'project.md'), 'content');\n\n      const result = await detectLegacyArtifacts(testDir);\n      expect(result.hasLegacyArtifacts).toBe(true);\n      expect(result.configFiles).toContain('CLAUDE.md');\n      expect(result.slashCommandDirs).toContain('.claude/commands/openspec');\n      expect(result.hasOpenspecAgents).toBe(true);\n      expect(result.hasProjectMd).toBe(true);\n    });\n  });\n\n  describe('cleanupLegacyArtifacts', () => {\n    it('should remove markers from config files that have only OpenSpec content (never delete)', async () => {\n      const claudePath = path.join(testDir, 'CLAUDE.md');\n      await fs.writeFile(claudePath, `${OPENSPEC_MARKERS.start}\\nContent\\n${OPENSPEC_MARKERS.end}`);\n\n      const detection = await detectLegacyArtifacts(testDir);\n      const result = await cleanupLegacyArtifacts(testDir, detection);\n\n      // Config files should NEVER be deleted, only have markers removed\n      expect(result.deletedFiles).not.toContain('CLAUDE.md');\n      expect(result.modifiedFiles).toContain('CLAUDE.md');\n      // File should still exist\n      await expect(fs.access(claudePath)).resolves.not.toThrow();\n      // File should be empty or have markers removed\n      const content = await fs.readFile(claudePath, 'utf-8');\n      expect(content).not.toContain(OPENSPEC_MARKERS.start);\n      expect(content).not.toContain(OPENSPEC_MARKERS.end);\n    });\n\n    it('should remove marker block from files with mixed content', async () => {\n      const claudePath = path.join(testDir, 'CLAUDE.md');\n      await fs.writeFile(claudePath, `User instructions\n${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}`);\n\n      const detection = await detectLegacyArtifacts(testDir);\n      const result = await cleanupLegacyArtifacts(testDir, detection);\n\n      expect(result.modifiedFiles).toContain('CLAUDE.md');\n      const content = await fs.readFile(claudePath, 'utf-8');\n      expect(content).toContain('User instructions');\n      expect(content).not.toContain(OPENSPEC_MARKERS.start);\n    });\n\n    it('should delete legacy slash command directories', async () => {\n      const dirPath = path.join(testDir, '.claude', 'commands', 'openspec');\n      await fs.mkdir(dirPath, { recursive: true });\n      await fs.writeFile(path.join(dirPath, 'proposal.md'), 'content');\n\n      const detection = await detectLegacyArtifacts(testDir);\n      const result = await cleanupLegacyArtifacts(testDir, detection);\n\n      expect(result.deletedDirs).toContain('.claude/commands/openspec');\n      await expect(fs.access(dirPath)).rejects.toThrow();\n      // Parent directory should still exist\n      await expect(fs.access(path.join(testDir, '.claude', 'commands'))).resolves.not.toThrow();\n    });\n\n    it('should delete legacy slash command files', async () => {\n      const dirPath = path.join(testDir, '.cursor', 'commands');\n      await fs.mkdir(dirPath, { recursive: true });\n      const filePath = path.join(dirPath, 'openspec-proposal.md');\n      await fs.writeFile(filePath, 'content');\n\n      const detection = await detectLegacyArtifacts(testDir);\n      const result = await cleanupLegacyArtifacts(testDir, detection);\n\n      expect(result.deletedFiles).toContain('.cursor/commands/openspec-proposal.md');\n      await expect(fs.access(filePath)).rejects.toThrow();\n    });\n\n    it('should delete openspec/AGENTS.md', async () => {\n      const agentsPath = path.join(testDir, 'openspec', 'AGENTS.md');\n      await fs.writeFile(agentsPath, 'content');\n\n      const detection = await detectLegacyArtifacts(testDir);\n      const result = await cleanupLegacyArtifacts(testDir, detection);\n\n      expect(result.deletedFiles).toContain('openspec/AGENTS.md');\n      await expect(fs.access(agentsPath)).rejects.toThrow();\n      // openspec directory should still exist\n      await expect(fs.access(path.join(testDir, 'openspec'))).resolves.not.toThrow();\n    });\n\n    it('should NOT delete openspec/project.md', async () => {\n      const projectPath = path.join(testDir, 'openspec', 'project.md');\n      await fs.writeFile(projectPath, 'User project content');\n\n      const detection = await detectLegacyArtifacts(testDir);\n      const result = await cleanupLegacyArtifacts(testDir, detection);\n\n      expect(result.projectMdNeedsMigration).toBe(true);\n      expect(result.deletedFiles).not.toContain('openspec/project.md');\n      await expect(fs.access(projectPath)).resolves.not.toThrow();\n    });\n\n    it('should handle root AGENTS.md with mixed content', async () => {\n      const agentsPath = path.join(testDir, 'AGENTS.md');\n      await fs.writeFile(agentsPath, `User content\n${OPENSPEC_MARKERS.start}\nOpenSpec content\n${OPENSPEC_MARKERS.end}`);\n\n      const detection = await detectLegacyArtifacts(testDir);\n      const result = await cleanupLegacyArtifacts(testDir, detection);\n\n      expect(result.modifiedFiles).toContain('AGENTS.md');\n      const content = await fs.readFile(agentsPath, 'utf-8');\n      expect(content).toContain('User content');\n      expect(content).not.toContain(OPENSPEC_MARKERS.start);\n    });\n\n    it('should remove markers from root AGENTS.md even when only OpenSpec content (never delete)', async () => {\n      const agentsPath = path.join(testDir, 'AGENTS.md');\n      await fs.writeFile(agentsPath, `${OPENSPEC_MARKERS.start}\\nOpenSpec content\\n${OPENSPEC_MARKERS.end}`);\n\n      const detection = await detectLegacyArtifacts(testDir);\n      const result = await cleanupLegacyArtifacts(testDir, detection);\n\n      // Root AGENTS.md should NEVER be deleted, only have markers removed\n      expect(result.deletedFiles).not.toContain('AGENTS.md');\n      expect(result.modifiedFiles).toContain('AGENTS.md');\n      // File should still exist\n      await expect(fs.access(agentsPath)).resolves.not.toThrow();\n    });\n\n    it('should report errors without stopping cleanup', async () => {\n      // Create a valid detection result with a non-existent file to simulate error\n      const detection = {\n        configFiles: ['NON_EXISTENT.md'],\n        configFilesToUpdate: ['NON_EXISTENT.md'],\n        slashCommandDirs: [],\n        slashCommandFiles: [],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const result = await cleanupLegacyArtifacts(testDir, detection);\n\n      // Should not throw, but should record the error\n      expect(result.errors.length).toBeGreaterThan(0);\n      expect(result.errors[0]).toContain('NON_EXISTENT.md');\n    });\n  });\n\n  describe('formatCleanupSummary', () => {\n    it('should format deleted files', () => {\n      const result = {\n        deletedFiles: ['CLAUDE.md', 'CLINE.md'],\n        modifiedFiles: [],\n        deletedDirs: [],\n        projectMdNeedsMigration: false,\n        errors: [],\n      };\n\n      const summary = formatCleanupSummary(result);\n      expect(summary).toContain('Cleaned up legacy files:');\n      expect(summary).toContain('✓ Removed CLAUDE.md');\n      expect(summary).toContain('✓ Removed CLINE.md');\n    });\n\n    it('should format deleted directories', () => {\n      const result = {\n        deletedFiles: [],\n        modifiedFiles: [],\n        deletedDirs: ['.claude/commands/openspec'],\n        projectMdNeedsMigration: false,\n        errors: [],\n      };\n\n      const summary = formatCleanupSummary(result);\n      expect(summary).toContain('✓ Removed .claude/commands/openspec/ (replaced by /opsx:*)');\n    });\n\n    it('should format modified files', () => {\n      const result = {\n        deletedFiles: [],\n        modifiedFiles: ['AGENTS.md'],\n        deletedDirs: [],\n        projectMdNeedsMigration: false,\n        errors: [],\n      };\n\n      const summary = formatCleanupSummary(result);\n      expect(summary).toContain('✓ Removed OpenSpec markers from AGENTS.md');\n    });\n\n    it('should include migration hint for project.md', () => {\n      const result = {\n        deletedFiles: [],\n        modifiedFiles: [],\n        deletedDirs: [],\n        projectMdNeedsMigration: true,\n        errors: [],\n      };\n\n      const summary = formatCleanupSummary(result);\n      expect(summary).toContain('Needs your attention');\n      expect(summary).toContain('openspec/project.md');\n      expect(summary).toContain('config.yaml');\n    });\n\n    it('should include errors', () => {\n      const result = {\n        deletedFiles: [],\n        modifiedFiles: [],\n        deletedDirs: [],\n        projectMdNeedsMigration: false,\n        errors: ['Failed to delete CLAUDE.md: Permission denied'],\n      };\n\n      const summary = formatCleanupSummary(result);\n      expect(summary).toContain('Errors during cleanup:');\n      expect(summary).toContain('Failed to delete CLAUDE.md');\n    });\n\n    it('should return empty string when nothing to report', () => {\n      const result = {\n        deletedFiles: [],\n        modifiedFiles: [],\n        deletedDirs: [],\n        projectMdNeedsMigration: false,\n        errors: [],\n      };\n\n      const summary = formatCleanupSummary(result);\n      expect(summary).toBe('');\n    });\n  });\n\n  describe('formatDetectionSummary', () => {\n    it('should include welcoming upgrade header and explanation', () => {\n      const detection = {\n        configFiles: ['CLAUDE.md'],\n        configFilesToUpdate: ['CLAUDE.md'],\n        slashCommandDirs: [],\n        slashCommandFiles: [],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const summary = formatDetectionSummary(detection);\n      expect(summary).toContain('Upgrading to the new OpenSpec');\n      expect(summary).toContain('agent skills');\n      expect(summary).toContain('keeping everything working');\n    });\n\n    it('should format config files as files to update (never remove)', () => {\n      const detection = {\n        configFiles: ['CLAUDE.md'],\n        configFilesToUpdate: ['CLAUDE.md'],\n        slashCommandDirs: [],\n        slashCommandFiles: [],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const summary = formatDetectionSummary(detection);\n      // Config files should be in \"Files to update\", not \"Files to remove\"\n      expect(summary).toContain('Files to update');\n      expect(summary).toContain('• CLAUDE.md');\n      // Should NOT be in removals\n      expect(summary).not.toContain('No user content to preserve');\n    });\n\n    it('should format files to be updated', () => {\n      const detection = {\n        configFiles: ['CLINE.md'],\n        configFilesToUpdate: ['CLINE.md'],\n        slashCommandDirs: [],\n        slashCommandFiles: [],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const summary = formatDetectionSummary(detection);\n      expect(summary).toContain('Files to update');\n      expect(summary).toContain('markers will be removed');\n      expect(summary).toContain('your content preserved');\n      expect(summary).toContain('• CLINE.md');\n    });\n\n    it('should format slash command directories', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: ['.claude/commands/openspec'],\n        slashCommandFiles: [],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const summary = formatDetectionSummary(detection);\n      expect(summary).toContain('Files to remove');\n      expect(summary).toContain('• .claude/commands/openspec/');\n    });\n\n    it('should format slash command files', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: ['.cursor/commands/openspec-proposal.md'],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const summary = formatDetectionSummary(detection);\n      expect(summary).toContain('Files to remove');\n      expect(summary).toContain('• .cursor/commands/openspec-proposal.md');\n    });\n\n    it('should format openspec/AGENTS.md', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: [],\n        hasOpenspecAgents: true,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const summary = formatDetectionSummary(detection);\n      expect(summary).toContain('Files to remove');\n      expect(summary).toContain('• openspec/AGENTS.md');\n    });\n\n    it('should include attention section for project.md', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: [],\n        hasOpenspecAgents: false,\n        hasProjectMd: true,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: false,\n      };\n\n      const summary = formatDetectionSummary(detection);\n      expect(summary).toContain('Needs your attention');\n      expect(summary).toContain('• openspec/project.md');\n      expect(summary).toContain('won\\'t delete this file');\n      expect(summary).toContain('config.yaml');\n      expect(summary).toContain('\"context:\"');\n    });\n\n    it('should include attention section with other legacy artifacts', () => {\n      const detection = {\n        configFiles: ['CLAUDE.md'],\n        configFilesToUpdate: ['CLAUDE.md'],\n        slashCommandDirs: [],\n        slashCommandFiles: [],\n        hasOpenspecAgents: false,\n        hasProjectMd: true,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const summary = formatDetectionSummary(detection);\n      // Config files now in \"Files to update\", not \"Files to remove\"\n      expect(summary).toContain('Files to update');\n      expect(summary).toContain('CLAUDE.md');\n      expect(summary).toContain('Needs your attention');\n      expect(summary).toContain('openspec/project.md');\n    });\n\n    it('should group both removals and updates correctly', () => {\n      const detection = {\n        configFiles: ['CLAUDE.md', 'CLINE.md'],\n        configFilesToUpdate: ['CLAUDE.md', 'CLINE.md'],\n        slashCommandDirs: ['.claude/commands/openspec'],\n        slashCommandFiles: [],\n        hasOpenspecAgents: true,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const summary = formatDetectionSummary(detection);\n      // Check both sections exist\n      expect(summary).toContain('Files to remove');\n      expect(summary).toContain('Files to update');\n      // Check removals (only slash commands and openspec/AGENTS.md)\n      expect(summary).toContain('• .claude/commands/openspec/');\n      expect(summary).toContain('• openspec/AGENTS.md');\n      // Check updates (all config files)\n      expect(summary).toContain('• CLAUDE.md');\n      expect(summary).toContain('• CLINE.md');\n    });\n\n    it('should return empty string when nothing is detected', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: [],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: false,\n      };\n\n      const summary = formatDetectionSummary(detection);\n      expect(summary).toBe('');\n    });\n  });\n\n  describe('formatProjectMdMigrationHint', () => {\n    it('should return migration hint message', () => {\n      const hint = formatProjectMdMigrationHint();\n      expect(hint).toContain('Needs your attention');\n      expect(hint).toContain('openspec/project.md');\n      expect(hint).toContain('won\\'t delete this file');\n      expect(hint).toContain('config.yaml');\n      expect(hint).toContain('\"context:\"');\n    });\n\n    it('should include actionable instructions', () => {\n      const hint = formatProjectMdMigrationHint();\n      expect(hint).toContain('move any useful content');\n      expect(hint).toContain('delete the file when ready');\n    });\n\n    it('should explain the new context section benefits', () => {\n      const hint = formatProjectMdMigrationHint();\n      expect(hint).toContain('included in every OpenSpec request');\n      expect(hint).toContain('reliably');\n    });\n  });\n\n  describe('LEGACY_CONFIG_FILES', () => {\n    it('should include expected config file names', () => {\n      expect(LEGACY_CONFIG_FILES).toContain('CLAUDE.md');\n      expect(LEGACY_CONFIG_FILES).toContain('CLINE.md');\n      expect(LEGACY_CONFIG_FILES).toContain('CODEBUDDY.md');\n      expect(LEGACY_CONFIG_FILES).toContain('COSTRICT.md');\n      expect(LEGACY_CONFIG_FILES).toContain('QODER.md');\n      expect(LEGACY_CONFIG_FILES).toContain('IFLOW.md');\n      expect(LEGACY_CONFIG_FILES).toContain('AGENTS.md');\n      expect(LEGACY_CONFIG_FILES).toContain('QWEN.md');\n    });\n  });\n\n  describe('LEGACY_SLASH_COMMAND_PATHS', () => {\n    it('should include expected tool patterns', () => {\n      expect(LEGACY_SLASH_COMMAND_PATHS['claude']).toEqual({\n        type: 'directory',\n        path: '.claude/commands/openspec',\n      });\n\n      expect(LEGACY_SLASH_COMMAND_PATHS['cursor']).toEqual({\n        type: 'files',\n        pattern: '.cursor/commands/openspec-*.md',\n      });\n\n      expect(LEGACY_SLASH_COMMAND_PATHS['windsurf']).toEqual({\n        type: 'files',\n        pattern: '.windsurf/workflows/openspec-*.md',\n      });\n    });\n\n    it('should only include legacy tool IDs that are present in the CommandAdapterRegistry', () => {\n      const registeredTools = new Set(CommandAdapterRegistry.getAll().map(adapter => adapter.toolId));\n\n      // Verify all legacy map entries correspond to known adapters\n      for (const tool of Object.keys(LEGACY_SLASH_COMMAND_PATHS)) {\n        expect(registeredTools.has(tool)).toBe(true);\n      }\n\n      // Pi was never a pre-1.0 legacy tool\n      expect(LEGACY_SLASH_COMMAND_PATHS).not.toHaveProperty('pi');\n    });\n  });\n\n  describe('getToolsFromLegacyArtifacts', () => {\n    it('should extract claude from directory-based legacy artifacts', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: ['.claude/commands/openspec'],\n        slashCommandFiles: [],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toContain('claude');\n      expect(tools).toHaveLength(1);\n    });\n\n    it('should extract cursor from file-based legacy artifacts', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: ['.cursor/commands/openspec-proposal.md'],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toContain('cursor');\n      expect(tools).toHaveLength(1);\n    });\n\n    it('should extract multiple tools from mixed legacy artifacts', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: ['.claude/commands/openspec', '.qoder/commands/openspec'],\n        slashCommandFiles: ['.cursor/commands/openspec-apply.md', '.windsurf/workflows/openspec-archive.md'],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toContain('claude');\n      expect(tools).toContain('qoder');\n      expect(tools).toContain('cursor');\n      expect(tools).toContain('windsurf');\n      expect(tools).toHaveLength(4);\n    });\n\n    it('should deduplicate tools when multiple files match same tool', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: [\n          '.cursor/commands/openspec-proposal.md',\n          '.cursor/commands/openspec-apply.md',\n          '.cursor/commands/openspec-archive.md',\n        ],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toContain('cursor');\n      expect(tools).toHaveLength(1);\n    });\n\n    it('should return empty array when no legacy artifacts', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: [],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: false,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toHaveLength(0);\n    });\n\n    it('should handle qwen TOML-based legacy files', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: ['.qwen/commands/openspec-proposal.toml'],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toContain('qwen');\n      expect(tools).toHaveLength(1);\n    });\n\n    it('should handle continue prompt files', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: ['.continue/prompts/openspec-apply.prompt'],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toContain('continue');\n      expect(tools).toHaveLength(1);\n    });\n\n    it('should handle github-copilot prompt files', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: ['.github/prompts/openspec-apply.prompt.md'],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toContain('github-copilot');\n      expect(tools).toHaveLength(1);\n    });\n\n    it('should handle opencode opsx-* legacy files', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: ['.opencode/command/opsx-propose.md'],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toContain('opencode');\n      expect(tools).toHaveLength(1);\n    });\n\n    it('should handle opencode openspec-* legacy files', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: ['.opencode/command/openspec-new.md'],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toContain('opencode');\n      expect(tools).toHaveLength(1);\n    });\n\n    it('should deduplicate opencode when both opsx-* and openspec-* files exist', () => {\n      const detection = {\n        configFiles: [],\n        configFilesToUpdate: [],\n        slashCommandDirs: [],\n        slashCommandFiles: [\n          '.opencode/command/opsx-propose.md',\n          '.opencode/command/openspec-new.md',\n        ],\n        hasOpenspecAgents: false,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toContain('opencode');\n      expect(tools).toHaveLength(1);\n    });\n\n    it('should not extract tools from config files only', () => {\n      // Config files don't indicate which tools were configured\n      // Only slash command dirs/files tell us which tools to upgrade\n      const detection = {\n        configFiles: ['CLAUDE.md'],\n        configFilesToUpdate: ['CLAUDE.md'],\n        slashCommandDirs: [],\n        slashCommandFiles: [],\n        hasOpenspecAgents: true,\n        hasProjectMd: false,\n        hasRootAgentsWithMarkers: false,\n        hasLegacyArtifacts: true,\n      };\n\n      const tools = getToolsFromLegacyArtifacts(detection);\n      expect(tools).toHaveLength(0);\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/list.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { ListCommand } from '../../src/core/list.js';\n\ndescribe('ListCommand', () => {\n  let tempDir: string;\n  let originalLog: typeof console.log;\n  let logOutput: string[] = [];\n\n  beforeEach(async () => {\n    // Create temp directory\n    tempDir = path.join(os.tmpdir(), `openspec-list-test-${Date.now()}`);\n    await fs.mkdir(tempDir, { recursive: true });\n\n    // Mock console.log to capture output\n    originalLog = console.log;\n    console.log = (...args: any[]) => {\n      logOutput.push(args.join(' '));\n    };\n    logOutput = [];\n  });\n\n  afterEach(async () => {\n    // Restore console.log\n    console.log = originalLog;\n\n    // Clean up temp directory\n    await fs.rm(tempDir, { recursive: true, force: true });\n  });\n\n  describe('execute', () => {\n    it('should handle missing openspec/changes directory', async () => {\n      const listCommand = new ListCommand();\n      \n      await expect(listCommand.execute(tempDir, 'changes')).rejects.toThrow(\n        \"No OpenSpec changes directory found. Run 'openspec init' first.\"\n      );\n    });\n\n    it('should handle empty changes directory', async () => {\n      const changesDir = path.join(tempDir, 'openspec', 'changes');\n      await fs.mkdir(changesDir, { recursive: true });\n\n      const listCommand = new ListCommand();\n      await listCommand.execute(tempDir, 'changes');\n\n      expect(logOutput).toEqual(['No active changes found.']);\n    });\n\n    it('should exclude archive directory', async () => {\n      const changesDir = path.join(tempDir, 'openspec', 'changes');\n      await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true });\n      await fs.mkdir(path.join(changesDir, 'my-change'), { recursive: true });\n      \n      // Create tasks.md with some tasks\n      await fs.writeFile(\n        path.join(changesDir, 'my-change', 'tasks.md'),\n        '- [x] Task 1\\n- [ ] Task 2\\n'\n      );\n\n      const listCommand = new ListCommand();\n      await listCommand.execute(tempDir, 'changes');\n\n      expect(logOutput).toContain('Changes:');\n      expect(logOutput.some(line => line.includes('my-change'))).toBe(true);\n      expect(logOutput.some(line => line.includes('archive'))).toBe(false);\n    });\n\n    it('should count tasks correctly', async () => {\n      const changesDir = path.join(tempDir, 'openspec', 'changes');\n      await fs.mkdir(path.join(changesDir, 'test-change'), { recursive: true });\n      \n      await fs.writeFile(\n        path.join(changesDir, 'test-change', 'tasks.md'),\n        `# Tasks\n- [x] Completed task 1\n- [x] Completed task 2\n- [ ] Incomplete task 1\n- [ ] Incomplete task 2\n- [ ] Incomplete task 3\nRegular text that should be ignored\n`\n      );\n\n      const listCommand = new ListCommand();\n      await listCommand.execute(tempDir, 'changes');\n\n      expect(logOutput.some(line => line.includes('2/5 tasks'))).toBe(true);\n    });\n\n    it('should show complete status for fully completed changes', async () => {\n      const changesDir = path.join(tempDir, 'openspec', 'changes');\n      await fs.mkdir(path.join(changesDir, 'completed-change'), { recursive: true });\n      \n      await fs.writeFile(\n        path.join(changesDir, 'completed-change', 'tasks.md'),\n        '- [x] Task 1\\n- [x] Task 2\\n- [x] Task 3\\n'\n      );\n\n      const listCommand = new ListCommand();\n      await listCommand.execute(tempDir, 'changes');\n\n      expect(logOutput.some(line => line.includes('✓ Complete'))).toBe(true);\n    });\n\n    it('should handle changes without tasks.md', async () => {\n      const changesDir = path.join(tempDir, 'openspec', 'changes');\n      await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true });\n\n      const listCommand = new ListCommand();\n      await listCommand.execute(tempDir, 'changes');\n\n      expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true);\n    });\n\n    it('should sort changes alphabetically when sort=name', async () => {\n      const changesDir = path.join(tempDir, 'openspec', 'changes');\n      await fs.mkdir(path.join(changesDir, 'zebra'), { recursive: true });\n      await fs.mkdir(path.join(changesDir, 'alpha'), { recursive: true });\n      await fs.mkdir(path.join(changesDir, 'middle'), { recursive: true });\n\n      const listCommand = new ListCommand();\n      await listCommand.execute(tempDir, 'changes', { sort: 'name' });\n\n      const changeLines = logOutput.filter(line =>\n        line.includes('alpha') || line.includes('middle') || line.includes('zebra')\n      );\n\n      expect(changeLines[0]).toContain('alpha');\n      expect(changeLines[1]).toContain('middle');\n      expect(changeLines[2]).toContain('zebra');\n    });\n\n    it('should handle multiple changes with various states', async () => {\n      const changesDir = path.join(tempDir, 'openspec', 'changes');\n      \n      // Complete change\n      await fs.mkdir(path.join(changesDir, 'completed'), { recursive: true });\n      await fs.writeFile(\n        path.join(changesDir, 'completed', 'tasks.md'),\n        '- [x] Task 1\\n- [x] Task 2\\n'\n      );\n\n      // Partial change\n      await fs.mkdir(path.join(changesDir, 'partial'), { recursive: true });\n      await fs.writeFile(\n        path.join(changesDir, 'partial', 'tasks.md'),\n        '- [x] Done\\n- [ ] Not done\\n- [ ] Also not done\\n'\n      );\n\n      // No tasks\n      await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true });\n\n      const listCommand = new ListCommand();\n      await listCommand.execute(tempDir);\n\n      expect(logOutput).toContain('Changes:');\n      expect(logOutput.some(line => line.includes('completed') && line.includes('✓ Complete'))).toBe(true);\n      expect(logOutput.some(line => line.includes('partial') && line.includes('1/3 tasks'))).toBe(true);\n      expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true);\n    });\n  });\n});"
  },
  {
    "path": "test/core/migration.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\nimport fs from 'node:fs';\nimport { promises as fsp } from 'node:fs';\nimport { AI_TOOLS, type AIToolOption } from '../../src/core/config.js';\nimport { CommandAdapterRegistry } from '../../src/core/command-generation/index.js';\nimport { saveGlobalConfig, getGlobalConfigPath } from '../../src/core/global-config.js';\nimport { migrateIfNeeded, scanInstalledWorkflows } from '../../src/core/migration.js';\n\nconst CLAUDE_TOOL = AI_TOOLS.find((tool) => tool.value === 'claude') as AIToolOption | undefined;\n\nfunction ensureClaudeTool(): AIToolOption {\n  if (!CLAUDE_TOOL) {\n    throw new Error('Claude tool definition not found');\n  }\n  return CLAUDE_TOOL;\n}\n\nasync function writeSkill(projectPath: string, dirName: string): Promise<void> {\n  const skillFile = path.join(projectPath, '.claude', 'skills', dirName, 'SKILL.md');\n  await fsp.mkdir(path.dirname(skillFile), { recursive: true });\n  await fsp.writeFile(skillFile, 'name: test\\n', 'utf-8');\n}\n\nasync function writeManagedCommand(projectPath: string, workflowId: string): Promise<void> {\n  const adapter = CommandAdapterRegistry.get('claude');\n  if (!adapter) {\n    throw new Error('Claude adapter not found');\n  }\n  const commandPath = adapter.getFilePath(workflowId);\n  const fullPath = path.isAbsolute(commandPath)\n    ? commandPath\n    : path.join(projectPath, commandPath);\n  await fsp.mkdir(path.dirname(fullPath), { recursive: true });\n  await fsp.writeFile(fullPath, '# command\\n', 'utf-8');\n}\n\nfunction readRawConfig(): Record<string, unknown> {\n  return JSON.parse(fs.readFileSync(getGlobalConfigPath(), 'utf-8')) as Record<string, unknown>;\n}\n\ndescribe('migration', () => {\n  let projectDir: string;\n  let configHome: string;\n  let originalEnv: NodeJS.ProcessEnv;\n\n  beforeEach(async () => {\n    projectDir = path.join(os.tmpdir(), `openspec-migration-project-${randomUUID()}`);\n    configHome = path.join(os.tmpdir(), `openspec-migration-config-${randomUUID()}`);\n    await fsp.mkdir(projectDir, { recursive: true });\n    await fsp.mkdir(configHome, { recursive: true });\n    originalEnv = { ...process.env };\n    process.env.XDG_CONFIG_HOME = configHome;\n  });\n\n  afterEach(async () => {\n    process.env = originalEnv;\n    await fsp.rm(projectDir, { recursive: true, force: true });\n    await fsp.rm(configHome, { recursive: true, force: true });\n  });\n\n  it('migrates to custom skills delivery when only managed skills are detected', async () => {\n    await writeSkill(projectDir, 'openspec-explore');\n    await writeSkill(projectDir, 'openspec-apply-change');\n\n    migrateIfNeeded(projectDir, [ensureClaudeTool()]);\n\n    const config = readRawConfig();\n    expect(config.profile).toBe('custom');\n    expect(config.delivery).toBe('skills');\n    expect(config.workflows).toEqual(['explore', 'apply']);\n  });\n\n  it('migrates to custom commands delivery when only managed commands are detected', async () => {\n    await writeManagedCommand(projectDir, 'explore');\n    await writeManagedCommand(projectDir, 'archive');\n\n    migrateIfNeeded(projectDir, [ensureClaudeTool()]);\n\n    const config = readRawConfig();\n    expect(config.profile).toBe('custom');\n    expect(config.delivery).toBe('commands');\n    expect(config.workflows).toEqual(['explore', 'archive']);\n  });\n\n  it('migrates to custom both delivery when managed skills and commands are detected', async () => {\n    await writeSkill(projectDir, 'openspec-explore');\n    await writeManagedCommand(projectDir, 'apply');\n\n    migrateIfNeeded(projectDir, [ensureClaudeTool()]);\n\n    const config = readRawConfig();\n    expect(config.profile).toBe('custom');\n    expect(config.delivery).toBe('both');\n    expect(config.workflows).toEqual(['explore', 'apply']);\n  });\n\n  it('does not migrate when profile is already explicitly configured', async () => {\n    saveGlobalConfig({\n      featureFlags: {},\n      profile: 'core',\n      delivery: 'both',\n    });\n    await writeSkill(projectDir, 'openspec-explore');\n\n    migrateIfNeeded(projectDir, [ensureClaudeTool()]);\n\n    const config = readRawConfig();\n    expect(config.profile).toBe('core');\n    expect(config.delivery).toBe('both');\n    expect(config.workflows).toBeUndefined();\n  });\n\n  it('preserves explicit delivery value during migration', async () => {\n    // Raw config has explicit delivery but no profile yet.\n    saveGlobalConfig({\n      featureFlags: {},\n      delivery: 'both',\n    });\n    await writeSkill(projectDir, 'openspec-explore');\n\n    migrateIfNeeded(projectDir, [ensureClaudeTool()]);\n\n    const config = readRawConfig();\n    expect(config.profile).toBe('custom');\n    expect(config.delivery).toBe('both');\n    expect(config.workflows).toEqual(['explore']);\n  });\n\n  it('does not migrate when no managed workflow artifacts are detected', async () => {\n    migrateIfNeeded(projectDir, [ensureClaudeTool()]);\n\n    expect(fs.existsSync(getGlobalConfigPath())).toBe(false);\n  });\n\n  it('ignores unknown custom skill and command files when scanning workflows', async () => {\n    await writeSkill(projectDir, 'my-custom-skill');\n    const customCommandPath = path.join(projectDir, '.claude', 'commands', 'opsx', 'my-custom.md');\n    await fsp.mkdir(path.dirname(customCommandPath), { recursive: true });\n    await fsp.writeFile(customCommandPath, '# custom\\n', 'utf-8');\n\n    const workflows = scanInstalledWorkflows(projectDir, [ensureClaudeTool()]);\n    expect(workflows).toEqual([]);\n\n    migrateIfNeeded(projectDir, [ensureClaudeTool()]);\n    expect(fs.existsSync(getGlobalConfigPath())).toBe(false);\n  });\n});\n"
  },
  {
    "path": "test/core/parsers/change-parser.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport path from 'path';\nimport { promises as fs } from 'fs';\nimport os from 'os';\nimport { ChangeParser } from '../../../src/core/parsers/change-parser.js';\n\nasync function withTempDir(run: (dir: string) => Promise<void>) {\n  const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-change-parser-'));\n  try {\n    await run(dir);\n  } finally {\n    // Best-effort cleanup\n    try { await fs.rm(dir, { recursive: true, force: true }); } catch {}\n  }\n}\n\ndescribe('ChangeParser', () => {\n  it('parses simple What Changes bullet list', async () => {\n    const content = `# Test Change\\n\\n## Why\\nWe need it because reasons that are sufficiently long.\\n\\n## What Changes\\n- **spec-a:** Add a new requirement to A\\n- **spec-b:** Rename requirement X to Y\\n- **spec-c:** Remove obsolete requirement`;\n\n    const parser = new ChangeParser(content, process.cwd());\n    const change = await parser.parseChangeWithDeltas('test-change');\n\n    expect(change.name).toBe('test-change');\n    expect(change.deltas.length).toBe(3);\n    expect(change.deltas[0].spec).toBe('spec-a');\n    expect(['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED']).toContain(change.deltas[1].operation);\n  });\n\n  it('prefers delta-format specs over simple bullets when both exist', async () => {\n    await withTempDir(async (dir) => {\n      const changeDir = dir;\n      const specsDir = path.join(changeDir, 'specs', 'foo');\n      await fs.mkdir(specsDir, { recursive: true });\n\n      const content = `# Test Change\\n\\n## Why\\nWe need it because reasons that are sufficiently long.\\n\\n## What Changes\\n- **foo:** Add something via bullets (should be overridden)`;\n      const deltaSpec = `# Delta for Foo\\n\\n## ADDED Requirements\\n\\n### Requirement: New thing\\n\\n#### Scenario: basic\\nGiven X\\nWhen Y\\nThen Z`;\n\n      await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec, 'utf8');\n\n      const parser = new ChangeParser(content, changeDir);\n      const change = await parser.parseChangeWithDeltas('test-change');\n\n      expect(change.deltas.length).toBeGreaterThan(0);\n      // Since delta spec exists, the description should reflect delta-derived entries\n      expect(change.deltas[0].spec).toBe('foo');\n      expect(change.deltas[0].description).toContain('Add requirement:');\n      expect(change.deltas[0].operation).toBe('ADDED');\n      expect(change.deltas[0].requirement).toBeDefined();\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/parsers/markdown-parser.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { MarkdownParser } from '../../../src/core/parsers/markdown-parser.js';\n\ndescribe('MarkdownParser', () => {\n  describe('parseSpec', () => {\n    it('should parse a valid spec', () => {\n      const content = `# User Authentication Spec\n\n## Purpose\nThis specification defines the requirements for user authentication.\n\n## Requirements\n\n### The system SHALL provide secure user authentication\nUsers need to be able to log in securely.\n\n#### Scenario: Successful login\nGiven a user with valid credentials\nWhen they submit the login form\nThen they are authenticated\n\n### The system SHALL handle invalid login attempts\nThe system must handle incorrect credentials.\n\n#### Scenario: Invalid credentials\nGiven a user with invalid credentials\nWhen they submit the login form\nThen they see an error message`;\n\n      const parser = new MarkdownParser(content);\n      const spec = parser.parseSpec('user-auth');\n      \n      expect(spec.name).toBe('user-auth');\n      expect(spec.overview).toContain('requirements for user authentication');\n      expect(spec.requirements).toHaveLength(2);\n      \n      const firstReq = spec.requirements[0];\n      expect(firstReq.text).toBe('Users need to be able to log in securely.');\n      expect(firstReq.scenarios).toHaveLength(1);\n      \n      const scenario = firstReq.scenarios[0];\n      expect(scenario.rawText).toContain('Given a user with valid credentials');\n      expect(scenario.rawText).toContain('When they submit the login form');\n      expect(scenario.rawText).toContain('Then they are authenticated');\n    });\n\n    it('should handle multi-line scenarios', () => {\n      const content = `# Test Spec\n\n## Purpose\nTest overview\n\n## Requirements\n\n### The system SHALL handle complex scenarios\nThis requirement has content.\n\n#### Scenario: Multi-line scenario\nGiven a user with valid credentials\n  and the user has admin privileges\n  and the system is in maintenance mode\nWhen they attempt to login\n  and provide their MFA token\nThen they are authenticated\n  and redirected to admin dashboard\n  and see a maintenance warning`;\n\n      const parser = new MarkdownParser(content);\n      const spec = parser.parseSpec('test');\n      \n      const scenario = spec.requirements[0].scenarios[0];\n      expect(scenario.rawText).toContain('Given a user with valid credentials');\n      expect(scenario.rawText).toContain('and the user has admin privileges');\n      expect(scenario.rawText).toContain('When they attempt to login');\n      expect(scenario.rawText).toContain('and provide their MFA token');\n      expect(scenario.rawText).toContain('Then they are authenticated');\n      expect(scenario.rawText).toContain('and see a maintenance warning');\n    });\n\n    it('should throw error for missing overview', () => {\n      const content = `# Test Spec\n\n## Requirements\n\n### The system SHALL do something\n\n#### Scenario: Test\nGiven test\nWhen action\nThen result`;\n\n      const parser = new MarkdownParser(content);\n      expect(() => parser.parseSpec('test')).toThrow('must have a Purpose section');\n    });\n\n    it('should throw error for missing requirements', () => {\n      const content = `# Test Spec\n\n## Purpose\nThis is a test spec`;\n\n      const parser = new MarkdownParser(content);\n      expect(() => parser.parseSpec('test')).toThrow('must have a Requirements section');\n    });\n  });\n\n  describe('parseChange', () => {\n    it('should parse a valid change', () => {\n      const content = `# Add User Authentication\n\n## Why\nWe need to implement user authentication to secure the application and protect user data from unauthorized access.\n\n## What Changes\n- **user-auth:** Add new user authentication specification\n- **api-endpoints:** Modify to include authentication endpoints\n- **database:** Remove old session management tables`;\n\n      const parser = new MarkdownParser(content);\n      const change = parser.parseChange('add-user-auth');\n      \n      expect(change.name).toBe('add-user-auth');\n      expect(change.why).toContain('secure the application');\n      expect(change.whatChanges).toContain('user-auth');\n      expect(change.deltas).toHaveLength(3);\n      \n      expect(change.deltas[0].spec).toBe('user-auth');\n      expect(change.deltas[0].operation).toBe('ADDED');\n      expect(change.deltas[0].description).toContain('Add new user authentication');\n      \n      expect(change.deltas[1].spec).toBe('api-endpoints');\n      expect(change.deltas[1].operation).toBe('MODIFIED');\n      \n      expect(change.deltas[2].spec).toBe('database');\n      expect(change.deltas[2].operation).toBe('REMOVED');\n    });\n\n    it('should throw error for missing why section', () => {\n      const content = `# Test Change\n\n## What Changes\n- **test:** Add test`;\n\n      const parser = new MarkdownParser(content);\n      expect(() => parser.parseChange('test')).toThrow('must have a Why section');\n    });\n\n    it('should throw error for missing what changes section', () => {\n      const content = `# Test Change\n\n## Why\nBecause we need it`;\n\n      const parser = new MarkdownParser(content);\n      expect(() => parser.parseChange('test')).toThrow('must have a What Changes section');\n    });\n\n    it('should handle changes without deltas', () => {\n      const content = `# Test Change\n\n## Why\nWe need to make some changes for important reasons that justify this work.\n\n## What Changes\nSome general description of changes without specific deltas`;\n\n      const parser = new MarkdownParser(content);\n      const change = parser.parseChange('test');\n      \n      expect(change.deltas).toHaveLength(0);\n    });\n\n    it('parses change documents saved with CRLF line endings', () => {\n      const crlfContent = [\n        '# CRLF Change',\n        '',\n        '## Why',\n        'Reasons on Windows editors should parse like POSIX environments.',\n        '',\n        '## What Changes',\n        '- **alpha:** Add cross-platform parsing coverage',\n      ].join('\\r\\n');\n\n      const parser = new MarkdownParser(crlfContent);\n      const change = parser.parseChange('crlf-change');\n\n      expect(change.why).toContain('Windows editors should parse');\n      expect(change.deltas).toHaveLength(1);\n      expect(change.deltas[0].spec).toBe('alpha');\n    });\n  });\n\n  describe('section parsing', () => {\n    it('should handle nested sections correctly', () => {\n      const content = `# Test Spec\n\n## Purpose\nThis is the overview section for testing nested sections.\n\n## Requirements\n\n### The system SHALL handle nested sections\n\n#### Scenario: Test nested\nGiven a nested structure\nWhen parsing sections\nThen handle correctly\n\n### Another requirement SHALL work\n\n#### Scenario: Another test\nGiven another test\nWhen running\nThen success`;\n\n      const parser = new MarkdownParser(content);\n      const spec = parser.parseSpec('test');\n      \n      // Should find the correct sections at different levels\n      expect(spec).toBeDefined();\n      expect(spec.overview).toContain('testing nested sections');\n      expect(spec.requirements).toHaveLength(2);\n    });\n\n    it('should preserve content between headers', () => {\n      const content = `# Test\n\n## Purpose\nThis is the overview.\nIt has multiple lines.\n\nSome more content here.\n\n## Requirements\n\n### Requirement 1\nContent for requirement 1`;\n\n      const parser = new MarkdownParser(content);\n      const spec = parser.parseSpec('test');\n      \n      expect(spec.overview).toContain('multiple lines');\n      expect(spec.overview).toContain('more content');\n    });\n\n    it('should use requirement heading as fallback when no content is provided', () => {\n      const content = `# Test Spec\n\n## Purpose\nTest overview\n\n## Requirements\n\n### The system SHALL use heading text when no content\n\n#### Scenario: Test\nGiven test\nWhen action\nThen result`;\n\n      const parser = new MarkdownParser(content);\n      const spec = parser.parseSpec('test');\n      \n      expect(spec.requirements[0].text).toBe('The system SHALL use heading text when no content');\n    });\n\n    it('should extract requirement text from first non-empty content line', () => {\n      const content = `# Test Spec\n\n## Purpose\nTest overview\n\n## Requirements\n\n### Requirement heading\n\nThis is the actual requirement text.\nThis is additional description.\n\n#### Scenario: Test\nGiven test\nWhen action\nThen result`;\n\n      const parser = new MarkdownParser(content);\n      const spec = parser.parseSpec('test');\n      \n      expect(spec.requirements[0].text).toBe('This is the actual requirement text.');\n    });\n  });\n});"
  },
  {
    "path": "test/core/profile-sync-drift.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport {\n  hasProjectConfigDrift,\n  WORKFLOW_TO_SKILL_DIR,\n} from '../../src/core/profile-sync-drift.js';\nimport { CORE_WORKFLOWS } from '../../src/core/profiles.js';\nimport { CommandAdapterRegistry } from '../../src/core/command-generation/index.js';\n\nfunction writeSkill(projectDir: string, workflowId: string): void {\n  const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId as keyof typeof WORKFLOW_TO_SKILL_DIR];\n  const skillPath = path.join(projectDir, '.claude', 'skills', skillDirName, 'SKILL.md');\n  fs.mkdirSync(path.dirname(skillPath), { recursive: true });\n  fs.writeFileSync(skillPath, `name: ${skillDirName}\\n`);\n}\n\nfunction writeCommand(projectDir: string, workflowId: string): void {\n  const adapter = CommandAdapterRegistry.get('claude');\n  if (!adapter) throw new Error('Claude adapter unavailable in test environment');\n  const cmdPath = adapter.getFilePath(workflowId);\n  const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectDir, cmdPath);\n  fs.mkdirSync(path.dirname(fullPath), { recursive: true });\n  fs.writeFileSync(fullPath, `# ${workflowId}\\n`);\n}\n\nfunction setupCoreSkills(projectDir: string): void {\n  for (const workflow of CORE_WORKFLOWS) {\n    writeSkill(projectDir, workflow);\n  }\n}\n\nfunction setupCoreCommands(projectDir: string): void {\n  for (const workflow of CORE_WORKFLOWS) {\n    writeCommand(projectDir, workflow);\n  }\n}\n\ndescribe('profile sync drift detection', () => {\n  let tempDir: string;\n\n  beforeEach(() => {\n    tempDir = path.join(os.tmpdir(), `openspec-profile-sync-drift-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);\n    fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true });\n  });\n\n  afterEach(() => {\n    fs.rmSync(tempDir, { recursive: true, force: true });\n  });\n\n  it('detects drift for skills-only delivery when commands still exist', () => {\n    setupCoreSkills(tempDir);\n    setupCoreCommands(tempDir);\n\n    const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'skills');\n    expect(hasDrift).toBe(true);\n  });\n\n  it('detects drift for commands-only delivery when skills still exist', () => {\n    setupCoreCommands(tempDir);\n    setupCoreSkills(tempDir);\n\n    const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'commands');\n    expect(hasDrift).toBe(true);\n  });\n\n  it('detects drift when required profile workflow files are missing', () => {\n    writeSkill(tempDir, 'explore');\n\n    const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'both');\n    expect(hasDrift).toBe(true);\n  });\n\n  it('returns false when project files match core profile and delivery', () => {\n    setupCoreSkills(tempDir);\n    setupCoreCommands(tempDir);\n\n    const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'both');\n    expect(hasDrift).toBe(false);\n  });\n\n  it('detects drift when extra workflows are installed for both delivery', () => {\n    setupCoreSkills(tempDir);\n    setupCoreCommands(tempDir);\n    writeSkill(tempDir, 'sync');\n    writeCommand(tempDir, 'sync');\n\n    const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'both');\n    expect(hasDrift).toBe(true);\n  });\n});\n"
  },
  {
    "path": "test/core/profiles.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\n\nimport {\n  CORE_WORKFLOWS,\n  ALL_WORKFLOWS,\n  getProfileWorkflows,\n} from '../../src/core/profiles.js';\n\ndescribe('profiles', () => {\n  describe('CORE_WORKFLOWS', () => {\n    it('should contain the four core workflows', () => {\n      expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'archive']);\n    });\n\n    it('should be a subset of ALL_WORKFLOWS', () => {\n      for (const workflow of CORE_WORKFLOWS) {\n        expect(ALL_WORKFLOWS).toContain(workflow);\n      }\n    });\n  });\n\n  describe('ALL_WORKFLOWS', () => {\n    it('should contain all 11 workflows', () => {\n      expect(ALL_WORKFLOWS).toHaveLength(11);\n    });\n\n    it('should contain expected workflow IDs', () => {\n      const expected = [\n        'propose', 'explore', 'new', 'continue', 'apply',\n        'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard',\n      ];\n      expect([...ALL_WORKFLOWS]).toEqual(expected);\n    });\n  });\n\n  describe('getProfileWorkflows', () => {\n    it('should return core workflows for core profile', () => {\n      const result = getProfileWorkflows('core');\n      expect(result).toEqual(CORE_WORKFLOWS);\n    });\n\n    it('should return core workflows for core profile even if customWorkflows provided', () => {\n      const result = getProfileWorkflows('core', ['new', 'apply']);\n      expect(result).toEqual(CORE_WORKFLOWS);\n    });\n\n    it('should return custom workflows for custom profile', () => {\n      const customWorkflows = ['explore', 'new', 'apply', 'ff'];\n      const result = getProfileWorkflows('custom', customWorkflows);\n      expect(result).toEqual(customWorkflows);\n    });\n\n    it('should return empty array for custom profile with no customWorkflows', () => {\n      const result = getProfileWorkflows('custom');\n      expect(result).toEqual([]);\n    });\n\n    it('should return empty array for custom profile with empty customWorkflows', () => {\n      const result = getProfileWorkflows('custom', []);\n      expect(result).toEqual([]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/project-config.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport {\n  readProjectConfig,\n  validateConfigRules,\n  suggestSchemas,\n} from '../../src/core/project-config.js';\n\ndescribe('project-config', () => {\n  let tempDir: string;\n  let consoleWarnSpy: ReturnType<typeof vi.spyOn>;\n\n  beforeEach(() => {\n    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-config-'));\n    consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n  });\n\n  afterEach(() => {\n    fs.rmSync(tempDir, { recursive: true, force: true });\n    consoleWarnSpy.mockRestore();\n  });\n\n  describe('readProjectConfig', () => {\n    describe('resilient parsing', () => {\n      it('should parse complete valid config', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: |\n  Tech stack: TypeScript, React\n  API style: RESTful\nrules:\n  proposal:\n    - Include rollback plan\n    - Identify affected teams\n  specs:\n    - Use Given/When/Then format\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toEqual({\n          schema: 'spec-driven',\n          context: 'Tech stack: TypeScript, React\\nAPI style: RESTful\\n',\n          rules: {\n            proposal: ['Include rollback plan', 'Identify affected teams'],\n            specs: ['Use Given/When/Then format'],\n          },\n        });\n        expect(consoleWarnSpy).not.toHaveBeenCalled();\n      });\n\n      it('should parse minimal config with schema only', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(path.join(configDir, 'config.yaml'), 'schema: spec-driven\\n');\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toEqual({\n          schema: 'spec-driven',\n        });\n        expect(consoleWarnSpy).not.toHaveBeenCalled();\n      });\n\n      it('should return partial config when schema is invalid', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: \"\"\ncontext: Valid context here\nrules:\n  proposal:\n    - Valid rule\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toEqual({\n          context: 'Valid context here',\n          rules: {\n            proposal: ['Valid rule'],\n          },\n        });\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining(\"Invalid 'schema' field\")\n        );\n      });\n\n      it('should return partial config when context is invalid', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: 123\nrules:\n  proposal:\n    - Valid rule\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toEqual({\n          schema: 'spec-driven',\n          rules: {\n            proposal: ['Valid rule'],\n          },\n        });\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining(\"Invalid 'context' field\")\n        );\n      });\n\n      it('should return partial config when rules is not an object', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: Valid context\nrules: [\"not\", \"an\", \"object\"]\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toEqual({\n          schema: 'spec-driven',\n          context: 'Valid context',\n        });\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining(\"Invalid 'rules' field\")\n        );\n      });\n\n      it('should handle rules: null without aborting config parsing', () => {\n        // YAML `rules:` with no value parses to null\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: Valid context\nrules:\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        // Should still parse schema and context despite null rules\n        expect(config).toEqual({\n          schema: 'spec-driven',\n          context: 'Valid context',\n        });\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining(\"Invalid 'rules' field\")\n        );\n      });\n\n      it('should filter out invalid rules for specific artifact', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\nrules:\n  proposal:\n    - Valid rule\n  specs: \"not an array\"\n  design:\n    - Another valid rule\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toEqual({\n          schema: 'spec-driven',\n          rules: {\n            proposal: ['Valid rule'],\n            design: ['Another valid rule'],\n          },\n        });\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining(\"Rules for 'specs' must be an array of strings\")\n        );\n      });\n\n      it('should filter out empty string rules', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\nrules:\n  proposal:\n    - Valid rule\n    - \"\"\n    - Another valid rule\n    - \"\"\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toEqual({\n          schema: 'spec-driven',\n          rules: {\n            proposal: ['Valid rule', 'Another valid rule'],\n          },\n        });\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining(\"Some rules for 'proposal' are empty strings\")\n        );\n      });\n\n      it('should skip artifact if all rules are empty strings', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\nrules:\n  proposal:\n    - \"\"\n    - \"\"\n  specs:\n    - Valid rule\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toEqual({\n          schema: 'spec-driven',\n          rules: {\n            specs: ['Valid rule'],\n          },\n        });\n      });\n\n      it('should handle completely invalid YAML gracefully', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(path.join(configDir, 'config.yaml'), 'schema: [unclosed');\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toBeNull();\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining('Failed to parse openspec/config.yaml'),\n          expect.anything()\n        );\n      });\n\n      it('should warn when config is not a YAML object', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(path.join(configDir, 'config.yaml'), '\"just a string\"');\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toBeNull();\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining('not a valid YAML object')\n        );\n      });\n\n      it('should handle empty config file', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(path.join(configDir, 'config.yaml'), '');\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toBeNull();\n      });\n    });\n\n    describe('context size limit enforcement', () => {\n      it('should accept context under 50KB limit', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        const smallContext = 'a'.repeat(1000); // 1KB\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\\ncontext: \"${smallContext}\"\\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config?.context).toBe(smallContext);\n        expect(consoleWarnSpy).not.toHaveBeenCalledWith(\n          expect.stringContaining('Context too large')\n        );\n      });\n\n      it('should reject context over 50KB limit', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        const largeContext = 'a'.repeat(51 * 1024); // 51KB\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\\ncontext: \"${largeContext}\"\\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toEqual({ schema: 'spec-driven' });\n        expect(config?.context).toBeUndefined();\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining('Context too large (51.0KB, limit: 50KB)')\n        );\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining('Ignoring context field')\n        );\n      });\n\n      it('should handle context exactly at 50KB limit', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        const exactContext = 'a'.repeat(50 * 1024); // Exactly 50KB\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\\ncontext: \"${exactContext}\"\\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config?.context).toBe(exactContext);\n        expect(consoleWarnSpy).not.toHaveBeenCalledWith(\n          expect.stringContaining('Context too large')\n        );\n      });\n\n      it('should handle multi-byte UTF-8 characters in size calculation', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        // Unicode snowman is 3 bytes in UTF-8\n        const contextWithUnicode = '☃'.repeat(18000); // ~54KB in UTF-8 (18000 * 3 bytes)\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: |\n  ${contextWithUnicode}\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config?.context).toBeUndefined();\n        expect(consoleWarnSpy).toHaveBeenCalledWith(\n          expect.stringContaining('Context too large')\n        );\n      });\n    });\n\n    describe('.yml/.yaml precedence', () => {\n      it('should prefer .yaml when both exist', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          'schema: spec-driven\\ncontext: from yaml\\n'\n        );\n        fs.writeFileSync(\n          path.join(configDir, 'config.yml'),\n          'schema: custom-schema\\ncontext: from yml\\n'\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config?.schema).toBe('spec-driven');\n        expect(config?.context).toBe('from yaml');\n      });\n\n      it('should use .yml when .yaml does not exist', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yml'),\n          'schema: custom-schema\\ncontext: from yml\\n'\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config?.schema).toBe('custom-schema');\n        expect(config?.context).toBe('from yml');\n      });\n\n      it('should return null when neither .yaml nor .yml exist', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toBeNull();\n        expect(consoleWarnSpy).not.toHaveBeenCalled();\n      });\n\n      it('should return null when openspec directory does not exist', () => {\n        const config = readProjectConfig(tempDir);\n\n        expect(config).toBeNull();\n        expect(consoleWarnSpy).not.toHaveBeenCalled();\n      });\n    });\n\n    describe('multi-line and special characters', () => {\n      it('should preserve multi-line context', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: |\n  Line 1: Tech stack\n  Line 2: API conventions\n  Line 3: Testing approach\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config?.context).toBe(\n          'Line 1: Tech stack\\nLine 2: API conventions\\nLine 3: Testing approach\\n'\n        );\n      });\n\n      it('should preserve special YAML characters in context', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\ncontext: |\n  Special chars: : @ # $ % & * [ ] { }\n  Quotes: \"double\" 'single'\n  Symbols: < > | \\\\ /\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config?.context).toContain('Special chars: : @ # $ % & * [ ] { }');\n        expect(config?.context).toContain('\"double\"');\n        expect(config?.context).toContain(\"'single'\");\n        expect(config?.context).toContain('Symbols: < > | \\\\ /');\n      });\n\n      it('should preserve special characters in rule strings', () => {\n        const configDir = path.join(tempDir, 'openspec');\n        fs.mkdirSync(configDir, { recursive: true });\n        fs.writeFileSync(\n          path.join(configDir, 'config.yaml'),\n          `schema: spec-driven\nrules:\n  proposal:\n    - \"Use <template> tags in docs\"\n    - \"Reference @mentions and #channels\"\n    - \"Follow {variable} naming\"\n`\n        );\n\n        const config = readProjectConfig(tempDir);\n\n        expect(config?.rules?.proposal).toEqual([\n          'Use <template> tags in docs',\n          'Reference @mentions and #channels',\n          'Follow {variable} naming',\n        ]);\n      });\n    });\n  });\n\n  describe('validateConfigRules', () => {\n    it('should return no warnings for valid artifact IDs', () => {\n      const rules = {\n        proposal: ['Rule 1'],\n        specs: ['Rule 2'],\n        design: ['Rule 3'],\n      };\n      const validIds = new Set(['proposal', 'specs', 'design', 'tasks']);\n\n      const warnings = validateConfigRules(rules, validIds, 'spec-driven');\n\n      expect(warnings).toEqual([]);\n    });\n\n    it('should warn about unknown artifact IDs', () => {\n      const rules = {\n        proposal: ['Rule 1'],\n        testplan: ['Rule 2'], // Invalid\n        documentation: ['Rule 3'], // Invalid\n      };\n      const validIds = new Set(['proposal', 'specs', 'design', 'tasks']);\n\n      const warnings = validateConfigRules(rules, validIds, 'spec-driven');\n\n      expect(warnings).toHaveLength(2);\n      expect(warnings[0]).toContain('Unknown artifact ID in rules: \"testplan\"');\n      expect(warnings[0]).toContain('Valid IDs for schema \"spec-driven\": design, proposal, specs, tasks');\n      expect(warnings[1]).toContain('Unknown artifact ID in rules: \"documentation\"');\n    });\n\n    it('should return warnings for all unknown artifact IDs', () => {\n      const rules = {\n        invalid1: ['Rule 1'],\n        invalid2: ['Rule 2'],\n        invalid3: ['Rule 3'],\n      };\n      const validIds = new Set(['proposal', 'specs']);\n\n      const warnings = validateConfigRules(rules, validIds, 'spec-driven');\n\n      expect(warnings).toHaveLength(3);\n    });\n\n    it('should handle empty rules object', () => {\n      const rules = {};\n      const validIds = new Set(['proposal', 'specs']);\n\n      const warnings = validateConfigRules(rules, validIds, 'spec-driven');\n\n      expect(warnings).toEqual([]);\n    });\n  });\n\n  describe('suggestSchemas', () => {\n    const availableSchemas = [\n      { name: 'spec-driven', isBuiltIn: true },\n      { name: 'custom-workflow', isBuiltIn: false },\n      { name: 'team-process', isBuiltIn: false },\n    ];\n\n    it('should suggest close matches using fuzzy matching', () => {\n      const message = suggestSchemas('spec-drven', availableSchemas); // Missing 'i'\n\n      expect(message).toContain(\"Schema 'spec-drven' not found\");\n      expect(message).toContain('Did you mean one of these?');\n      expect(message).toContain('spec-driven (built-in)');\n    });\n\n    it('should suggest custom-workflow for workflow typo', () => {\n      const message = suggestSchemas('custom-workflo', availableSchemas);\n\n      expect(message).toContain('Did you mean one of these?');\n      expect(message).toContain('custom-workflow');\n    });\n\n    it('should list all available schemas', () => {\n      const message = suggestSchemas('nonexistent', availableSchemas);\n\n      expect(message).toContain('Available schemas:');\n      expect(message).toContain('Built-in: spec-driven');\n      expect(message).toContain('Project-local: custom-workflow, team-process');\n    });\n\n    it('should handle case when no project-local schemas exist', () => {\n      const builtInOnly = [\n        { name: 'spec-driven', isBuiltIn: true },\n      ];\n      const message = suggestSchemas('invalid', builtInOnly);\n\n      expect(message).toContain('Built-in: spec-driven');\n      expect(message).toContain('Project-local: (none found)');\n    });\n\n    it('should include fix instruction', () => {\n      const message = suggestSchemas('wrong-schema', availableSchemas);\n\n      expect(message).toContain(\n        \"Fix: Edit openspec/config.yaml and change 'schema: wrong-schema' to a valid schema name\"\n      );\n    });\n\n    it('should limit suggestions to top 3 matches', () => {\n      const manySchemas = [\n        { name: 'test-a', isBuiltIn: true },\n        { name: 'test-b', isBuiltIn: true },\n        { name: 'test-c', isBuiltIn: true },\n        { name: 'test-d', isBuiltIn: true },\n        { name: 'test-e', isBuiltIn: true },\n      ];\n      const message = suggestSchemas('test', manySchemas);\n\n      // Should suggest at most 3\n      const suggestionCount = (message.match(/test-/g) || []).length;\n      expect(suggestionCount).toBeGreaterThanOrEqual(3);\n      expect(suggestionCount).toBeLessThanOrEqual(3 + 5); // 3 in suggestions + 5 in \"Available\" list\n    });\n\n    it('should not suggest schemas with distance > 3', () => {\n      const message = suggestSchemas('abcdefghijk', availableSchemas);\n\n      // 'abcdefghijk' has large Levenshtein distance from all schemas\n      expect(message).not.toContain('Did you mean');\n      expect(message).toContain('Available schemas:');\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/shared/skill-generation.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport {\n  getSkillTemplates,\n  getCommandTemplates,\n  getCommandContents,\n  generateSkillContent,\n} from '../../../src/core/shared/skill-generation.js';\n\ndescribe('skill-generation', () => {\n  describe('getSkillTemplates', () => {\n    it('should return all 11 skill templates', () => {\n      const templates = getSkillTemplates();\n      expect(templates).toHaveLength(11);\n    });\n\n    it('should have unique directory names', () => {\n      const templates = getSkillTemplates();\n      const dirNames = templates.map(t => t.dirName);\n      const uniqueDirNames = new Set(dirNames);\n      expect(uniqueDirNames.size).toBe(templates.length);\n    });\n\n    it('should include all expected skills', () => {\n      const templates = getSkillTemplates();\n      const dirNames = templates.map(t => t.dirName);\n\n      expect(dirNames).toContain('openspec-explore');\n      expect(dirNames).toContain('openspec-new-change');\n      expect(dirNames).toContain('openspec-continue-change');\n      expect(dirNames).toContain('openspec-apply-change');\n      expect(dirNames).toContain('openspec-ff-change');\n      expect(dirNames).toContain('openspec-sync-specs');\n      expect(dirNames).toContain('openspec-archive-change');\n      expect(dirNames).toContain('openspec-bulk-archive-change');\n      expect(dirNames).toContain('openspec-verify-change');\n      expect(dirNames).toContain('openspec-onboard');\n      expect(dirNames).toContain('openspec-propose');\n    });\n\n    it('should have valid template structure', () => {\n      const templates = getSkillTemplates();\n\n      for (const { template, dirName, workflowId } of templates) {\n        expect(template.name).toBeTruthy();\n        expect(template.description).toBeTruthy();\n        expect(template.instructions).toBeTruthy();\n        expect(dirName).toBeTruthy();\n        expect(workflowId).toBeTruthy();\n      }\n    });\n\n    it('should have unique workflow IDs', () => {\n      const templates = getSkillTemplates();\n      const ids = templates.map(t => t.workflowId);\n      const uniqueIds = new Set(ids);\n      expect(uniqueIds.size).toBe(templates.length);\n    });\n\n    it('should filter by workflow IDs when provided', () => {\n      const filtered = getSkillTemplates(['propose', 'explore', 'apply', 'archive']);\n      expect(filtered).toHaveLength(4);\n      const ids = filtered.map(t => t.workflowId);\n      expect(ids).toContain('propose');\n      expect(ids).toContain('explore');\n      expect(ids).toContain('apply');\n      expect(ids).toContain('archive');\n      expect(ids).not.toContain('new');\n      expect(ids).not.toContain('ff');\n    });\n\n    it('should return all templates when filter is undefined', () => {\n      const all = getSkillTemplates();\n      const noFilter = getSkillTemplates(undefined);\n      expect(noFilter).toHaveLength(all.length);\n    });\n\n    it('should return empty array when filter matches nothing', () => {\n      const filtered = getSkillTemplates(['nonexistent']);\n      expect(filtered).toHaveLength(0);\n    });\n\n    it('should return single template when filter has one workflow', () => {\n      const filtered = getSkillTemplates(['propose']);\n      expect(filtered).toHaveLength(1);\n      expect(filtered[0].workflowId).toBe('propose');\n      expect(filtered[0].dirName).toBe('openspec-propose');\n    });\n  });\n\n  describe('getCommandTemplates', () => {\n    it('should return all 11 command templates', () => {\n      const templates = getCommandTemplates();\n      expect(templates).toHaveLength(11);\n    });\n\n    it('should have unique IDs', () => {\n      const templates = getCommandTemplates();\n      const ids = templates.map(t => t.id);\n      const uniqueIds = new Set(ids);\n      expect(uniqueIds.size).toBe(templates.length);\n    });\n\n    it('should include all expected commands', () => {\n      const templates = getCommandTemplates();\n      const ids = templates.map(t => t.id);\n\n      expect(ids).toContain('explore');\n      expect(ids).toContain('new');\n      expect(ids).toContain('continue');\n      expect(ids).toContain('apply');\n      expect(ids).toContain('ff');\n      expect(ids).toContain('sync');\n      expect(ids).toContain('archive');\n      expect(ids).toContain('bulk-archive');\n      expect(ids).toContain('verify');\n      expect(ids).toContain('onboard');\n      expect(ids).toContain('propose');\n    });\n\n    it('should filter by workflow IDs when provided', () => {\n      const filtered = getCommandTemplates(['propose', 'explore', 'apply', 'archive']);\n      expect(filtered).toHaveLength(4);\n      const ids = filtered.map(t => t.id);\n      expect(ids).toContain('propose');\n      expect(ids).toContain('explore');\n      expect(ids).toContain('apply');\n      expect(ids).toContain('archive');\n      expect(ids).not.toContain('new');\n      expect(ids).not.toContain('ff');\n    });\n\n    it('should return all templates when filter is undefined', () => {\n      const all = getCommandTemplates();\n      const noFilter = getCommandTemplates(undefined);\n      expect(noFilter).toHaveLength(all.length);\n    });\n\n    it('should return empty array when filter matches nothing', () => {\n      const filtered = getCommandTemplates(['nonexistent']);\n      expect(filtered).toHaveLength(0);\n    });\n  });\n\n  describe('getCommandContents', () => {\n    it('should return all 11 command contents', () => {\n      const contents = getCommandContents();\n      expect(contents).toHaveLength(11);\n    });\n\n    it('should have valid content structure', () => {\n      const contents = getCommandContents();\n\n      for (const content of contents) {\n        expect(content.id).toBeTruthy();\n        expect(content.name).toBeTruthy();\n        expect(content.description).toBeTruthy();\n        expect(content.body).toBeTruthy();\n      }\n    });\n\n    it('should have matching IDs with command templates', () => {\n      const templates = getCommandTemplates();\n      const contents = getCommandContents();\n\n      const templateIds = templates.map(t => t.id).sort();\n      const contentIds = contents.map(c => c.id).sort();\n\n      expect(contentIds).toEqual(templateIds);\n    });\n\n    it('should filter by workflow IDs when provided', () => {\n      const filtered = getCommandContents(['propose', 'explore']);\n      expect(filtered).toHaveLength(2);\n      const ids = filtered.map(c => c.id);\n      expect(ids).toContain('propose');\n      expect(ids).toContain('explore');\n      expect(ids).not.toContain('new');\n    });\n\n    it('should return all contents when filter is undefined', () => {\n      const all = getCommandContents();\n      const noFilter = getCommandContents(undefined);\n      expect(noFilter).toHaveLength(all.length);\n    });\n  });\n\n  describe('generateSkillContent', () => {\n    it('should generate valid YAML frontmatter', () => {\n      const template = {\n        name: 'test-skill',\n        description: 'Test description',\n        instructions: 'Test instructions',\n        license: 'MIT',\n        compatibility: 'Test compatibility',\n        metadata: {\n          author: 'test-author',\n          version: '2.0',\n        },\n      };\n\n      const content = generateSkillContent(template, '0.23.0');\n\n      expect(content).toMatch(/^---\\n/);\n      expect(content).toContain('name: test-skill');\n      expect(content).toContain('description: Test description');\n      expect(content).toContain('license: MIT');\n      expect(content).toContain('compatibility: Test compatibility');\n      expect(content).toContain('author: test-author');\n      expect(content).toContain('version: \"2.0\"');\n      expect(content).toContain('generatedBy: \"0.23.0\"');\n      expect(content).toContain('Test instructions');\n    });\n\n    it('should use default values for optional fields', () => {\n      const template = {\n        name: 'minimal-skill',\n        description: 'Minimal description',\n        instructions: 'Minimal instructions',\n      };\n\n      const content = generateSkillContent(template, '0.24.0');\n\n      expect(content).toContain('license: MIT');\n      expect(content).toContain('compatibility: Requires openspec CLI.');\n      expect(content).toContain('author: openspec');\n      expect(content).toContain('version: \"1.0\"');\n      expect(content).toContain('generatedBy: \"0.24.0\"');\n    });\n\n    it('should embed the provided version in generatedBy field', () => {\n      const template = {\n        name: 'version-test',\n        description: 'Test version embedding',\n        instructions: 'Instructions',\n      };\n\n      const content1 = generateSkillContent(template, '0.23.0');\n      expect(content1).toContain('generatedBy: \"0.23.0\"');\n\n      const content2 = generateSkillContent(template, '1.0.0');\n      expect(content2).toContain('generatedBy: \"1.0.0\"');\n\n      const content3 = generateSkillContent(template, '0.24.0-beta.1');\n      expect(content3).toContain('generatedBy: \"0.24.0-beta.1\"');\n    });\n\n    it('should end frontmatter with separator and blank line', () => {\n      const template = {\n        name: 'test',\n        description: 'Test',\n        instructions: 'Body content',\n      };\n\n      const content = generateSkillContent(template, '0.23.0');\n\n      expect(content).toMatch(/---\\n\\nBody content\\n$/);\n    });\n\n    it('should apply transformInstructions callback when provided', () => {\n      const template = {\n        name: 'transform-test',\n        description: 'Test transform callback',\n        instructions: 'Use /opsx:new to start and /opsx:apply to implement.',\n      };\n\n      const transformer = (text: string) => text.replace(/\\/opsx:/g, '/opsx-');\n      const content = generateSkillContent(template, '0.23.0', transformer);\n\n      expect(content).toContain('/opsx-new');\n      expect(content).toContain('/opsx-apply');\n      expect(content).not.toContain('/opsx:new');\n      expect(content).not.toContain('/opsx:apply');\n    });\n\n    it('should not transform instructions when callback is undefined', () => {\n      const template = {\n        name: 'no-transform-test',\n        description: 'Test without transform',\n        instructions: 'Use /opsx:new to start.',\n      };\n\n      const content = generateSkillContent(template, '0.23.0', undefined);\n\n      expect(content).toContain('/opsx:new');\n    });\n\n    it('should support custom transformInstructions logic', () => {\n      const template = {\n        name: 'custom-transform',\n        description: 'Test custom transform',\n        instructions: 'Some PLACEHOLDER text here.',\n      };\n\n      const customTransformer = (text: string) => text.replace('PLACEHOLDER', 'REPLACED');\n      const content = generateSkillContent(template, '0.23.0', customTransformer);\n\n      expect(content).toContain('Some REPLACED text here.');\n      expect(content).not.toContain('PLACEHOLDER');\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/shared/tool-detection.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\nimport {\n  SKILL_NAMES,\n  getToolsWithSkillsDir,\n  getToolSkillStatus,\n  getToolStates,\n  extractGeneratedByVersion,\n  getToolVersionStatus,\n  getConfiguredTools,\n  getAllToolVersionStatus,\n} from '../../../src/core/shared/tool-detection.js';\n\ndescribe('tool-detection', () => {\n  let testDir: string;\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);\n    await fs.mkdir(testDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('SKILL_NAMES', () => {\n    it('should contain all skill names matching COMMAND_IDS', () => {\n      expect(SKILL_NAMES).toHaveLength(11);\n      expect(SKILL_NAMES).toContain('openspec-explore');\n      expect(SKILL_NAMES).toContain('openspec-new-change');\n      expect(SKILL_NAMES).toContain('openspec-continue-change');\n      expect(SKILL_NAMES).toContain('openspec-apply-change');\n      expect(SKILL_NAMES).toContain('openspec-ff-change');\n      expect(SKILL_NAMES).toContain('openspec-sync-specs');\n      expect(SKILL_NAMES).toContain('openspec-archive-change');\n      expect(SKILL_NAMES).toContain('openspec-bulk-archive-change');\n      expect(SKILL_NAMES).toContain('openspec-verify-change');\n      expect(SKILL_NAMES).toContain('openspec-onboard');\n      expect(SKILL_NAMES).toContain('openspec-propose');\n    });\n  });\n\n  describe('getToolsWithSkillsDir', () => {\n    it('should return tools that have skillsDir configured', () => {\n      const tools = getToolsWithSkillsDir();\n      expect(tools).toContain('claude');\n      expect(tools).toContain('cursor');\n      expect(tools).toContain('windsurf');\n      expect(tools.length).toBeGreaterThan(0);\n    });\n  });\n\n  describe('getToolSkillStatus', () => {\n    it('should return not configured for unknown tool', () => {\n      const status = getToolSkillStatus(testDir, 'unknown-tool');\n      expect(status.configured).toBe(false);\n      expect(status.fullyConfigured).toBe(false);\n      expect(status.skillCount).toBe(0);\n    });\n\n    it('should return not configured when no skills exist', () => {\n      const status = getToolSkillStatus(testDir, 'claude');\n      expect(status.configured).toBe(false);\n      expect(status.fullyConfigured).toBe(false);\n      expect(status.skillCount).toBe(0);\n    });\n\n    it('should detect when one skill exists', async () => {\n      const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');\n      await fs.mkdir(skillDir, { recursive: true });\n      await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'test content');\n\n      const status = getToolSkillStatus(testDir, 'claude');\n      expect(status.configured).toBe(true);\n      expect(status.fullyConfigured).toBe(false);\n      expect(status.skillCount).toBe(1);\n    });\n\n    it('should detect when all skills exist', async () => {\n      for (const skillName of SKILL_NAMES) {\n        const skillDir = path.join(testDir, '.claude', 'skills', skillName);\n        await fs.mkdir(skillDir, { recursive: true });\n        await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'test content');\n      }\n\n      const status = getToolSkillStatus(testDir, 'claude');\n      expect(status.configured).toBe(true);\n      expect(status.fullyConfigured).toBe(true);\n      expect(status.skillCount).toBe(SKILL_NAMES.length);\n    });\n  });\n\n  describe('getToolStates', () => {\n    it('should return status for all tools with skillsDir', () => {\n      const states = getToolStates(testDir);\n      expect(states.has('claude')).toBe(true);\n      expect(states.has('cursor')).toBe(true);\n\n      const claudeStatus = states.get('claude');\n      expect(claudeStatus?.configured).toBe(false);\n    });\n\n    it('should detect configured tools', async () => {\n      const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');\n      await fs.mkdir(skillDir, { recursive: true });\n      await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'test content');\n\n      const states = getToolStates(testDir);\n      expect(states.get('claude')?.configured).toBe(true);\n      expect(states.get('cursor')?.configured).toBe(false);\n    });\n  });\n\n  describe('extractGeneratedByVersion', () => {\n    it('should return null for non-existent file', () => {\n      const version = extractGeneratedByVersion(path.join(testDir, 'missing.md'));\n      expect(version).toBeNull();\n    });\n\n    it('should return null when generatedBy is not present', async () => {\n      const filePath = path.join(testDir, 'skill.md');\n      await fs.writeFile(filePath, `---\nname: openspec-explore\nmetadata:\n  author: openspec\n  version: \"1.0\"\n---\n\nContent here\n`);\n\n      const version = extractGeneratedByVersion(filePath);\n      expect(version).toBeNull();\n    });\n\n    it('should extract generatedBy version with double quotes', async () => {\n      const filePath = path.join(testDir, 'skill.md');\n      await fs.writeFile(filePath, `---\nname: openspec-explore\nmetadata:\n  author: openspec\n  version: \"1.0\"\n  generatedBy: \"0.23.0\"\n---\n\nContent here\n`);\n\n      const version = extractGeneratedByVersion(filePath);\n      expect(version).toBe('0.23.0');\n    });\n\n    it('should extract generatedBy version with single quotes', async () => {\n      const filePath = path.join(testDir, 'skill.md');\n      await fs.writeFile(filePath, `---\nname: openspec-explore\nmetadata:\n  generatedBy: '0.24.0'\n---\n\nContent here\n`);\n\n      const version = extractGeneratedByVersion(filePath);\n      expect(version).toBe('0.24.0');\n    });\n\n    it('should extract generatedBy version without quotes', async () => {\n      const filePath = path.join(testDir, 'skill.md');\n      await fs.writeFile(filePath, `---\nname: openspec-explore\nmetadata:\n  generatedBy: 0.25.0\n---\n\nContent here\n`);\n\n      const version = extractGeneratedByVersion(filePath);\n      expect(version).toBe('0.25.0');\n    });\n  });\n\n  describe('getToolVersionStatus', () => {\n    it('should return not configured for unknown tool', () => {\n      const status = getToolVersionStatus(testDir, 'unknown-tool', '0.23.0');\n      expect(status.configured).toBe(false);\n      expect(status.generatedByVersion).toBeNull();\n      expect(status.needsUpdate).toBe(false);\n    });\n\n    it('should return not configured when no skills exist', () => {\n      const status = getToolVersionStatus(testDir, 'claude', '0.23.0');\n      expect(status.configured).toBe(false);\n      expect(status.generatedByVersion).toBeNull();\n      expect(status.needsUpdate).toBe(false);\n    });\n\n    it('should detect needsUpdate when generatedBy is missing', async () => {\n      const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');\n      await fs.mkdir(skillDir, { recursive: true });\n      await fs.writeFile(path.join(skillDir, 'SKILL.md'), `---\nname: openspec-explore\nmetadata:\n  author: openspec\n  version: \"1.0\"\n---\n\nContent here\n`);\n\n      const status = getToolVersionStatus(testDir, 'claude', '0.23.0');\n      expect(status.configured).toBe(true);\n      expect(status.generatedByVersion).toBeNull();\n      expect(status.needsUpdate).toBe(true);\n    });\n\n    it('should detect needsUpdate when version differs', async () => {\n      const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');\n      await fs.mkdir(skillDir, { recursive: true });\n      await fs.writeFile(path.join(skillDir, 'SKILL.md'), `---\nname: openspec-explore\nmetadata:\n  author: openspec\n  version: \"1.0\"\n  generatedBy: \"0.22.0\"\n---\n\nContent here\n`);\n\n      const status = getToolVersionStatus(testDir, 'claude', '0.23.0');\n      expect(status.configured).toBe(true);\n      expect(status.generatedByVersion).toBe('0.22.0');\n      expect(status.needsUpdate).toBe(true);\n    });\n\n    it('should not need update when version matches', async () => {\n      const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');\n      await fs.mkdir(skillDir, { recursive: true });\n      await fs.writeFile(path.join(skillDir, 'SKILL.md'), `---\nname: openspec-explore\nmetadata:\n  author: openspec\n  version: \"1.0\"\n  generatedBy: \"0.23.0\"\n---\n\nContent here\n`);\n\n      const status = getToolVersionStatus(testDir, 'claude', '0.23.0');\n      expect(status.configured).toBe(true);\n      expect(status.generatedByVersion).toBe('0.23.0');\n      expect(status.needsUpdate).toBe(false);\n    });\n\n    it('should include tool name in status', async () => {\n      const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');\n      await fs.mkdir(skillDir, { recursive: true });\n      await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'content');\n\n      const status = getToolVersionStatus(testDir, 'claude', '0.23.0');\n      expect(status.toolId).toBe('claude');\n      expect(status.toolName).toBe('Claude Code');\n    });\n  });\n\n  describe('getConfiguredTools', () => {\n    it('should return empty array when no tools are configured', () => {\n      const tools = getConfiguredTools(testDir);\n      expect(tools).toEqual([]);\n    });\n\n    it('should return configured tools', async () => {\n      // Setup Claude\n      const claudeSkillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');\n      await fs.mkdir(claudeSkillDir, { recursive: true });\n      await fs.writeFile(path.join(claudeSkillDir, 'SKILL.md'), 'content');\n\n      // Setup Cursor\n      const cursorSkillDir = path.join(testDir, '.cursor', 'skills', 'openspec-explore');\n      await fs.mkdir(cursorSkillDir, { recursive: true });\n      await fs.writeFile(path.join(cursorSkillDir, 'SKILL.md'), 'content');\n\n      const tools = getConfiguredTools(testDir);\n      expect(tools).toContain('claude');\n      expect(tools).toContain('cursor');\n      expect(tools).toHaveLength(2);\n    });\n  });\n\n  describe('getAllToolVersionStatus', () => {\n    it('should return empty array when no tools are configured', () => {\n      const statuses = getAllToolVersionStatus(testDir, '0.23.0');\n      expect(statuses).toEqual([]);\n    });\n\n    it('should return version status for all configured tools', async () => {\n      // Setup Claude with old version\n      const claudeSkillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');\n      await fs.mkdir(claudeSkillDir, { recursive: true });\n      await fs.writeFile(path.join(claudeSkillDir, 'SKILL.md'), `---\nmetadata:\n  generatedBy: \"0.22.0\"\n---\n`);\n\n      // Setup Cursor with current version\n      const cursorSkillDir = path.join(testDir, '.cursor', 'skills', 'openspec-explore');\n      await fs.mkdir(cursorSkillDir, { recursive: true });\n      await fs.writeFile(path.join(cursorSkillDir, 'SKILL.md'), `---\nmetadata:\n  generatedBy: \"0.23.0\"\n---\n`);\n\n      const statuses = getAllToolVersionStatus(testDir, '0.23.0');\n      expect(statuses).toHaveLength(2);\n\n      const claudeStatus = statuses.find(s => s.toolId === 'claude');\n      expect(claudeStatus?.generatedByVersion).toBe('0.22.0');\n      expect(claudeStatus?.needsUpdate).toBe(true);\n\n      const cursorStatus = statuses.find(s => s.toolId === 'cursor');\n      expect(cursorStatus?.generatedByVersion).toBe('0.23.0');\n      expect(cursorStatus?.needsUpdate).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/templates/skill-templates-parity.test.ts",
    "content": "import { createHash } from 'node:crypto';\nimport { describe, expect, it } from 'vitest';\n\nimport {\n  type SkillTemplate,\n  getApplyChangeSkillTemplate,\n  getArchiveChangeSkillTemplate,\n  getBulkArchiveChangeSkillTemplate,\n  getContinueChangeSkillTemplate,\n  getExploreSkillTemplate,\n  getFeedbackSkillTemplate,\n  getFfChangeSkillTemplate,\n  getNewChangeSkillTemplate,\n  getOnboardSkillTemplate,\n  getOpsxApplyCommandTemplate,\n  getOpsxArchiveCommandTemplate,\n  getOpsxBulkArchiveCommandTemplate,\n  getOpsxContinueCommandTemplate,\n  getOpsxExploreCommandTemplate,\n  getOpsxFfCommandTemplate,\n  getOpsxNewCommandTemplate,\n  getOpsxOnboardCommandTemplate,\n  getOpsxSyncCommandTemplate,\n  getOpsxProposeCommandTemplate,\n  getOpsxProposeSkillTemplate,\n  getOpsxVerifyCommandTemplate,\n  getSyncSpecsSkillTemplate,\n  getVerifyChangeSkillTemplate,\n} from '../../../src/core/templates/skill-templates.js';\nimport { generateSkillContent } from '../../../src/core/shared/skill-generation.js';\n\nconst EXPECTED_FUNCTION_HASHES: Record<string, string> = {\n  getExploreSkillTemplate: '55a2a1afcba0af88c638e77e4e3870f65ed82c030b4a2056d39812ae13a616be',\n  getNewChangeSkillTemplate: '5989672758eccf54e3bb554ab97f2c129a192b12bbb7688cc1ffcf6bccb1ae9d',\n  getContinueChangeSkillTemplate: 'f2e413f0333dfd6641cc2bd1a189273fdea5c399eecdde98ef528b5216f097b3',\n  getApplyChangeSkillTemplate: '26e52e67693e93fbcdd40dcd3e20949c07ce019183d55a8149d0260c791cd7f4',\n  getFfChangeSkillTemplate: 'a7332fb14c8dc3f9dec71f5d332790b4a8488191e7db4ab6132ccbefecf9ded9',\n  getSyncSpecsSkillTemplate: 'bded184e4c345619148de2c0ad80a5b527d4ffe45c87cc785889b9329e0f465b',\n  getOnboardSkillTemplate: '819a2d117ad1386187975686839cb0584b41484013d0ca6a6691f7a439a11a4a',\n  getOpsxExploreCommandTemplate: '91353d9e8633a3a9ce7339e796f1283478fca279153f3807c92f4f8ece246b19',\n  getOpsxNewCommandTemplate: '62eee32d6d81a376e7be845d0891e28e6262ad07482f9bfe6af12a9f0366c364',\n  getOpsxContinueCommandTemplate: '8bbaedcc95287f9e822572608137df4f49ad54cedfb08d3342d0d1c4e9716caa',\n  getOpsxApplyCommandTemplate: 'a9d631a07fcd832b67d263ff3800b98604ab8d378baf1b0d545907ef3affa3b5',\n  getOpsxFfCommandTemplate: 'cdebe872cc8e0fcc25c8864b98ffd66a93484c0657db94bd1285b8113092702a',\n  getArchiveChangeSkillTemplate: '6f8ca383fdb5a4eb9872aca81e07bf0ba7f25e4de8617d7a047ca914ca7f14b9',\n  getBulkArchiveChangeSkillTemplate: 'b40fc44ea4e420bdc9c803985b10e5c091fc472cdfc69153b962be6be303bddd',\n  getOpsxSyncCommandTemplate: '378d035fe7cc30be3e027b66dcc4b8afc78ef1c8369c39479c9b05a582fb5ccf',\n  getVerifyChangeSkillTemplate: '63a213ba3b42af54a1cd56f5072234a03b265c3fe4a1da12cd6fbbef5ee46c4b',\n  getOpsxArchiveCommandTemplate: 'b44cc9748109f61687f9f596604b037bc3ea803abc143b22f09a76aebd98b493',\n  getOpsxOnboardCommandTemplate: '10052d05a4e2cdade7fdfa549b3444f7a92f55a39bf81ddd6af7e0e9e83a7302',\n  getOpsxBulkArchiveCommandTemplate: 'eaaba253a950b9e681d8427a5cbc6b50c4e91137fb37fd2360859e08f63a0c14',\n  getOpsxVerifyCommandTemplate: '9b4d3ca422553b7534764eb3a009da87a051612c5238e9baab294c7b1233e9a2',\n  getOpsxProposeSkillTemplate: 'd67f937d44650e9c61d2158c865309fbab23cb3f50a3d4868a640a97776e3999',\n  getOpsxProposeCommandTemplate: '41ad59b37eafd7a161bab5c6e41997a37368f9c90b194451295ede5cd42e4d46',\n  getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d',\n};\n\nconst EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {\n  'openspec-explore': '90463d00761417dfbca5cb09361adcf8bbdbbb24000b86dd03647869a4104479',\n  'openspec-new-change': 'c324a7ace1f244aa3f534ac8e3370a2c11190d6d1b85a315f26a211398310f0f',\n  'openspec-continue-change': '463cf0b980ec9c3c24774414ef2a3e48e9faa8577bc8748990f45ab3d5efe960',\n  'openspec-apply-change': 'a0084442b59be9d7e22a0382a279d470501e1ecf74bdd5347e169951c9be191c',\n  'openspec-ff-change': '672c3a5b8df152d959b15bd7ae2be7a75ab7b8eaa2ec1e0daa15c02479b27937',\n  'openspec-sync-specs': 'b8859cf454379a19ca35dbf59eedca67306607f44a355327f9dc851114e50bde',\n  'openspec-archive-change': 'f83c85452bd47de0dee6b8efbcea6a62534f8a175480e9044f3043f887cebf0f',\n  'openspec-bulk-archive-change': 'a235a539f7729ab7669e45256905808789240ecd02820e044f4d0eef67b0c2ab',\n  'openspec-verify-change': '30d07c6f7051965f624f5964db51844ec17c7dfd05f0da95281fe0ca73616326',\n  'openspec-onboard': 'dbce376cf895f3fe4f63b4bce66d258c35b7b8884ac746670e5e35fabcefd255',\n  'openspec-propose': '20e36dabefb90e232bad0667292bd5007ec280f8fc4fc995dbc4282bf45a22e7',\n};\n\nfunction stableStringify(value: unknown): string {\n  if (Array.isArray(value)) {\n    return `[${value.map(stableStringify).join(',')}]`;\n  }\n\n  if (value && typeof value === 'object') {\n    const entries = Object.entries(value as Record<string, unknown>)\n      .sort(([left], [right]) => left.localeCompare(right))\n      .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`);\n\n    return `{${entries.join(',')}}`;\n  }\n\n  return JSON.stringify(value);\n}\n\nfunction hash(value: string): string {\n  return createHash('sha256').update(value).digest('hex');\n}\n\ndescribe('skill templates split parity', () => {\n  it('preserves all template function payloads exactly', () => {\n    const functionFactories: Record<string, () => unknown> = {\n      getExploreSkillTemplate,\n      getNewChangeSkillTemplate,\n      getContinueChangeSkillTemplate,\n      getApplyChangeSkillTemplate,\n      getFfChangeSkillTemplate,\n      getSyncSpecsSkillTemplate,\n      getOnboardSkillTemplate,\n      getOpsxExploreCommandTemplate,\n      getOpsxNewCommandTemplate,\n      getOpsxContinueCommandTemplate,\n      getOpsxApplyCommandTemplate,\n      getOpsxFfCommandTemplate,\n      getArchiveChangeSkillTemplate,\n      getBulkArchiveChangeSkillTemplate,\n      getOpsxSyncCommandTemplate,\n      getVerifyChangeSkillTemplate,\n      getOpsxArchiveCommandTemplate,\n      getOpsxOnboardCommandTemplate,\n      getOpsxBulkArchiveCommandTemplate,\n      getOpsxVerifyCommandTemplate,\n      getOpsxProposeSkillTemplate,\n      getOpsxProposeCommandTemplate,\n      getFeedbackSkillTemplate,\n    };\n\n    const actualHashes = Object.fromEntries(\n      Object.entries(functionFactories).map(([name, fn]) => [name, hash(stableStringify(fn()))])\n    );\n\n    expect(actualHashes).toEqual(EXPECTED_FUNCTION_HASHES);\n  });\n\n  it('preserves generated skill file content exactly', () => {\n    // Intentionally excludes getFeedbackSkillTemplate: skillFactories only models templates\n    // deployed via generateSkillContent, while feedback is covered in function payload parity.\n    const skillFactories: Array<[string, () => SkillTemplate]> = [\n      ['openspec-explore', getExploreSkillTemplate],\n      ['openspec-new-change', getNewChangeSkillTemplate],\n      ['openspec-continue-change', getContinueChangeSkillTemplate],\n      ['openspec-apply-change', getApplyChangeSkillTemplate],\n      ['openspec-ff-change', getFfChangeSkillTemplate],\n      ['openspec-sync-specs', getSyncSpecsSkillTemplate],\n      ['openspec-archive-change', getArchiveChangeSkillTemplate],\n      ['openspec-bulk-archive-change', getBulkArchiveChangeSkillTemplate],\n      ['openspec-verify-change', getVerifyChangeSkillTemplate],\n      ['openspec-onboard', getOnboardSkillTemplate],\n      ['openspec-propose', getOpsxProposeSkillTemplate],\n    ];\n\n    const actualHashes = Object.fromEntries(\n      skillFactories.map(([dirName, createTemplate]) => [\n        dirName,\n        hash(generateSkillContent(createTemplate(), 'PARITY-BASELINE')),\n      ])\n    );\n\n    expect(actualHashes).toEqual(EXPECTED_GENERATED_SKILL_CONTENT_HASHES);\n  });\n});\n"
  },
  {
    "path": "test/core/update.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { UpdateCommand, scanInstalledWorkflows } from '../../src/core/update.js';\nimport { InitCommand } from '../../src/core/init.js';\nimport { FileSystemUtils } from '../../src/utils/file-system.js';\nimport { OPENSPEC_MARKERS } from '../../src/core/config.js';\nimport type { GlobalConfig } from '../../src/core/global-config.js';\nimport path from 'path';\nimport fs from 'fs/promises';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\n\n// Shared mutable mock config state\nconst mockState = {\n  config: {\n    featureFlags: {},\n    profile: 'core' as const,\n    delivery: 'both' as const,\n  } as GlobalConfig,\n};\n\n// Mock global config module to isolate tests from the machine's actual config\nvi.mock('../../src/core/global-config.js', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('../../src/core/global-config.js')>();\n\n  return {\n    ...actual,\n    getGlobalConfig: () => ({ ...mockState.config }),\n    saveGlobalConfig: vi.fn(),\n  };\n});\n\n// Helper to set mock config for tests\nfunction setMockConfig(config: GlobalConfig) {\n  mockState.config = config;\n}\n\nfunction resetMockConfig() {\n  mockState.config = { featureFlags: {}, profile: 'core', delivery: 'both' };\n}\n\ndescribe('UpdateCommand', () => {\n  let testDir: string;\n  let updateCommand: UpdateCommand;\n\n  beforeEach(async () => {\n    // Create a temporary test directory\n    testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);\n    await fs.mkdir(testDir, { recursive: true });\n\n    // Create openspec directory\n    const openspecDir = path.join(testDir, 'openspec');\n    await fs.mkdir(openspecDir, { recursive: true });\n\n    updateCommand = new UpdateCommand();\n\n    // Reset mock config to defaults\n    resetMockConfig();\n\n    // Clear all mocks before each test\n    vi.restoreAllMocks();\n  });\n\n  afterEach(async () => {\n    // Restore all mocks after each test\n    vi.restoreAllMocks();\n\n    // Clean up test directory\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('basic validation', () => {\n    it('should throw error if openspec directory does not exist', async () => {\n      // Remove openspec directory\n      await fs.rm(path.join(testDir, 'openspec'), {\n        recursive: true,\n        force: true,\n      });\n\n      await expect(updateCommand.execute(testDir)).rejects.toThrow(\n        \"No OpenSpec directory found. Run 'openspec init' first.\"\n      );\n    });\n\n    it('should report no configured tools when none exist', async () => {\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('No configured tools found')\n      );\n\n      consoleSpy.mockRestore();\n    });\n  });\n\n  describe('skill updates', () => {\n    it('should update skill files for configured Claude tool', async () => {\n      // Set up a configured Claude tool by creating skill directories\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      const exploreSkillDir = path.join(skillsDir, 'openspec-explore');\n      await fs.mkdir(exploreSkillDir, { recursive: true });\n\n      // Create an existing skill file\n      const oldSkillContent = `---\nname: openspec-explore (old)\ndescription: Old description\nlicense: MIT\ncompatibility: Requires openspec CLI.\nmetadata:\n  author: openspec\n  version: \"0.9\"\n---\n\nOld instructions content\n`;\n      await fs.writeFile(\n        path.join(exploreSkillDir, 'SKILL.md'),\n        oldSkillContent\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Check skill file was updated\n      const updatedSkill = await fs.readFile(\n        path.join(exploreSkillDir, 'SKILL.md'),\n        'utf-8'\n      );\n      expect(updatedSkill).toContain('name: openspec-explore');\n      expect(updatedSkill).not.toContain('Old instructions content');\n      expect(updatedSkill).toContain('license: MIT');\n\n      // Check console output\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Updating 1 tool(s): claude')\n      );\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should update core profile skill files when tool is configured', async () => {\n      // Set up a configured tool with one skill directory\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n\n      // Create at least one skill to mark tool as configured\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old content'\n      );\n\n      await updateCommand.execute(testDir);\n\n      // Verify core profile skill files were created/updated (propose, explore, apply, archive)\n      const coreSkillNames = [\n        'openspec-explore',\n        'openspec-apply-change',\n        'openspec-archive-change',\n        'openspec-propose',\n      ];\n\n      for (const skillName of coreSkillNames) {\n        const skillFile = path.join(skillsDir, skillName, 'SKILL.md');\n        const exists = await FileSystemUtils.fileExists(skillFile);\n        expect(exists).toBe(true);\n\n        const content = await fs.readFile(skillFile, 'utf-8');\n        expect(content).toContain('---');\n        expect(content).toContain('name:');\n        expect(content).toContain('description:');\n      }\n\n      // Verify non-core skills are NOT created\n      const nonCoreSkillNames = [\n        'openspec-new-change',\n        'openspec-continue-change',\n        'openspec-ff-change',\n        'openspec-sync-specs',\n        'openspec-bulk-archive-change',\n        'openspec-verify-change',\n      ];\n\n      for (const skillName of nonCoreSkillNames) {\n        const skillFile = path.join(skillsDir, skillName, 'SKILL.md');\n        const exists = await FileSystemUtils.fileExists(skillFile);\n        expect(exists).toBe(false);\n      }\n    });\n  });\n\n  describe('command updates', () => {\n    it('should update opsx commands for configured Claude tool', async () => {\n      // Set up a configured Claude tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old content'\n      );\n\n      await updateCommand.execute(testDir);\n\n      // Check opsx command files were created\n      const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');\n      const exploreCmd = path.join(commandsDir, 'explore.md');\n      const exists = await FileSystemUtils.fileExists(exploreCmd);\n      expect(exists).toBe(true);\n\n      const content = await fs.readFile(exploreCmd, 'utf-8');\n      expect(content).toContain('---');\n      expect(content).toContain('name:');\n      expect(content).toContain('description:');\n      expect(content).toContain('category:');\n      expect(content).toContain('tags:');\n    });\n\n    it('should update core profile opsx commands when tool is configured', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old content'\n      );\n\n      await updateCommand.execute(testDir);\n\n      // Verify core profile commands were created (propose, explore, apply, archive)\n      const coreCommandIds = ['explore', 'apply', 'archive', 'propose'];\n      const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');\n      for (const cmdId of coreCommandIds) {\n        const cmdFile = path.join(commandsDir, `${cmdId}.md`);\n        const exists = await FileSystemUtils.fileExists(cmdFile);\n        expect(exists).toBe(true);\n      }\n\n      // Verify non-core commands are NOT created\n      const nonCoreCommandIds = ['new', 'continue', 'ff', 'sync', 'bulk-archive', 'verify'];\n      for (const cmdId of nonCoreCommandIds) {\n        const cmdFile = path.join(commandsDir, `${cmdId}.md`);\n        const exists = await FileSystemUtils.fileExists(cmdFile);\n        expect(exists).toBe(false);\n      }\n    });\n  });\n\n  describe('multi-tool support', () => {\n    it('should update multiple configured tools', async () => {\n      // Set up Claude\n      const claudeSkillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(claudeSkillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      // Set up Cursor\n      const cursorSkillsDir = path.join(testDir, '.cursor', 'skills');\n      await fs.mkdir(path.join(cursorSkillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(cursorSkillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Both tools should be updated\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Updating 2 tool(s)')\n      );\n\n      // Verify Claude skills updated\n      const claudeSkill = await fs.readFile(\n        path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'),\n        'utf-8'\n      );\n      expect(claudeSkill).toContain('name: openspec-explore');\n\n      // Verify Cursor skills updated\n      const cursorSkill = await fs.readFile(\n        path.join(cursorSkillsDir, 'openspec-explore', 'SKILL.md'),\n        'utf-8'\n      );\n      expect(cursorSkill).toContain('name: openspec-explore');\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should update Qwen tool with correct command format', async () => {\n      // Set up Qwen\n      const qwenSkillsDir = path.join(testDir, '.qwen', 'skills');\n      await fs.mkdir(path.join(qwenSkillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(qwenSkillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      await updateCommand.execute(testDir);\n\n      // Check Qwen command format (TOML) - Qwen uses flat path structure: opsx-<id>.toml\n      const qwenCmd = path.join(\n        testDir,\n        '.qwen',\n        'commands',\n        'opsx-explore.toml'\n      );\n      const exists = await FileSystemUtils.fileExists(qwenCmd);\n      expect(exists).toBe(true);\n\n      const content = await fs.readFile(qwenCmd, 'utf-8');\n      expect(content).toContain('description =');\n      expect(content).toContain('prompt =');\n    });\n\n    it('should update Windsurf tool with correct command format', async () => {\n      // Set up Windsurf\n      const windsurfSkillsDir = path.join(testDir, '.windsurf', 'skills');\n      await fs.mkdir(path.join(windsurfSkillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(windsurfSkillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      await updateCommand.execute(testDir);\n\n      // Check Windsurf command format\n      const windsurfCmd = path.join(\n        testDir,\n        '.windsurf',\n        'workflows',\n        'opsx-explore.md'\n      );\n      const exists = await FileSystemUtils.fileExists(windsurfCmd);\n      expect(exists).toBe(true);\n\n      const content = await fs.readFile(windsurfCmd, 'utf-8');\n      expect(content).toContain('---');\n      expect(content).toContain('name:');\n    });\n  });\n\n  describe('error handling', () => {\n    it('should handle tool update failures gracefully', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      // Mock writeFile to fail for skills\n      const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils);\n      const writeSpy = vi\n        .spyOn(FileSystemUtils, 'writeFile')\n        .mockImplementation(async (filePath, content) => {\n          if (filePath.includes('SKILL.md')) {\n            throw new Error('EACCES: permission denied');\n          }\n          return originalWriteFile(filePath, content);\n        });\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      // Should not throw\n      await updateCommand.execute(testDir);\n\n      // Should report failure\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Failed')\n      );\n\n      writeSpy.mockRestore();\n      consoleSpy.mockRestore();\n    });\n\n    it('should continue updating other tools when one fails', async () => {\n      // Set up Claude and Cursor\n      const claudeSkillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(claudeSkillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      const cursorSkillsDir = path.join(testDir, '.cursor', 'skills');\n      await fs.mkdir(path.join(cursorSkillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(cursorSkillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      // Mock writeFile to fail only for Claude\n      const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils);\n      const writeSpy = vi\n        .spyOn(FileSystemUtils, 'writeFile')\n        .mockImplementation(async (filePath, content) => {\n          if (filePath.includes('.claude') && filePath.includes('SKILL.md')) {\n            throw new Error('EACCES: permission denied');\n          }\n          return originalWriteFile(filePath, content);\n        });\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Cursor should still be updated - check the actual format from ora spinner\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Updated: Cursor')\n      );\n\n      // Claude should be reported as failed\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Failed')\n      );\n\n      writeSpy.mockRestore();\n      consoleSpy.mockRestore();\n    });\n  });\n\n  describe('tool detection', () => {\n    it('should detect tool as configured only when skill file exists', async () => {\n      // Create skills directory but no skill files\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(skillsDir, { recursive: true });\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Should report no configured tools\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('No configured tools found')\n      );\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should detect tool when any single skill exists', async () => {\n      // Create only one skill file\n      const skillDir = path.join(\n        testDir,\n        '.claude',\n        'skills',\n        'openspec-archive-change'\n      );\n      await fs.mkdir(skillDir, { recursive: true });\n      await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'old');\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Should detect and update Claude\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Updating 1 tool(s): claude')\n      );\n\n      consoleSpy.mockRestore();\n    });\n  });\n\n  describe('skill content validation', () => {\n    it('should generate valid YAML frontmatter in skill files', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      await updateCommand.execute(testDir);\n\n      const skillContent = await fs.readFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'utf-8'\n      );\n\n      // Validate frontmatter structure\n      expect(skillContent).toMatch(/^---\\n/);\n      expect(skillContent).toContain('name:');\n      expect(skillContent).toContain('description:');\n      expect(skillContent).toContain('license:');\n      expect(skillContent).toContain('compatibility:');\n      expect(skillContent).toContain('metadata:');\n      expect(skillContent).toContain('author:');\n      expect(skillContent).toContain('version:');\n      expect(skillContent).toMatch(/---\\n\\n/);\n    });\n\n    it('should include proper instructions in skill files', async () => {\n      // Set up a configured tool with apply-change skill (which is in core profile)\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-apply-change'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-apply-change', 'SKILL.md'),\n        'old'\n      );\n\n      await updateCommand.execute(testDir);\n\n      const skillContent = await fs.readFile(\n        path.join(skillsDir, 'openspec-apply-change', 'SKILL.md'),\n        'utf-8'\n      );\n\n      // Apply skill should contain implementation instructions\n      expect(skillContent.toLowerCase()).toContain('task');\n    });\n  });\n\n  describe('success output', () => {\n    it('should display success message with tool name', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // The success output uses \"✓ Updated: <name>\"\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Updated: Claude Code')\n      );\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should suggest IDE restart after update', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Restart your IDE')\n      );\n\n      consoleSpy.mockRestore();\n    });\n  });\n\n  describe('smart update detection', () => {\n    it('should show \"up to date\" message when skills have current version', async () => {\n      // Initialize full core profile output so there is no profile/delivery drift.\n      const initCommand = new InitCommand({ tools: 'claude', force: true });\n      await initCommand.execute(testDir);\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('up to date')\n      );\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('--force')\n      );\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should detect update needed when generatedBy is missing', async () => {\n      // Set up a configured tool without generatedBy\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        `---\nname: openspec-explore\nmetadata:\n  author: openspec\n  version: \"1.0\"\n---\n\nLegacy content without generatedBy\n`\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Should show \"unknown → version\" in the update message\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('unknown')\n      );\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should detect update needed when version differs', async () => {\n      // Set up a configured tool with old version\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        `---\nname: openspec-explore\nmetadata:\n  generatedBy: \"0.1.0\"\n---\n\nOld version content\n`\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Should show version transition\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('0.1.0')\n      );\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should embed generatedBy in updated skill files', async () => {\n      // Set up a configured tool without generatedBy\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old content without version'\n      );\n\n      await updateCommand.execute(testDir);\n\n      const updatedContent = await fs.readFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'utf-8'\n      );\n\n      // Should contain generatedBy field\n      expect(updatedContent).toMatch(/generatedBy:\\s*[\"']\\d+\\.\\d+\\.\\d+[\"']/);\n    });\n  });\n\n  describe('--force flag', () => {\n    it('should update when force is true even if up to date', async () => {\n      // Set up a configured tool with current version\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n\n      const { version } = await import('../../package.json');\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        `---\nmetadata:\n  generatedBy: \"${version}\"\n---\nContent\n`\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      // Create update command with force option\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Should show \"Force updating\" message\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Force updating')\n      );\n\n      // Should show updated message\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Updated: Claude Code')\n      );\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should not show --force hint when force is used', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old content'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Get all console.log calls as strings\n      const allCalls = consoleSpy.mock.calls.map(call =>\n        call.map(arg => String(arg)).join(' ')\n      );\n\n      // Should not show \"Use --force\" since force was used\n      const hasForceHint = allCalls.some(call => call.includes('Use --force'));\n      expect(hasForceHint).toBe(false);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should update all tools when force is used with mixed versions', async () => {\n      // Set up Claude with current version\n      const { version } = await import('../../package.json');\n      const claudeSkillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');\n      await fs.mkdir(claudeSkillDir, { recursive: true });\n      await fs.writeFile(\n        path.join(claudeSkillDir, 'SKILL.md'),\n        `---\nmetadata:\n  generatedBy: \"${version}\"\n---\n`\n      );\n\n      // Set up Cursor with old version\n      const cursorSkillDir = path.join(testDir, '.cursor', 'skills', 'openspec-explore');\n      await fs.mkdir(cursorSkillDir, { recursive: true });\n      await fs.writeFile(\n        path.join(cursorSkillDir, 'SKILL.md'),\n        `---\nmetadata:\n  generatedBy: \"0.1.0\"\n---\n`\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Should show both tools being force updated\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Force updating 2 tool(s)')\n      );\n\n      consoleSpy.mockRestore();\n    });\n  });\n\n  describe('version tracking', () => {\n    it('should show version in success message', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Should show version in success message\n      const { version } = await import('../../package.json');\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining(`(v${version})`)\n      );\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should only update tools that need updating', async () => {\n      // Initialize both tools so Cursor is fully synced with profile/delivery.\n      const initCommand = new InitCommand({ tools: 'claude,cursor', force: true });\n      await initCommand.execute(testDir);\n\n      // Make Claude stale to force a version update.\n      const claudeSkillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const claudeContent = await fs.readFile(claudeSkillFile, 'utf-8');\n      await fs.writeFile(\n        claudeSkillFile,\n        claudeContent.replace(/generatedBy:\\s*[\"'][^\"']+[\"']/, 'generatedBy: \"0.1.0\"')\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Should show only Claude being updated\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Updating 1 tool(s)')\n      );\n\n      // Should mention Cursor is already up to date\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Already up to date: cursor')\n      );\n\n      consoleSpy.mockRestore();\n    });\n  });\n\n  describe('legacy cleanup', () => {\n    it('should detect and auto-cleanup legacy files with --force flag', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      // Create legacy CLAUDE.md with OpenSpec markers\n      const legacyContent = `${OPENSPEC_MARKERS.start}\n# OpenSpec Instructions\n\nThese instructions are for AI assistants.\n${OPENSPEC_MARKERS.end}\n`;\n      await fs.writeFile(path.join(testDir, 'CLAUDE.md'), legacyContent);\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      // Create update command with force option\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Should show v1 upgrade message\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Upgrading to the new OpenSpec')\n      );\n\n      // Should show marker removal message (config files are never deleted, only have markers removed)\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Removed OpenSpec markers from CLAUDE.md')\n      );\n\n      // Config file should still exist (never deleted)\n      const legacyExists = await FileSystemUtils.fileExists(\n        path.join(testDir, 'CLAUDE.md')\n      );\n      expect(legacyExists).toBe(true);\n\n      // File should have markers removed\n      const content = await fs.readFile(path.join(testDir, 'CLAUDE.md'), 'utf-8');\n      expect(content).not.toContain(OPENSPEC_MARKERS.start);\n      expect(content).not.toContain(OPENSPEC_MARKERS.end);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should warn but continue with update when legacy files found in non-interactive mode', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      // Create legacy CLAUDE.md with OpenSpec markers\n      const legacyContent = `${OPENSPEC_MARKERS.start}\n# OpenSpec Instructions\n${OPENSPEC_MARKERS.end}\n`;\n      await fs.writeFile(path.join(testDir, 'CLAUDE.md'), legacyContent);\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      // Run without --force in non-interactive mode (CI environment)\n      await updateCommand.execute(testDir);\n\n      // Should show v1 upgrade message\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Upgrading to the new OpenSpec')\n      );\n\n      // Should show warning about --force\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Run with --force to auto-cleanup')\n      );\n\n      // Should continue with update\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Updated: Claude Code')\n      );\n\n      // Legacy file should still exist (not cleaned up)\n      const legacyExists = await FileSystemUtils.fileExists(\n        path.join(testDir, 'CLAUDE.md')\n      );\n      expect(legacyExists).toBe(true);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should cleanup legacy slash command directories with --force', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      // Create legacy slash command directory\n      const legacyCommandDir = path.join(testDir, '.claude', 'commands', 'openspec');\n      await fs.mkdir(legacyCommandDir, { recursive: true });\n      await fs.writeFile(\n        path.join(legacyCommandDir, 'old-command.md'),\n        'old command'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      // Create update command with force option\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Should show cleanup message for directory\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Removed .claude/commands/openspec/')\n      );\n\n      // Legacy directory should be deleted\n      const legacyDirExists = await FileSystemUtils.directoryExists(legacyCommandDir);\n      expect(legacyDirExists).toBe(false);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should cleanup legacy openspec/AGENTS.md with --force', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      // Create legacy openspec/AGENTS.md\n      await fs.writeFile(\n        path.join(testDir, 'openspec', 'AGENTS.md'),\n        '# Old AGENTS.md content'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      // Create update command with force option\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Should show cleanup message\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Removed openspec/AGENTS.md')\n      );\n\n      // Legacy file should be deleted\n      const legacyExists = await FileSystemUtils.fileExists(\n        path.join(testDir, 'openspec', 'AGENTS.md')\n      );\n      expect(legacyExists).toBe(false);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should not show legacy cleanup messages when no legacy files exist', async () => {\n      // Set up a configured tool with no legacy files\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Should not show v1 upgrade message (no legacy files)\n      const calls = consoleSpy.mock.calls.map(call =>\n        call.map(arg => String(arg)).join(' ')\n      );\n      const hasLegacyMessage = calls.some(call =>\n        call.includes('Upgrading to the new OpenSpec')\n      );\n      expect(hasLegacyMessage).toBe(false);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should remove OpenSpec marker block from mixed content files', async () => {\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {\n        recursive: true,\n      });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old'\n      );\n\n      // Create CLAUDE.md with mixed content (user content + OpenSpec markers)\n      const mixedContent = `# My Project\n\nSome user-defined instructions here.\n\n${OPENSPEC_MARKERS.start}\n# OpenSpec Instructions\n\nThese instructions are for AI assistants.\n${OPENSPEC_MARKERS.end}\n\nMore user content after markers.\n`;\n      await fs.writeFile(path.join(testDir, 'CLAUDE.md'), mixedContent);\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      // Create update command with force option\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Should show marker removal message\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Removed OpenSpec markers from CLAUDE.md')\n      );\n\n      // File should still exist\n      const fileExists = await FileSystemUtils.fileExists(\n        path.join(testDir, 'CLAUDE.md')\n      );\n      expect(fileExists).toBe(true);\n\n      // File should have markers removed but preserve user content\n      const updatedContent = await fs.readFile(\n        path.join(testDir, 'CLAUDE.md'),\n        'utf-8'\n      );\n      expect(updatedContent).toContain('# My Project');\n      expect(updatedContent).toContain('Some user-defined instructions here');\n      expect(updatedContent).toContain('More user content after markers');\n      expect(updatedContent).not.toContain(OPENSPEC_MARKERS.start);\n      expect(updatedContent).not.toContain(OPENSPEC_MARKERS.end);\n\n      consoleSpy.mockRestore();\n    });\n  });\n\n  describe('legacy tool upgrade', () => {\n    it('should upgrade legacy tools to new skills with --force', async () => {\n      // Create legacy slash command directory (no skills exist yet)\n      const legacyCommandDir = path.join(testDir, '.claude', 'commands', 'openspec');\n      await fs.mkdir(legacyCommandDir, { recursive: true });\n      await fs.writeFile(\n        path.join(legacyCommandDir, 'proposal.md'),\n        'old command content'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      // Create update command with force option\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Should show detected tools message\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Tools detected from legacy artifacts')\n      );\n\n      // Should show Claude Code being set up\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Claude Code')\n      );\n\n      // Should show getting started message for newly configured tools\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Getting started')\n      );\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('/opsx:new')\n      );\n\n      // Skills should be created\n      const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const skillExists = await FileSystemUtils.fileExists(skillFile);\n      expect(skillExists).toBe(true);\n\n      // Legacy directory should be deleted\n      const legacyDirExists = await FileSystemUtils.directoryExists(legacyCommandDir);\n      expect(legacyDirExists).toBe(false);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should upgrade multiple legacy tools with --force', async () => {\n      // Create legacy command directories for Claude and Cursor\n      await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });\n      await fs.writeFile(\n        path.join(testDir, '.claude', 'commands', 'openspec', 'proposal.md'),\n        'content'\n      );\n\n      await fs.mkdir(path.join(testDir, '.cursor', 'commands'), { recursive: true });\n      await fs.writeFile(\n        path.join(testDir, '.cursor', 'commands', 'openspec-proposal.md'),\n        'content'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      // Create update command with force option\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Should detect both tools\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Tools detected from legacy artifacts')\n      );\n\n      // Both tools should have skills created\n      const claudeSkillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');\n      const cursorSkillFile = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');\n\n      expect(await FileSystemUtils.fileExists(claudeSkillFile)).toBe(true);\n      expect(await FileSystemUtils.fileExists(cursorSkillFile)).toBe(true);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should not upgrade legacy tools already configured', async () => {\n      // Set up a configured Claude tool with skills\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'existing skill'\n      );\n\n      // Also create legacy directory (simulating partial upgrade)\n      const legacyCommandDir = path.join(testDir, '.claude', 'commands', 'openspec');\n      await fs.mkdir(legacyCommandDir, { recursive: true });\n      await fs.writeFile(\n        path.join(legacyCommandDir, 'proposal.md'),\n        'old command'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      // Create update command with force option\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Legacy cleanup should happen\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Removed .claude/commands/openspec/')\n      );\n\n      // Should NOT show \"Tools detected from legacy artifacts\" because claude is already configured\n      const calls = consoleSpy.mock.calls.map(call =>\n        call.map(arg => String(arg)).join(' ')\n      );\n      const hasDetectedMessage = calls.some(call =>\n        call.includes('Tools detected from legacy artifacts')\n      );\n      expect(hasDetectedMessage).toBe(false);\n\n      // Should update existing skills (not \"Getting started\" for newly configured)\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Updated: Claude Code')\n      );\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should upgrade only unconfigured legacy tools when mixed', async () => {\n      // Set up configured Claude tool with skills\n      const claudeSkillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(claudeSkillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(\n        path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'),\n        'existing skill'\n      );\n\n      // Create legacy commands for both Claude (configured) and Cursor (not configured)\n      await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });\n      await fs.writeFile(\n        path.join(testDir, '.claude', 'commands', 'openspec', 'proposal.md'),\n        'content'\n      );\n\n      await fs.mkdir(path.join(testDir, '.cursor', 'commands'), { recursive: true });\n      await fs.writeFile(\n        path.join(testDir, '.cursor', 'commands', 'openspec-proposal.md'),\n        'content'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      // Create update command with force option\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Should detect Cursor as a legacy tool to upgrade (but not Claude)\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Tools detected from legacy artifacts')\n      );\n\n      // Cursor skills should be created\n      const cursorSkillFile = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');\n      expect(await FileSystemUtils.fileExists(cursorSkillFile)).toBe(true);\n\n      // Should show \"Getting started\" for newly configured Cursor\n      expect(consoleSpy).toHaveBeenCalledWith(\n        expect.stringContaining('Getting started')\n      );\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should not show getting started message when no new tools configured', async () => {\n      // Set up a configured tool (no legacy artifacts)\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        'old skill'\n      );\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Should NOT show \"Getting started\" message\n      const calls = consoleSpy.mock.calls.map(call =>\n        call.map(arg => String(arg)).join(' ')\n      );\n      const hasGettingStarted = calls.some(call =>\n        call.includes('Getting started')\n      );\n      expect(hasGettingStarted).toBe(false);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should create only effective profile skills when upgrading legacy tools', async () => {\n      // Create legacy command directory\n      await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });\n      await fs.writeFile(\n        path.join(testDir, '.claude', 'commands', 'openspec', 'proposal.md'),\n        'content'\n      );\n\n      // Create update command with force option\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // Default profile is core, so only core workflows should be generated.\n      const skillNames = [\n        'openspec-propose',\n        'openspec-explore',\n        'openspec-apply-change',\n        'openspec-archive-change',\n      ];\n\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      for (const skillName of skillNames) {\n        const skillFile = path.join(skillsDir, skillName, 'SKILL.md');\n        const exists = await FileSystemUtils.fileExists(skillFile);\n        expect(exists).toBe(true);\n      }\n\n      const nonCoreSkill = path.join(skillsDir, 'openspec-new-change', 'SKILL.md');\n      expect(await FileSystemUtils.fileExists(nonCoreSkill)).toBe(false);\n    });\n\n    it('should create commands when upgrading legacy tools', async () => {\n      // Create legacy command directory\n      await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });\n      await fs.writeFile(\n        path.join(testDir, '.claude', 'commands', 'openspec', 'proposal.md'),\n        'content'\n      );\n\n      // Create update command with force option\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      // New opsx commands should be created\n      const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');\n      const exploreCmd = path.join(commandsDir, 'explore.md');\n      const exists = await FileSystemUtils.fileExists(exploreCmd);\n      expect(exists).toBe(true);\n    });\n\n    it('should not inject non-profile workflows when upgrading legacy tools', async () => {\n      setMockConfig({\n        featureFlags: {},\n        profile: 'custom',\n        delivery: 'both',\n        workflows: ['explore'],\n      });\n\n      await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });\n      await fs.writeFile(\n        path.join(testDir, '.claude', 'commands', 'openspec', 'proposal.md'),\n        'content'\n      );\n\n      const forceUpdateCommand = new UpdateCommand({ force: true });\n      await forceUpdateCommand.execute(testDir);\n\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      expect(await FileSystemUtils.fileExists(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md')\n      )).toBe(true);\n      expect(await FileSystemUtils.fileExists(\n        path.join(skillsDir, 'openspec-propose', 'SKILL.md')\n      )).toBe(false);\n\n      const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');\n      expect(await FileSystemUtils.fileExists(\n        path.join(commandsDir, 'explore.md')\n      )).toBe(true);\n      expect(await FileSystemUtils.fileExists(\n        path.join(commandsDir, 'propose.md')\n      )).toBe(false);\n    });\n  });\n\n  describe('profile-aware updates', () => {\n    it('should generate only profile workflows when custom profile is set', async () => {\n      // Set custom profile with only explore and new\n      setMockConfig({\n        featureFlags: {},\n        profile: 'custom',\n        delivery: 'both',\n        workflows: ['explore', 'new'],\n      });\n\n      // Set up a configured tool\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old');\n\n      await updateCommand.execute(testDir);\n\n      // Should create explore and new skills\n      expect(await FileSystemUtils.fileExists(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md')\n      )).toBe(true);\n      expect(await FileSystemUtils.fileExists(\n        path.join(skillsDir, 'openspec-new-change', 'SKILL.md')\n      )).toBe(true);\n\n      // Should NOT create non-profile skills\n      expect(await FileSystemUtils.fileExists(\n        path.join(skillsDir, 'openspec-apply-change', 'SKILL.md')\n      )).toBe(false);\n      expect(await FileSystemUtils.fileExists(\n        path.join(skillsDir, 'openspec-propose', 'SKILL.md')\n      )).toBe(false);\n    });\n\n    it('should respect skills-only delivery setting', async () => {\n      setMockConfig({\n        featureFlags: {},\n        profile: 'core',\n        delivery: 'skills',\n      });\n\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old');\n\n      await updateCommand.execute(testDir);\n\n      // Skills should be created\n      expect(await FileSystemUtils.fileExists(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md')\n      )).toBe(true);\n\n      // Commands should NOT be created\n      const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');\n      expect(await FileSystemUtils.fileExists(\n        path.join(commandsDir, 'explore.md')\n      )).toBe(false);\n    });\n\n    it('should respect commands-only delivery setting', async () => {\n      setMockConfig({\n        featureFlags: {},\n        profile: 'core',\n        delivery: 'commands',\n      });\n\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old');\n\n      await updateCommand.execute(testDir);\n\n      // Commands should be created\n      const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');\n      expect(await FileSystemUtils.fileExists(\n        path.join(commandsDir, 'explore.md')\n      )).toBe(true);\n\n      // Skills should be removed for commands-only delivery\n      expect(await FileSystemUtils.fileExists(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md')\n      )).toBe(false);\n    });\n\n    it('should remove skills for configured tools without command adapters in commands-only delivery', async () => {\n      setMockConfig({\n        featureFlags: {},\n        profile: 'core',\n        delivery: 'commands',\n      });\n\n      const { AI_TOOLS } = await import('../../src/core/config.js');\n      const { CommandAdapterRegistry } = await import('../../src/core/command-generation/index.js');\n      const adapterlessTool = AI_TOOLS.find((tool) => tool.skillsDir && !CommandAdapterRegistry.get(tool.value));\n      expect(adapterlessTool).toBeDefined();\n      if (!adapterlessTool?.skillsDir) {\n        return;\n      }\n\n      const skillsDir = path.join(testDir, adapterlessTool.skillsDir, 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old');\n\n      await expect(updateCommand.execute(testDir)).resolves.toBeUndefined();\n\n      expect(await FileSystemUtils.fileExists(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md')\n      )).toBe(false);\n    });\n\n    it('should apply config sync when templates are up to date', async () => {\n      setMockConfig({\n        featureFlags: {},\n        profile: 'core',\n        delivery: 'skills',\n      });\n\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });\n      const packageJsonPath = path.join(process.cwd(), 'package.json');\n      const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) as { version: string };\n      await fs.writeFile(\n        path.join(skillsDir, 'openspec-explore', 'SKILL.md'),\n        `---\nname: openspec-explore\nmetadata:\n  generatedBy: \"${packageJson.version}\"\n---\ncontent\n`\n      );\n\n      const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');\n      await fs.mkdir(commandsDir, { recursive: true });\n      await fs.writeFile(path.join(commandsDir, 'explore.md'), 'old command');\n\n      await updateCommand.execute(testDir);\n\n      // Command files should be removed due to delivery change, even though skill version is current\n      expect(await FileSystemUtils.fileExists(\n        path.join(commandsDir, 'explore.md')\n      )).toBe(false);\n    });\n\n    it('should detect commands-only tool configuration', async () => {\n      setMockConfig({\n        featureFlags: {},\n        profile: 'core',\n        delivery: 'commands',\n      });\n\n      const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');\n      await fs.mkdir(commandsDir, { recursive: true });\n      await fs.writeFile(path.join(commandsDir, 'explore.md'), 'existing command');\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Should not short-circuit with \"No configured tools found\"\n      const calls = consoleSpy.mock.calls.map(call =>\n        call.map(arg => String(arg)).join(' ')\n      );\n      const hasNoConfiguredMessage = calls.some(call =>\n        call.includes('No configured tools found')\n      );\n      expect(hasNoConfiguredMessage).toBe(false);\n\n      // Commands should be updated/generated for the core profile\n      expect(await FileSystemUtils.fileExists(\n        path.join(commandsDir, 'propose.md')\n      )).toBe(true);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should remove workflows outside profile during update sync', async () => {\n      // Set core profile (propose, explore, apply, archive)\n      setMockConfig({\n        featureFlags: {},\n        profile: 'core',\n        delivery: 'both',\n      });\n\n      // Set up tool with extra workflows beyond core profile\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old');\n\n      // Add a non-core workflow\n      await fs.mkdir(path.join(skillsDir, 'openspec-new-change'), { recursive: true });\n      await fs.writeFile(path.join(skillsDir, 'openspec-new-change', 'SKILL.md'), 'old');\n      const extraCommandFile = path.join(testDir, '.claude', 'commands', 'opsx', 'new.md');\n      await fs.mkdir(path.dirname(extraCommandFile), { recursive: true });\n      await fs.writeFile(extraCommandFile, 'old');\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Deselected workflow artifacts should be removed for both delivery surfaces.\n      expect(await FileSystemUtils.fileExists(\n        path.join(skillsDir, 'openspec-new-change', 'SKILL.md')\n      )).toBe(false);\n      expect(await FileSystemUtils.fileExists(extraCommandFile)).toBe(false);\n\n      // Should report deselected workflow cleanup.\n      const calls = consoleSpy.mock.calls.map(call =>\n        call.map(arg => String(arg)).join(' ')\n      );\n      const hasDeselectedRemovalNote = calls.some(call =>\n        call.includes('deselected workflows')\n      );\n      expect(hasDeselectedRemovalNote).toBe(true);\n\n      consoleSpy.mockRestore();\n    });\n  });\n\n  describe('new tool detection', () => {\n    it('should detect new tool directories not currently configured', async () => {\n      // Set up a configured Claude tool\n      const claudeSkillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(claudeSkillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'), 'old');\n\n      // Create a Cursor directory (not configured — no skills)\n      await fs.mkdir(path.join(testDir, '.cursor'), { recursive: true });\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      // Should detect Cursor as a new tool\n      const calls = consoleSpy.mock.calls.map(call =>\n        call.map(arg => String(arg)).join(' ')\n      );\n      const hasNewToolMessage = calls.some(call =>\n        call.includes(\"Detected new tool: Cursor. Run 'openspec init' to add it.\")\n      );\n      expect(hasNewToolMessage).toBe(true);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should consolidate multiple new tools into one message', async () => {\n      // Set up a configured Claude tool\n      const claudeSkillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(claudeSkillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'), 'old');\n\n      // Create two unconfigured tool directories\n      await fs.mkdir(path.join(testDir, '.github'), { recursive: true });\n      await fs.mkdir(path.join(testDir, '.windsurf'), { recursive: true });\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      const calls = consoleSpy.mock.calls.map(call =>\n        call.map(arg => String(arg)).join(' ')\n      );\n\n      const consolidatedCalls = calls.filter(call =>\n        call.includes('Detected new tools:')\n      );\n      expect(consolidatedCalls).toHaveLength(1);\n      expect(consolidatedCalls[0]).toContain('GitHub Copilot');\n      expect(consolidatedCalls[0]).toContain('Windsurf');\n      expect(consolidatedCalls[0]).toContain(\"Run 'openspec init' to add them.\");\n\n      const repeatedSingularCalls = calls.filter(call =>\n        call.includes('Detected new tool:')\n      );\n      expect(repeatedSingularCalls).toHaveLength(0);\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should not show new tool message when no new tools detected', async () => {\n      // Set up a configured tool (only Claude, no other tool directories)\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old');\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      const calls = consoleSpy.mock.calls.map(call =>\n        call.map(arg => String(arg)).join(' ')\n      );\n      const hasNewToolMessage = calls.some(call =>\n        call.includes('Detected new tool')\n      );\n      expect(hasNewToolMessage).toBe(false);\n\n      consoleSpy.mockRestore();\n    });\n  });\n\n  describe('scanInstalledWorkflows', () => {\n    it('should detect installed workflows across tools', async () => {\n      // Create skills for Claude\n      const claudeSkillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(claudeSkillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'), 'content');\n      await fs.mkdir(path.join(claudeSkillsDir, 'openspec-apply-change'), { recursive: true });\n      await fs.writeFile(path.join(claudeSkillsDir, 'openspec-apply-change', 'SKILL.md'), 'content');\n\n      const workflows = scanInstalledWorkflows(testDir, ['claude']);\n      expect(workflows).toContain('explore');\n      expect(workflows).toContain('apply');\n      expect(workflows).not.toContain('propose');\n    });\n\n    it('should return union of workflows across multiple tools', async () => {\n      // Claude has explore\n      const claudeSkillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(claudeSkillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'), 'content');\n\n      // Cursor has apply\n      const cursorSkillsDir = path.join(testDir, '.cursor', 'skills');\n      await fs.mkdir(path.join(cursorSkillsDir, 'openspec-apply-change'), { recursive: true });\n      await fs.writeFile(path.join(cursorSkillsDir, 'openspec-apply-change', 'SKILL.md'), 'content');\n\n      const workflows = scanInstalledWorkflows(testDir, ['claude', 'cursor']);\n      expect(workflows).toContain('explore');\n      expect(workflows).toContain('apply');\n    });\n\n    it('should only match workflows in ALL_WORKFLOWS', async () => {\n      // Create a custom skill directory that doesn't match any workflow\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'my-custom-skill'), { recursive: true });\n      await fs.writeFile(path.join(skillsDir, 'my-custom-skill', 'SKILL.md'), 'content');\n\n      const workflows = scanInstalledWorkflows(testDir, ['claude']);\n      expect(workflows).toHaveLength(0);\n    });\n\n    it('should return empty array when no tools have skills', async () => {\n      const workflows = scanInstalledWorkflows(testDir, ['claude']);\n      expect(workflows).toHaveLength(0);\n    });\n\n    it('should detect installed workflows from managed command files', async () => {\n      const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');\n      await fs.mkdir(commandsDir, { recursive: true });\n      await fs.writeFile(path.join(commandsDir, 'explore.md'), 'content');\n\n      const workflows = scanInstalledWorkflows(testDir, ['claude']);\n      expect(workflows).toContain('explore');\n    });\n  });\n\n  describe('tools output', () => {\n    it('should list affected tools in output', async () => {\n      const skillsDir = path.join(testDir, '.claude', 'skills');\n      await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });\n      await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old');\n\n      const consoleSpy = vi.spyOn(console, 'log');\n\n      await updateCommand.execute(testDir);\n\n      const calls = consoleSpy.mock.calls.map(call =>\n        call.map(arg => String(arg)).join(' ')\n      );\n      const hasToolsList = calls.some(call =>\n        call.includes('Tools:') && call.includes('Claude Code')\n      );\n      expect(hasToolsList).toBe(true);\n\n      consoleSpy.mockRestore();\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/validation.enriched-messages.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { Validator } from '../../src/core/validation/validator.js';\n\ndescribe('Validator enriched messages', () => {\n  const testDir = path.join(process.cwd(), 'test-validation-enriched-tmp');\n\n  beforeEach(async () => {\n    await fs.mkdir(testDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  it('adds guidance for no deltas in change', async () => {\n    const changeContent = `# Test Change\n\n## Why\nThis is a sufficiently long explanation to pass the why length requirement for validation purposes.\n\n## What Changes\nThere are changes proposed, but no delta specs provided yet.`;\n    const changePath = path.join(testDir, 'proposal.md');\n    await fs.writeFile(changePath, changeContent);\n\n    const validator = new Validator();\n    const report = await validator.validateChange(changePath);\n    expect(report.valid).toBe(false);\n    const msg = report.issues.map(i => i.message).join('\\n');\n    expect(msg).toContain('Change must have at least one delta');\n    expect(msg).toContain('Ensure your change has a specs/ directory');\n    expect(msg).toContain('## ADDED/MODIFIED/REMOVED/RENAMED Requirements');\n  });\n\n  it('adds guidance when spec missing Purpose/Requirements', async () => {\n    const specContent = `# Test Spec\\n\\n## Requirements\\n\\n### Requirement: Foo\\nFoo SHALL ...\\n\\n#### Scenario: Bar\\nWhen...`;\n    const specPath = path.join(testDir, 'spec.md');\n    await fs.writeFile(specPath, specContent);\n\n    const validator = new Validator();\n    const report = await validator.validateSpec(specPath);\n    expect(report.valid).toBe(false);\n    const msg = report.issues.map(i => i.message).join('\\n');\n    expect(msg).toContain('Spec must have a Purpose section');\n    expect(msg).toContain('Expected headers: \"## Purpose\" and \"## Requirements\"');\n  });\n\n  it('warns with scenario conversion template when missing scenarios', async () => {\n    const specContent = `# Test Spec\n\n## Purpose\nThis is a sufficiently long purpose section to avoid warnings about brevity.\n\n## Requirements\n\n### Requirement: Foo SHALL be described\nText of requirement\n`;\n    const specPath = path.join(testDir, 'spec.md');\n    await fs.writeFile(specPath, specContent);\n\n    const validator = new Validator();\n    const report = await validator.validateSpec(specPath);\n    expect(report.valid).toBe(false);\n    const warn = report.issues.find(i => i.path.includes('requirements[0].scenarios'));\n    expect(warn?.message).toContain('Requirement must have at least one scenario');\n    expect(warn?.message).toContain('Scenarios must use level-4 headers');\n    expect(warn?.message).toContain('#### Scenario:');\n  });\n});\n\n\n"
  },
  {
    "path": "test/core/validation.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { Validator } from '../../src/core/validation/validator.js';\nimport { \n  ScenarioSchema, \n  RequirementSchema, \n  SpecSchema, \n  ChangeSchema,\n  DeltaSchema \n} from '../../src/core/schemas/index.js';\n\ndescribe('Validation Schemas', () => {\n  describe('ScenarioSchema', () => {\n    it('should validate a valid scenario', () => {\n      const scenario = {\n        rawText: 'Given a user is logged in\\nWhen they click logout\\nThen they are redirected to login page',\n      };\n      \n      const result = ScenarioSchema.safeParse(scenario);\n      expect(result.success).toBe(true);\n    });\n\n    it('should reject scenario with empty text', () => {\n      const scenario = {\n        rawText: '',\n      };\n      \n      const result = ScenarioSchema.safeParse(scenario);\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.issues[0].message).toBe('Scenario text cannot be empty');\n      }\n    });\n  });\n\n  describe('RequirementSchema', () => {\n    it('should validate a valid requirement', () => {\n      const requirement = {\n        text: 'The system SHALL provide user authentication',\n        scenarios: [\n          {\n            rawText: 'Given a user with valid credentials\\nWhen they submit the login form\\nThen they are authenticated',\n          },\n        ],\n      };\n      \n      const result = RequirementSchema.safeParse(requirement);\n      expect(result.success).toBe(true);\n    });\n\n    it('should reject requirement without SHALL or MUST', () => {\n      const requirement = {\n        text: 'The system provides user authentication',\n        scenarios: [\n          {\n            rawText: 'Given a user\\nWhen they login\\nThen authenticated',\n          },\n        ],\n      };\n      \n      const result = RequirementSchema.safeParse(requirement);\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.issues[0].message).toBe('Requirement must contain SHALL or MUST keyword');\n      }\n    });\n\n    it('should reject requirement without scenarios', () => {\n      const requirement = {\n        text: 'The system SHALL provide user authentication',\n        scenarios: [],\n      };\n      \n      const result = RequirementSchema.safeParse(requirement);\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.issues[0].message).toBe('Requirement must have at least one scenario');\n      }\n    });\n  });\n\n  describe('SpecSchema', () => {\n    it('should validate a valid spec', () => {\n      const spec = {\n        name: 'user-auth',\n        overview: 'This spec defines user authentication requirements',\n        requirements: [\n          {\n            text: 'The system SHALL provide user authentication',\n            scenarios: [\n              {\n                rawText: 'Given a user with valid credentials\\nWhen they submit the login form\\nThen they are authenticated',\n              },\n            ],\n          },\n        ],\n      };\n      \n      const result = SpecSchema.safeParse(spec);\n      expect(result.success).toBe(true);\n    });\n\n    it('should reject spec without requirements', () => {\n      const spec = {\n        name: 'user-auth',\n        overview: 'This spec defines user authentication requirements',\n        requirements: [],\n      };\n      \n      const result = SpecSchema.safeParse(spec);\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.issues[0].message).toBe('Spec must have at least one requirement');\n      }\n    });\n  });\n\n  describe('ChangeSchema', () => {\n    it('should validate a valid change', () => {\n      const change = {\n        name: 'add-user-auth',\n        why: 'We need user authentication to secure the application and protect user data',\n        whatChanges: 'Add authentication module with login and logout capabilities',\n        deltas: [\n          {\n            spec: 'user-auth',\n            operation: 'ADDED',\n            description: 'Add new user authentication spec',\n          },\n        ],\n      };\n      \n      const result = ChangeSchema.safeParse(change);\n      expect(result.success).toBe(true);\n    });\n\n    it('should reject change with short why section', () => {\n      const change = {\n        name: 'add-user-auth',\n        why: 'Need auth',\n        whatChanges: 'Add authentication',\n        deltas: [\n          {\n            spec: 'user-auth',\n            operation: 'ADDED',\n            description: 'Add auth',\n          },\n        ],\n      };\n      \n      const result = ChangeSchema.safeParse(change);\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.issues[0].message).toBe('Why section must be at least 50 characters');\n      }\n    });\n\n    it('should warn about too many deltas', () => {\n      const deltas = Array.from({ length: 11 }, (_, i) => ({\n        spec: `spec-${i}`,\n        operation: 'ADDED' as const,\n        description: `Add spec ${i}`,\n      }));\n      \n      const change = {\n        name: 'massive-change',\n        why: 'This is a massive change that affects many parts of the system',\n        whatChanges: 'Update everything',\n        deltas,\n      };\n      \n      const result = ChangeSchema.safeParse(change);\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.issues[0].message).toBe('Consider splitting changes with more than 10 deltas');\n      }\n    });\n  });\n});\n\ndescribe('Validator', () => {\n  const testDir = path.join(process.cwd(), 'test-validation-tmp');\n  \n  beforeEach(async () => {\n    await fs.mkdir(testDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('validateSpec', () => {\n    it('should validate a valid spec file', async () => {\n      const specContent = `# User Authentication Spec\n\n## Purpose\nThis specification defines the requirements for user authentication in the system.\n\n## Requirements\n\n### The system SHALL provide secure user authentication\nThe system SHALL provide secure user authentication mechanisms.\n\n#### Scenario: Successful login\nGiven a user with valid credentials\nWhen they submit the login form\nThen they are authenticated and redirected to the dashboard\n\n### The system SHALL handle invalid login attempts\nThe system SHALL gracefully handle incorrect credentials.\n\n#### Scenario: Invalid credentials\nGiven a user with invalid credentials\nWhen they submit the login form\nThen they see an error message`;\n\n      const specPath = path.join(testDir, 'spec.md');\n      await fs.writeFile(specPath, specContent);\n      \n      const validator = new Validator();\n      const report = await validator.validateSpec(specPath);\n      \n      expect(report.valid).toBe(true);\n      expect(report.summary.errors).toBe(0);\n    });\n\n    it('should detect missing overview section', async () => {\n      const specContent = `# User Authentication Spec\n\n## Requirements\n\n### The system SHALL provide secure user authentication\n\n#### Scenario: Login\nGiven a user\nWhen they login\nThen authenticated`;\n\n      const specPath = path.join(testDir, 'spec.md');\n      await fs.writeFile(specPath, specContent);\n      \n      const validator = new Validator();\n      const report = await validator.validateSpec(specPath);\n      \n      expect(report.valid).toBe(false);\n      expect(report.summary.errors).toBeGreaterThan(0);\n      expect(report.issues.some(i => i.message.includes('Purpose'))).toBe(true);\n    });\n  });\n\n  describe('validateChange', () => {\n    it('should validate a valid change file', async () => {\n      const changeContent = `# Add User Authentication\n\n## Why\nWe need to implement user authentication to secure the application and protect user data from unauthorized access.\n\n## What Changes\n- **user-auth:** Add new user authentication specification\n- **api-endpoints:** Modify to include auth endpoints`;\n\n      const changePath = path.join(testDir, 'change.md');\n      await fs.writeFile(changePath, changeContent);\n      \n      const validator = new Validator();\n      const report = await validator.validateChange(changePath);\n      \n      expect(report.valid).toBe(true);\n      expect(report.summary.errors).toBe(0);\n    });\n\n    it('should detect missing why section', async () => {\n      const changeContent = `# Add User Authentication\n\n## What Changes\n- **user-auth:** Add new user authentication specification`;\n\n      const changePath = path.join(testDir, 'change.md');\n      await fs.writeFile(changePath, changeContent);\n      \n      const validator = new Validator();\n      const report = await validator.validateChange(changePath);\n      \n      expect(report.valid).toBe(false);\n      expect(report.summary.errors).toBeGreaterThan(0);\n      expect(report.issues.some(i => i.message.includes('Why'))).toBe(true);\n    });\n  });\n\n  describe('strict mode', () => {\n    it('should fail on warnings in strict mode', async () => {\n      const specContent = `# Test Spec\n\n## Purpose\nBrief overview\n\n## Requirements\n\n### The system SHALL do something\n\n#### Scenario: Test\nGiven test\nWhen action\nThen result`;\n\n      const specPath = path.join(testDir, 'spec.md');\n      await fs.writeFile(specPath, specContent);\n\n      const validator = new Validator(true); // strict mode\n      const report = await validator.validateSpec(specPath);\n\n      expect(report.valid).toBe(false); // Should fail due to brief overview warning\n    });\n\n    it('should pass warnings in non-strict mode', async () => {\n      const specContent = `# Test Spec\n\n## Purpose\nBrief overview\n\n## Requirements\n\n### The system SHALL do something\n\n#### Scenario: Test\nGiven test\nWhen action\nThen result`;\n\n      const specPath = path.join(testDir, 'spec.md');\n      await fs.writeFile(specPath, specContent);\n\n      const validator = new Validator(false); // non-strict mode\n      const report = await validator.validateSpec(specPath);\n\n      expect(report.valid).toBe(true); // Should pass despite warnings\n      expect(report.summary.warnings).toBeGreaterThan(0);\n    });\n  });\n\n  describe('validateChangeDeltaSpecs with metadata', () => {\n    it('should validate requirement with metadata before SHALL/MUST text', async () => {\n      const changeDir = path.join(testDir, 'test-change');\n      const specsDir = path.join(changeDir, 'specs', 'test-spec');\n      await fs.mkdir(specsDir, { recursive: true });\n\n      const deltaSpec = `# Test Spec\n\n## ADDED Requirements\n\n### Requirement: Circuit Breaker State Management SHALL be implemented\n**ID**: REQ-CB-001\n**Priority**: P1 (High)\n\nThe system MUST implement a circuit breaker with three states.\n\n#### Scenario: Normal operation\n**Given** the circuit breaker is in CLOSED state\n**When** a request is made\n**Then** the request is executed normally`;\n\n      const specPath = path.join(specsDir, 'spec.md');\n      await fs.writeFile(specPath, deltaSpec);\n\n      const validator = new Validator(true);\n      const report = await validator.validateChangeDeltaSpecs(changeDir);\n\n      expect(report.valid).toBe(true);\n      expect(report.summary.errors).toBe(0);\n    });\n\n    it('should validate requirement with SHALL in text but not in header', async () => {\n      const changeDir = path.join(testDir, 'test-change-2');\n      const specsDir = path.join(changeDir, 'specs', 'test-spec');\n      await fs.mkdir(specsDir, { recursive: true });\n\n      const deltaSpec = `# Test Spec\n\n## ADDED Requirements\n\n### Requirement: Error Handling\n**ID**: REQ-ERR-001\n**Priority**: P2\n\nThe system SHALL handle all errors gracefully.\n\n#### Scenario: Error occurs\n**Given** an error condition\n**When** an error occurs\n**Then** the error is logged and user is notified`;\n\n      const specPath = path.join(specsDir, 'spec.md');\n      await fs.writeFile(specPath, deltaSpec);\n\n      const validator = new Validator(true);\n      const report = await validator.validateChangeDeltaSpecs(changeDir);\n\n      expect(report.valid).toBe(true);\n      expect(report.summary.errors).toBe(0);\n    });\n\n    it('should fail when requirement text lacks SHALL/MUST', async () => {\n      const changeDir = path.join(testDir, 'test-change-3');\n      const specsDir = path.join(changeDir, 'specs', 'test-spec');\n      await fs.mkdir(specsDir, { recursive: true });\n\n      const deltaSpec = `# Test Spec\n\n## ADDED Requirements\n\n### Requirement: Logging Feature\n**ID**: REQ-LOG-001\n\nThe system will log all events.\n\n#### Scenario: Event occurs\n**Given** an event\n**When** it occurs\n**Then** it is logged`;\n\n      const specPath = path.join(specsDir, 'spec.md');\n      await fs.writeFile(specPath, deltaSpec);\n\n      const validator = new Validator(true);\n      const report = await validator.validateChangeDeltaSpecs(changeDir);\n\n      expect(report.valid).toBe(false);\n      expect(report.summary.errors).toBeGreaterThan(0);\n      expect(report.issues.some(i => i.message.includes('must contain SHALL or MUST'))).toBe(true);\n    });\n\n    it('should handle requirements without metadata fields', async () => {\n      const changeDir = path.join(testDir, 'test-change-4');\n      const specsDir = path.join(changeDir, 'specs', 'test-spec');\n      await fs.mkdir(specsDir, { recursive: true });\n\n      const deltaSpec = `# Test Spec\n\n## ADDED Requirements\n\n### Requirement: Simple Feature\nThe system SHALL implement this feature.\n\n#### Scenario: Basic usage\n**Given** a condition\n**When** an action occurs\n**Then** a result happens`;\n\n      const specPath = path.join(specsDir, 'spec.md');\n      await fs.writeFile(specPath, deltaSpec);\n\n      const validator = new Validator(true);\n      const report = await validator.validateChangeDeltaSpecs(changeDir);\n\n      expect(report.valid).toBe(true);\n      expect(report.summary.errors).toBe(0);\n    });\n\n    it('should treat delta headers case-insensitively', async () => {\n      const changeDir = path.join(testDir, 'test-change-mixed-case');\n      const specsDir = path.join(changeDir, 'specs', 'test-spec');\n      await fs.mkdir(specsDir, { recursive: true });\n\n      const deltaSpec = `# Test Spec\n\n## Added Requirements\n\n### Requirement: Mixed Case Handling\nThe system MUST support mixed case delta headers.\n\n#### Scenario: Case insensitive parsing\n**Given** a delta file with mixed case headers\n**When** validation runs\n**Then** the delta is detected`;\n\n      const specPath = path.join(specsDir, 'spec.md');\n      await fs.writeFile(specPath, deltaSpec);\n\n      const validator = new Validator(true);\n      const report = await validator.validateChangeDeltaSpecs(changeDir);\n\n      expect(report.valid).toBe(true);\n      expect(report.summary.errors).toBe(0);\n      expect(report.summary.warnings).toBe(0);\n      expect(report.summary.info).toBe(0);\n    });\n  });\n});\n"
  },
  {
    "path": "test/core/view.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { ViewCommand } from '../../src/core/view.js';\n\nconst stripAnsi = (input: string): string => input.replace(/\\u001b\\[[0-9;]*m/g, '');\n\ndescribe('ViewCommand', () => {\n  let tempDir: string;\n  let originalLog: typeof console.log;\n  let logOutput: string[] = [];\n\n  beforeEach(async () => {\n    tempDir = path.join(os.tmpdir(), `openspec-view-test-${Date.now()}`);\n    await fs.mkdir(tempDir, { recursive: true });\n\n    originalLog = console.log;\n    console.log = (...args: any[]) => {\n      logOutput.push(args.join(' '));\n    };\n\n    logOutput = [];\n  });\n\n  afterEach(async () => {\n    console.log = originalLog;\n    await fs.rm(tempDir, { recursive: true, force: true });\n  });\n\n  it('shows changes with no tasks in Draft section, not Completed', async () => {\n    const changesDir = path.join(tempDir, 'openspec', 'changes');\n    await fs.mkdir(changesDir, { recursive: true });\n\n    // Empty change (no tasks.md) - should show in Draft\n    await fs.mkdir(path.join(changesDir, 'empty-change'), { recursive: true });\n\n    // Change with tasks.md but no tasks - should show in Draft\n    await fs.mkdir(path.join(changesDir, 'no-tasks-change'), { recursive: true });\n    await fs.writeFile(path.join(changesDir, 'no-tasks-change', 'tasks.md'), '# Tasks\\n\\nNo tasks yet.');\n\n    // Change with all tasks complete - should show in Completed\n    await fs.mkdir(path.join(changesDir, 'completed-change'), { recursive: true });\n    await fs.writeFile(\n      path.join(changesDir, 'completed-change', 'tasks.md'),\n      '- [x] Done task\\n'\n    );\n\n    const viewCommand = new ViewCommand();\n    await viewCommand.execute(tempDir);\n\n    const output = logOutput.map(stripAnsi).join('\\n');\n\n    // Draft section should contain empty and no-tasks changes\n    expect(output).toContain('Draft Changes');\n    expect(output).toContain('empty-change');\n    expect(output).toContain('no-tasks-change');\n\n    // Completed section should only contain changes with all tasks done\n    expect(output).toContain('Completed Changes');\n    expect(output).toContain('completed-change');\n\n    // Verify empty-change and no-tasks-change are in Draft section (marked with ○)\n    const draftLines = logOutput\n      .map(stripAnsi)\n      .filter((line) => line.includes('○'));\n    const draftNames = draftLines.map((line) => line.trim().replace('○ ', ''));\n    expect(draftNames).toContain('empty-change');\n    expect(draftNames).toContain('no-tasks-change');\n\n    // Verify completed-change is in Completed section (marked with ✓)\n    const completedLines = logOutput\n      .map(stripAnsi)\n      .filter((line) => line.includes('✓'));\n    const completedNames = completedLines.map((line) => line.trim().replace('✓ ', ''));\n    expect(completedNames).toContain('completed-change');\n    expect(completedNames).not.toContain('empty-change');\n    expect(completedNames).not.toContain('no-tasks-change');\n  });\n\n  it('sorts active changes by completion percentage ascending with deterministic tie-breakers', async () => {\n    const changesDir = path.join(tempDir, 'openspec', 'changes');\n    await fs.mkdir(changesDir, { recursive: true });\n\n    await fs.mkdir(path.join(changesDir, 'gamma-change'), { recursive: true });\n    await fs.writeFile(\n      path.join(changesDir, 'gamma-change', 'tasks.md'),\n      '- [x] Done\\n- [x] Also done\\n- [ ] Not done\\n'\n    );\n\n    await fs.mkdir(path.join(changesDir, 'beta-change'), { recursive: true });\n    await fs.writeFile(\n      path.join(changesDir, 'beta-change', 'tasks.md'),\n      '- [x] Task 1\\n- [ ] Task 2\\n'\n    );\n\n    await fs.mkdir(path.join(changesDir, 'delta-change'), { recursive: true });\n    await fs.writeFile(\n      path.join(changesDir, 'delta-change', 'tasks.md'),\n      '- [x] Task 1\\n- [ ] Task 2\\n'\n    );\n\n    await fs.mkdir(path.join(changesDir, 'alpha-change'), { recursive: true });\n    await fs.writeFile(\n      path.join(changesDir, 'alpha-change', 'tasks.md'),\n      '- [ ] Task 1\\n- [ ] Task 2\\n'\n    );\n\n    const viewCommand = new ViewCommand();\n    await viewCommand.execute(tempDir);\n\n    const activeLines = logOutput\n      .map(stripAnsi)\n      .filter(line => line.includes('◉'));\n\n    const activeOrder = activeLines.map(line => {\n      const afterBullet = line.split('◉')[1] ?? '';\n      return afterBullet.split('[')[0]?.trim();\n    });\n\n    expect(activeOrder).toEqual([\n      'alpha-change',\n      'beta-change',\n      'delta-change',\n      'gamma-change'\n    ]);\n  });\n});\n\n"
  },
  {
    "path": "test/fixtures/tmp-init/openspec/changes/c1/proposal.md",
    "content": "# Test Change\n\n## Why\nBecause reasons that are sufficiently long for validation.\n\n## What Changes\n- **alpha:** Add something\n"
  },
  {
    "path": "test/fixtures/tmp-init/openspec/changes/c1/specs/alpha/spec.md",
    "content": "## ADDED Requirements\n### Requirement: Parser SHALL accept CRLF change proposals\nThe parser SHALL accept CRLF change proposals without manual edits.\n\n#### Scenario: Validate CRLF change\n- **GIVEN** a change proposal saved with CRLF line endings\n- **WHEN** a developer runs openspec validate on the proposal\n- **THEN** validation succeeds without section errors\n"
  },
  {
    "path": "test/fixtures/tmp-init/openspec/specs/alpha/spec.md",
    "content": "## Purpose\nThis spec ensures the validation harness exercises a deterministic alpha module for automated tests.\n\n## Requirements\n\n### Requirement: Alpha module SHALL produce deterministic output\nThe alpha module SHALL produce a deterministic response for validation.\n\n#### Scenario: Deterministic alpha run\n- **GIVEN** a configured alpha module\n- **WHEN** the module runs the default flow\n- **THEN** the output matches the expected fixture result\n"
  },
  {
    "path": "test/helpers/run-cli.ts",
    "content": "import { spawn } from 'child_process';\nimport { existsSync } from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\nconst projectRoot = path.resolve(__dirname, '..', '..');\nconst cliEntry = path.join(projectRoot, 'dist', 'cli', 'index.js');\n\nlet buildPromise: Promise<void> | undefined;\n\ninterface RunCommandOptions {\n  cwd?: string;\n  env?: NodeJS.ProcessEnv;\n}\n\ninterface RunCLIOptions {\n  cwd?: string;\n  env?: NodeJS.ProcessEnv;\n  input?: string;\n  timeoutMs?: number;\n}\n\nexport interface RunCLIResult {\n  exitCode: number | null;\n  signal: NodeJS.Signals | null;\n  stdout: string;\n  stderr: string;\n  timedOut: boolean;\n  command: string;\n}\n\nfunction runCommand(command: string, args: string[], options: RunCommandOptions = {}) {\n  return new Promise<void>((resolve, reject) => {\n    const child = spawn(command, args, {\n      cwd: options.cwd ?? projectRoot,\n      env: { ...process.env, ...options.env },\n      stdio: 'inherit',\n      shell: process.platform === 'win32',\n    });\n\n    child.on('error', (error) => reject(error));\n    child.on('close', (code, signal) => {\n      if (code === 0) {\n        resolve();\n      } else {\n        const reason = signal ? `signal ${signal}` : `exit code ${code}`;\n        reject(new Error(`Command failed (${reason}): ${command} ${args.join(' ')}`));\n      }\n    });\n  });\n}\n\nexport async function ensureCliBuilt() {\n  if (existsSync(cliEntry)) {\n    return;\n  }\n\n  if (!buildPromise) {\n    buildPromise = runCommand('pnpm', ['run', 'build']).catch((error) => {\n      buildPromise = undefined;\n      throw error;\n    });\n  }\n\n  await buildPromise;\n\n  if (!existsSync(cliEntry)) {\n    throw new Error('CLI entry point missing after build. Expected dist/cli/index.js');\n  }\n}\n\nexport async function runCLI(args: string[] = [], options: RunCLIOptions = {}): Promise<RunCLIResult> {\n  await ensureCliBuilt();\n\n  const finalArgs = Array.isArray(args) ? args : [args];\n  const invocation = [cliEntry, ...finalArgs].join(' ');\n\n  return new Promise<RunCLIResult>((resolve, reject) => {\n    const child = spawn(process.execPath, [cliEntry, ...finalArgs], {\n      cwd: options.cwd ?? projectRoot,\n      env: {\n        ...process.env,\n        OPEN_SPEC_INTERACTIVE: '0',\n        ...options.env,\n      },\n      stdio: ['pipe', 'pipe', 'pipe'],\n      windowsHide: true,\n    });\n\n    // Prevent child process from keeping the event loop alive\n    child.unref();\n\n    let stdout = '';\n    let stderr = '';\n    let timedOut = false;\n\n    const timeout = options.timeoutMs\n      ? setTimeout(() => {\n          timedOut = true;\n          child.kill('SIGKILL');\n        }, options.timeoutMs)\n      : undefined;\n\n    child.stdout?.setEncoding('utf-8');\n    child.stdout?.on('data', (chunk) => {\n      stdout += chunk;\n    });\n\n    child.stderr?.setEncoding('utf-8');\n    child.stderr?.on('data', (chunk) => {\n      stderr += chunk;\n    });\n\n    child.on('error', (error) => {\n      if (timeout) clearTimeout(timeout);\n      // Explicitly destroy streams to prevent hanging handles\n      child.stdout?.destroy();\n      child.stderr?.destroy();\n      child.stdin?.destroy();\n      reject(error);\n    });\n\n    child.on('close', (code, signal) => {\n      if (timeout) clearTimeout(timeout);\n      // Explicitly destroy streams to prevent hanging handles\n      child.stdout?.destroy();\n      child.stderr?.destroy();\n      child.stdin?.destroy();\n      resolve({\n        exitCode: code,\n        signal,\n        stdout,\n        stderr,\n        timedOut,\n        command: `node ${invocation}`,\n      });\n    });\n\n    if (options.input && child.stdin) {\n      child.stdin.end(options.input);\n    } else if (child.stdin) {\n      child.stdin.end();\n    }\n  });\n}\n\nexport const cliProjectRoot = projectRoot;\n"
  },
  {
    "path": "test/prompts/searchable-multi-select.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest';\n\n/**\n * Tests for searchable-multi-select keybinding behavior.\n *\n * We mock @inquirer/core to intercept the prompt's render function and\n * keypress handler, then simulate key events to verify:\n *   - Space toggles selection (add/remove)\n *   - Enter confirms and submits\n *   - Tab does NOT confirm (removed)\n *   - Hint text is updated\n */\n\n// State store for the mock hook system\nconst state: Record<number, unknown> = {};\nlet stateIndex = 0;\nlet keypressHandler: ((key: Record<string, unknown>) => void) | null = null;\nlet renderOutput = '';\n\nfunction resetState() {\n  for (const k of Object.keys(state)) delete state[k as unknown as number];\n  stateIndex = 0;\n  keypressHandler = null;\n  renderOutput = '';\n  currentRenderFn = null;\n  currentConfig = null;\n  currentDone = null;\n}\n\n// Re-render: reset hook index, re-invoke the render function\nlet currentRenderFn: ((config: Record<string, unknown>, done: (v: string[]) => void) => string) | null = null;\nlet currentConfig: Record<string, unknown> | null = null;\nlet currentDone: ((v: string[]) => void) | null = null;\n\nfunction rerender() {\n  if (!currentRenderFn || !currentConfig || !currentDone) return;\n  stateIndex = 0;\n  renderOutput = currentRenderFn(currentConfig, currentDone);\n}\n\nvi.mock('@inquirer/core', () => {\n  return {\n    createPrompt: (fn: (config: Record<string, unknown>, done: (v: string[]) => void) => string) => {\n      currentRenderFn = fn;\n      return (config: Record<string, unknown>) => {\n        return new Promise<string[]>((resolve) => {\n          currentConfig = config;\n          currentDone = resolve;\n          stateIndex = 0;\n          renderOutput = fn(config, resolve);\n        });\n      };\n    },\n    useState: (initial: unknown) => {\n      const idx = stateIndex++;\n      if (!(idx in state)) {\n        state[idx] = typeof initial === 'function' ? (initial as () => unknown)() : initial;\n      }\n      const setter = (value: unknown) => {\n        state[idx] = value;\n        // Re-render after state change\n        rerender();\n      };\n      return [state[idx], setter];\n    },\n    useKeypress: (handler: (key: Record<string, unknown>) => void) => {\n      keypressHandler = handler;\n    },\n    useMemo: (fn: () => unknown, _deps: unknown[]) => fn(),\n    usePrefix: () => '?',\n    isEnterKey: (key: Record<string, unknown>) => key.name === 'return' || key.name === 'enter',\n    isBackspaceKey: (key: Record<string, unknown>) => key.name === 'backspace',\n    isUpKey: (key: Record<string, unknown>) => key.name === 'up',\n    isDownKey: (key: Record<string, unknown>) => key.name === 'down',\n  };\n});\n\nfunction pressKey(name: string) {\n  if (!keypressHandler) throw new Error('No keypress handler registered');\n  keypressHandler({ name, ctrl: false });\n}\n\nfunction getSelectedValues(): string[] {\n  return (state[1] as string[]) ?? [];\n}\n\nfunction getStatus(): string {\n  return (state[3] as string) ?? 'idle';\n}\n\nfunction getError(): string | null {\n  return (state[4] as string | null) ?? null;\n}\n\nconst testChoices = [\n  { name: 'Tool A', value: 'tool-a' },\n  { name: 'Tool B', value: 'tool-b' },\n  { name: 'Tool C', value: 'tool-c' },\n];\n\nasync function setup(choices = testChoices, validate?: (selected: string[]) => boolean | string) {\n  resetState();\n\n  const mod = await import('../../src/prompts/searchable-multi-select.js');\n\n  // Fire and forget - the promise resolves only when done() is called via Enter\n  // We just need the side effect of registering the keypress handler\n  mod.searchableMultiSelect({\n    message: 'Select tools',\n    choices,\n    validate,\n  });\n\n  // The async chain in searchableMultiSelect involves:\n  //   1. await createSearchableMultiSelect() -> await import('@inquirer/core')\n  //   2. prompt(config) which registers the keypress handler synchronously\n  // Flush enough microtask ticks for the full chain to settle.\n  await vi.waitFor(() => {\n    if (!keypressHandler) throw new Error('Keypress handler not yet registered');\n  }, { timeout: 500 });\n}\n\ndescribe('searchable-multi-select keybindings', () => {\n  beforeEach(() => {\n    resetState();\n    vi.resetModules();\n  });\n\n  describe('Space to toggle', () => {\n    it('should select highlighted item when Space is pressed', async () => {\n      await setup();\n      pressKey('space');\n      expect(getSelectedValues()).toContain('tool-a');\n    });\n\n    it('should deselect highlighted item when Space is pressed on already-selected item', async () => {\n      await setup();\n      pressKey('space');\n      expect(getSelectedValues()).toContain('tool-a');\n\n      pressKey('space');\n      expect(getSelectedValues()).not.toContain('tool-a');\n    });\n\n    it('should toggle multiple items independently', async () => {\n      await setup();\n\n      // Select Tool A\n      pressKey('space');\n      expect(getSelectedValues()).toEqual(['tool-a']);\n\n      // Move down to Tool B, select it\n      pressKey('down');\n      pressKey('space');\n      expect(getSelectedValues()).toContain('tool-a');\n      expect(getSelectedValues()).toContain('tool-b');\n\n      // Move back up to Tool A, deselect it\n      pressKey('up');\n      pressKey('space');\n      expect(getSelectedValues()).not.toContain('tool-a');\n      expect(getSelectedValues()).toContain('tool-b');\n    });\n  });\n\n  describe('Enter to confirm', () => {\n    it('should set status to done when Enter is pressed', async () => {\n      await setup();\n      pressKey('space');\n      pressKey('return');\n      expect(getStatus()).toBe('done');\n    });\n\n    it('should confirm with empty selection', async () => {\n      await setup();\n      pressKey('return');\n      expect(getStatus()).toBe('done');\n    });\n\n    it('should show validation error when validation fails', async () => {\n      const validate = (selected: string[]) =>\n        selected.length > 0 ? true : 'Select at least one';\n      await setup(testChoices, validate);\n\n      pressKey('return');\n      expect(getStatus()).toBe('idle');\n      expect(getError()).toBe('Select at least one');\n    });\n\n    it('should confirm when validation passes', async () => {\n      const validate = (selected: string[]) =>\n        selected.length > 0 ? true : 'Select at least one';\n      await setup(testChoices, validate);\n\n      pressKey('space');\n      pressKey('return');\n      expect(getStatus()).toBe('done');\n    });\n  });\n\n  describe('Tab does not confirm', () => {\n    it('should not change status when Tab is pressed', async () => {\n      await setup();\n      pressKey('space');\n      pressKey('tab');\n      expect(getStatus()).toBe('idle');\n    });\n  });\n\n  describe('hint text', () => {\n    it('should include Space toggle and Enter confirm in rendered output', async () => {\n      await setup();\n      expect(renderOutput).toContain('Space');\n      expect(renderOutput).toContain('toggle');\n      expect(renderOutput).toContain('Enter');\n      expect(renderOutput).toContain('confirm');\n      expect(renderOutput).not.toMatch(/Tab.*confirm/);\n    });\n  });\n});\n"
  },
  {
    "path": "test/specs/source-specs-normalization.test.ts",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\nimport { describe, it, expect } from 'vitest';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\nconst projectRoot = path.resolve(__dirname, '..', '..');\nconst specsRoot = path.join(projectRoot, 'openspec', 'specs');\n\nconst DELTA_HEADER_PATTERN = /^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements$/m;\nconst PURPOSE_PLACEHOLDER_PATTERN = /TBD - created by archiving change .*?\\. Update Purpose after archive\\./;\n\nasync function getSpecFiles(): Promise<string[]> {\n  const entries = await fs.readdir(specsRoot, { withFileTypes: true });\n  const files: string[] = [];\n\n  for (const entry of entries) {\n    if (!entry.isDirectory()) continue;\n    const specFile = path.join(specsRoot, entry.name, 'spec.md');\n    try {\n      await fs.access(specFile);\n      files.push(specFile);\n    } catch {\n      // Ignore directories without spec.md\n    }\n  }\n\n  return files.sort();\n}\n\ndescribe('source-of-truth specs normalization', () => {\n  it('enforces required sections and bans archive placeholders/delta headers', async () => {\n    const files = await getSpecFiles();\n    expect(files.length).toBeGreaterThan(0);\n\n    for (const file of files) {\n      const content = await fs.readFile(file, 'utf8');\n      const relativeFile = path.relative(projectRoot, file);\n\n      expect(content, `${relativeFile} must include ## Purpose`).toMatch(/^## Purpose$/m);\n      expect(content, `${relativeFile} must include ## Requirements`).toMatch(/^## Requirements$/m);\n      expect(content, `${relativeFile} must not include archive placeholder purpose text`).not.toMatch(\n        PURPOSE_PLACEHOLDER_PATTERN\n      );\n      expect(content, `${relativeFile} must not include delta headers in source-of-truth specs`).not.toMatch(\n        DELTA_HEADER_PATTERN\n      );\n    }\n  });\n});\n"
  },
  {
    "path": "test/telemetry/config.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\n\nimport {\n  getConfigPath,\n  readConfig,\n  writeConfig,\n  getTelemetryConfig,\n  updateTelemetryConfig,\n} from '../../src/telemetry/config.js';\n\ndescribe('telemetry/config', () => {\n  let tempDir: string;\n  let originalHome: string | undefined;\n  let originalUserProfile: string | undefined;\n\n  beforeEach(() => {\n    // Create temp directory for tests\n    tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${Date.now()}`);\n    fs.mkdirSync(tempDir, { recursive: true });\n\n    // Mock HOME/USERPROFILE to point to temp dir\n    // On POSIX, os.homedir() uses HOME; on Windows it uses USERPROFILE\n    originalHome = process.env.HOME;\n    originalUserProfile = process.env.USERPROFILE;\n    process.env.HOME = tempDir;\n    process.env.USERPROFILE = tempDir;\n  });\n\n  afterEach(() => {\n    // Restore HOME/USERPROFILE\n    process.env.HOME = originalHome;\n    process.env.USERPROFILE = originalUserProfile;\n\n    // Clean up temp directory\n    fs.rmSync(tempDir, { recursive: true, force: true });\n  });\n\n  describe('getConfigPath', () => {\n    it('should return path to config.json in .config/openspec', () => {\n      const result = getConfigPath();\n      expect(result).toBe(path.join(tempDir, '.config', 'openspec', 'config.json'));\n    });\n  });\n\n  describe('readConfig', () => {\n    it('should return empty object when config file does not exist', async () => {\n      const config = await readConfig();\n      expect(config).toEqual({});\n    });\n\n    it('should load valid config from file', async () => {\n      const configDir = path.join(tempDir, '.config', 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, JSON.stringify({\n        telemetry: { anonymousId: 'test-id', noticeSeen: true }\n      }));\n\n      const config = await readConfig();\n      expect(config.telemetry).toEqual({ anonymousId: 'test-id', noticeSeen: true });\n    });\n\n    it('should return empty object for invalid JSON', async () => {\n      const configDir = path.join(tempDir, '.config', 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, '{ invalid json }');\n\n      const config = await readConfig();\n      expect(config).toEqual({});\n    });\n  });\n\n  describe('writeConfig', () => {\n    it('should create directory if it does not exist', async () => {\n      const configDir = path.join(tempDir, '.config', 'openspec');\n\n      await writeConfig({ telemetry: { noticeSeen: true } });\n\n      expect(fs.existsSync(configDir)).toBe(true);\n    });\n\n    it('should write config to file', async () => {\n      const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');\n\n      await writeConfig({ telemetry: { anonymousId: 'test-123' } });\n\n      const content = fs.readFileSync(configPath, 'utf-8');\n      const parsed = JSON.parse(content);\n      expect(parsed.telemetry.anonymousId).toBe('test-123');\n    });\n\n    it('should preserve existing fields when updating', async () => {\n      const configDir = path.join(tempDir, '.config', 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      // Create initial config with other fields\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, JSON.stringify({\n        existingField: 'preserved',\n        telemetry: { anonymousId: 'old-id' }\n      }));\n\n      // Update telemetry\n      await writeConfig({ telemetry: { noticeSeen: true } });\n\n      const content = fs.readFileSync(configPath, 'utf-8');\n      const parsed = JSON.parse(content);\n      expect(parsed.existingField).toBe('preserved');\n      expect(parsed.telemetry.noticeSeen).toBe(true);\n    });\n\n    it('should deep merge telemetry fields', async () => {\n      const configDir = path.join(tempDir, '.config', 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      // Create initial config\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, JSON.stringify({\n        telemetry: { anonymousId: 'existing-id' }\n      }));\n\n      // Update with noticeSeen only\n      await writeConfig({ telemetry: { noticeSeen: true } });\n\n      const content = fs.readFileSync(configPath, 'utf-8');\n      const parsed = JSON.parse(content);\n      expect(parsed.telemetry.anonymousId).toBe('existing-id');\n      expect(parsed.telemetry.noticeSeen).toBe(true);\n    });\n  });\n\n  describe('getTelemetryConfig', () => {\n    it('should return empty object when no config exists', async () => {\n      const config = await getTelemetryConfig();\n      expect(config).toEqual({});\n    });\n\n    it('should return telemetry section from config', async () => {\n      const configDir = path.join(tempDir, '.config', 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, JSON.stringify({\n        telemetry: { anonymousId: 'my-id', noticeSeen: false }\n      }));\n\n      const config = await getTelemetryConfig();\n      expect(config).toEqual({ anonymousId: 'my-id', noticeSeen: false });\n    });\n  });\n\n  describe('updateTelemetryConfig', () => {\n    it('should create telemetry config when none exists', async () => {\n      await updateTelemetryConfig({ anonymousId: 'new-id' });\n\n      const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');\n      const content = fs.readFileSync(configPath, 'utf-8');\n      const parsed = JSON.parse(content);\n      expect(parsed.telemetry.anonymousId).toBe('new-id');\n    });\n\n    it('should merge with existing telemetry config', async () => {\n      const configDir = path.join(tempDir, '.config', 'openspec');\n      const configPath = path.join(configDir, 'config.json');\n\n      fs.mkdirSync(configDir, { recursive: true });\n      fs.writeFileSync(configPath, JSON.stringify({\n        telemetry: { anonymousId: 'existing-id' }\n      }));\n\n      await updateTelemetryConfig({ noticeSeen: true });\n\n      const content = fs.readFileSync(configPath, 'utf-8');\n      const parsed = JSON.parse(content);\n      expect(parsed.telemetry.anonymousId).toBe('existing-id');\n      expect(parsed.telemetry.noticeSeen).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "test/telemetry/index.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport { randomUUID } from 'node:crypto';\n\n// Mock posthog-node before importing the module\nvi.mock('posthog-node', () => {\n  return {\n    PostHog: vi.fn().mockImplementation(() => ({\n      capture: vi.fn(),\n      shutdown: vi.fn().mockResolvedValue(undefined),\n    })),\n  };\n});\n\n// Import after mocking\nimport { isTelemetryEnabled, maybeShowTelemetryNotice, shutdown, trackCommand } from '../../src/telemetry/index.js';\nimport { PostHog } from 'posthog-node';\n\ndescribe('telemetry/index', () => {\n  let tempDir: string;\n  let originalEnv: NodeJS.ProcessEnv;\n  let consoleLogSpy: ReturnType<typeof vi.spyOn>;\n\n  beforeEach(() => {\n    // Create unique temp directory for each test using UUID\n    tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${randomUUID()}`);\n    fs.mkdirSync(tempDir, { recursive: true });\n\n    // Save original env\n    originalEnv = { ...process.env };\n\n    // Mock HOME to point to temp dir\n    process.env.HOME = tempDir;\n\n    // Clear all mocks\n    vi.clearAllMocks();\n\n    // Spy on console.log for notice tests\n    consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});\n  });\n\n  afterEach(() => {\n    // Restore original env\n    process.env = originalEnv;\n\n    // Clean up temp directory\n    try {\n      fs.rmSync(tempDir, { recursive: true, force: true });\n    } catch {\n      // Ignore cleanup errors\n    }\n\n    // Restore all mocks\n    vi.restoreAllMocks();\n  });\n\n  describe('isTelemetryEnabled', () => {\n    it('should return false when OPENSPEC_TELEMETRY=0', () => {\n      process.env.OPENSPEC_TELEMETRY = '0';\n      expect(isTelemetryEnabled()).toBe(false);\n    });\n\n    it('should return false when DO_NOT_TRACK=1', () => {\n      process.env.DO_NOT_TRACK = '1';\n      expect(isTelemetryEnabled()).toBe(false);\n    });\n\n    it('should return false when CI=true', () => {\n      process.env.CI = 'true';\n      expect(isTelemetryEnabled()).toBe(false);\n    });\n\n    it('should return true when no opt-out is set', () => {\n      delete process.env.OPENSPEC_TELEMETRY;\n      delete process.env.DO_NOT_TRACK;\n      delete process.env.CI;\n      expect(isTelemetryEnabled()).toBe(true);\n    });\n\n    it('should prioritize OPENSPEC_TELEMETRY=0 over other settings', () => {\n      process.env.OPENSPEC_TELEMETRY = '0';\n      delete process.env.DO_NOT_TRACK;\n      delete process.env.CI;\n      expect(isTelemetryEnabled()).toBe(false);\n    });\n  });\n\n  describe('maybeShowTelemetryNotice', () => {\n    it('should not show notice when telemetry is disabled', async () => {\n      process.env.OPENSPEC_TELEMETRY = '0';\n\n      await maybeShowTelemetryNotice();\n\n      expect(consoleLogSpy).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('trackCommand', () => {\n    it('should not track when telemetry is disabled', async () => {\n      process.env.OPENSPEC_TELEMETRY = '0';\n\n      await trackCommand('test', '1.0.0');\n\n      expect(PostHog).not.toHaveBeenCalled();\n    });\n\n    it('should track when telemetry is enabled', async () => {\n      delete process.env.OPENSPEC_TELEMETRY;\n      delete process.env.DO_NOT_TRACK;\n      delete process.env.CI;\n\n      await trackCommand('test', '1.0.0');\n\n      expect(PostHog).toHaveBeenCalled();\n    });\n  });\n\n  describe('shutdown', () => {\n    it('should not throw when no client exists', async () => {\n      await expect(shutdown()).resolves.not.toThrow();\n    });\n\n    it('should handle shutdown errors silently', async () => {\n      const mockPostHog = {\n        capture: vi.fn(),\n        shutdown: vi.fn().mockRejectedValue(new Error('Network error')),\n      };\n      (PostHog as any).mockImplementation(() => mockPostHog);\n\n      await expect(shutdown()).resolves.not.toThrow();\n    });\n  });\n});\n"
  },
  {
    "path": "test/utils/change-metadata.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\nimport {\n  writeChangeMetadata,\n  readChangeMetadata,\n  resolveSchemaForChange,\n  validateSchemaName,\n  ChangeMetadataError,\n} from '../../src/utils/change-metadata.js';\nimport { ChangeMetadataSchema } from '../../src/core/artifact-graph/types.js';\n\ndescribe('ChangeMetadataSchema', () => {\n  describe('valid metadata', () => {\n    it('should accept valid schema with created date', () => {\n      const result = ChangeMetadataSchema.safeParse({\n        schema: 'spec-driven',\n        created: '2025-01-05',\n      });\n      expect(result.success).toBe(true);\n      if (result.success) {\n        expect(result.data.schema).toBe('spec-driven');\n        expect(result.data.created).toBe('2025-01-05');\n      }\n    });\n\n    it('should accept valid schema without created date', () => {\n      const result = ChangeMetadataSchema.safeParse({\n        schema: 'custom-schema',\n      });\n      expect(result.success).toBe(true);\n      if (result.success) {\n        expect(result.data.schema).toBe('custom-schema');\n        expect(result.data.created).toBeUndefined();\n      }\n    });\n  });\n\n  describe('invalid metadata', () => {\n    it('should reject empty schema', () => {\n      const result = ChangeMetadataSchema.safeParse({\n        schema: '',\n      });\n      expect(result.success).toBe(false);\n    });\n\n    it('should reject missing schema', () => {\n      const result = ChangeMetadataSchema.safeParse({\n        created: '2025-01-05',\n      });\n      expect(result.success).toBe(false);\n    });\n\n    it('should reject invalid date format', () => {\n      const result = ChangeMetadataSchema.safeParse({\n        schema: 'spec-driven',\n        created: '01/05/2025', // Wrong format\n      });\n      expect(result.success).toBe(false);\n    });\n\n    it('should reject non-ISO date format', () => {\n      const result = ChangeMetadataSchema.safeParse({\n        schema: 'spec-driven',\n        created: '2025-1-5', // Missing leading zeros\n      });\n      expect(result.success).toBe(false);\n    });\n  });\n});\n\ndescribe('writeChangeMetadata', () => {\n  let testDir: string;\n  let changeDir: string;\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);\n    changeDir = path.join(testDir, 'openspec', 'changes', 'test-change');\n    await fs.mkdir(changeDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  it('should write valid YAML metadata file', async () => {\n    writeChangeMetadata(changeDir, {\n      schema: 'spec-driven',\n      created: '2025-01-05',\n    });\n\n    const metaPath = path.join(changeDir, '.openspec.yaml');\n    const content = await fs.readFile(metaPath, 'utf-8');\n\n    expect(content).toContain('schema: spec-driven');\n    expect(content).toContain('created: 2025-01-05');\n  });\n\n  it('should throw error for unknown schema', () => {\n    expect(() =>\n      writeChangeMetadata(changeDir, {\n        schema: 'unknown-schema',\n        created: '2025-01-05',\n      })\n    ).toThrow(/Unknown schema 'unknown-schema'/);\n  });\n});\n\ndescribe('readChangeMetadata', () => {\n  let testDir: string;\n  let changeDir: string;\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);\n    changeDir = path.join(testDir, 'openspec', 'changes', 'test-change');\n    await fs.mkdir(changeDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  it('should return null when no metadata file exists', () => {\n    const result = readChangeMetadata(changeDir);\n    expect(result).toBeNull();\n  });\n\n  it('should read valid metadata', async () => {\n    const metaPath = path.join(changeDir, '.openspec.yaml');\n    await fs.writeFile(\n      metaPath,\n      'schema: spec-driven\\ncreated: \"2025-01-05\"\\n',\n      'utf-8'\n    );\n\n    const result = readChangeMetadata(changeDir);\n    expect(result).toEqual({\n      schema: 'spec-driven',\n      created: '2025-01-05',\n    });\n  });\n\n  it('should throw ChangeMetadataError for invalid YAML', async () => {\n    const metaPath = path.join(changeDir, '.openspec.yaml');\n    await fs.writeFile(metaPath, '{ invalid yaml', 'utf-8');\n\n    expect(() => readChangeMetadata(changeDir)).toThrow(ChangeMetadataError);\n  });\n\n  it('should throw ChangeMetadataError for missing schema field', async () => {\n    const metaPath = path.join(changeDir, '.openspec.yaml');\n    await fs.writeFile(metaPath, 'created: \"2025-01-05\"\\n', 'utf-8');\n\n    expect(() => readChangeMetadata(changeDir)).toThrow(ChangeMetadataError);\n  });\n\n  it('should throw ChangeMetadataError for unknown schema', async () => {\n    const metaPath = path.join(changeDir, '.openspec.yaml');\n    await fs.writeFile(metaPath, 'schema: unknown-schema\\n', 'utf-8');\n\n    expect(() => readChangeMetadata(changeDir)).toThrow(/Unknown schema/);\n  });\n});\n\ndescribe('resolveSchemaForChange', () => {\n  let testDir: string;\n  let changeDir: string;\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);\n    changeDir = path.join(testDir, 'openspec', 'changes', 'test-change');\n    await fs.mkdir(changeDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  it('should return explicit schema when provided', async () => {\n    // Even with metadata file, explicit schema wins\n    const metaPath = path.join(changeDir, '.openspec.yaml');\n    await fs.writeFile(metaPath, 'schema: spec-driven\\n', 'utf-8');\n\n    const result = resolveSchemaForChange(changeDir, 'custom-schema');\n    expect(result).toBe('custom-schema');\n  });\n\n  it('should return schema from metadata when no explicit schema', async () => {\n    const metaPath = path.join(changeDir, '.openspec.yaml');\n    await fs.writeFile(metaPath, 'schema: spec-driven\\n', 'utf-8');\n\n    const result = resolveSchemaForChange(changeDir);\n    expect(result).toBe('spec-driven');\n  });\n\n  it('should return default when no metadata and no explicit schema', () => {\n    const result = resolveSchemaForChange(changeDir);\n    expect(result).toBe('spec-driven');\n  });\n\n  it('should return default when metadata read fails', async () => {\n    // Create an invalid metadata file\n    const metaPath = path.join(changeDir, '.openspec.yaml');\n    await fs.writeFile(metaPath, '{ invalid yaml', 'utf-8');\n\n    // Should fall back to default, not throw\n    const result = resolveSchemaForChange(changeDir);\n    expect(result).toBe('spec-driven');\n  });\n\n  it('should use project config schema when no metadata exists', async () => {\n    // Create project config\n    const configDir = path.join(testDir, 'openspec');\n    await fs.mkdir(configDir, { recursive: true });\n    await fs.writeFile(\n      path.join(configDir, 'config.yaml'),\n      'schema: custom-schema\\n',\n      'utf-8'\n    );\n\n    const result = resolveSchemaForChange(changeDir);\n    expect(result).toBe('custom-schema');\n  });\n\n  it('should prefer change metadata over project config', async () => {\n    // Create project config\n    const configDir = path.join(testDir, 'openspec');\n    await fs.mkdir(configDir, { recursive: true });\n    await fs.writeFile(\n      path.join(configDir, 'config.yaml'),\n      'schema: custom-schema\\n',\n      'utf-8'\n    );\n\n    // Create change metadata with different schema\n    const metaPath = path.join(changeDir, '.openspec.yaml');\n    await fs.writeFile(metaPath, 'schema: spec-driven\\n', 'utf-8');\n\n    const result = resolveSchemaForChange(changeDir);\n    expect(result).toBe('spec-driven'); // Change metadata wins\n  });\n\n  it('should prefer explicit schema over all config sources', async () => {\n    // Create project config\n    const configDir = path.join(testDir, 'openspec');\n    await fs.mkdir(configDir, { recursive: true });\n    await fs.writeFile(\n      path.join(configDir, 'config.yaml'),\n      'schema: custom-schema\\n',\n      'utf-8'\n    );\n\n    // Create change metadata\n    const metaPath = path.join(changeDir, '.openspec.yaml');\n    await fs.writeFile(metaPath, 'schema: spec-driven\\n', 'utf-8');\n\n    // Explicit schema should win\n    const result = resolveSchemaForChange(changeDir, 'custom-schema');\n    expect(result).toBe('custom-schema');\n  });\n\n  it('should test full precedence order: CLI > metadata > config > default', async () => {\n    // Setup all levels\n    const configDir = path.join(testDir, 'openspec');\n    await fs.mkdir(configDir, { recursive: true });\n    await fs.writeFile(\n      path.join(configDir, 'config.yaml'),\n      'schema: custom-schema\\n',\n      'utf-8'\n    );\n\n    const metaPath = path.join(changeDir, '.openspec.yaml');\n    await fs.writeFile(metaPath, 'schema: spec-driven\\n', 'utf-8');\n\n    // Test each level\n    expect(resolveSchemaForChange(changeDir, 'custom-schema')).toBe('custom-schema'); // CLI wins\n    expect(resolveSchemaForChange(changeDir)).toBe('spec-driven'); // Metadata wins when no CLI\n\n    // Remove metadata, config should win\n    await fs.unlink(metaPath);\n    expect(resolveSchemaForChange(changeDir)).toBe('custom-schema'); // Config wins\n\n    // Remove config, default should win\n    await fs.unlink(path.join(configDir, 'config.yaml'));\n    expect(resolveSchemaForChange(changeDir)).toBe('spec-driven'); // Default wins\n  });\n});\n\ndescribe('validateSchemaName', () => {\n  it('should accept valid schema name', () => {\n    expect(() => validateSchemaName('spec-driven')).not.toThrow();\n  });\n\n  it('should throw for unknown schema', () => {\n    expect(() => validateSchemaName('unknown-schema')).toThrow(\n      /Unknown schema 'unknown-schema'/\n    );\n  });\n});\n"
  },
  {
    "path": "test/utils/change-utils.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\nimport { validateChangeName, createChange } from '../../src/utils/change-utils.js';\n\ndescribe('validateChangeName', () => {\n  describe('valid names', () => {\n    it('should accept simple kebab-case name', () => {\n      const result = validateChangeName('add-auth');\n      expect(result).toEqual({ valid: true });\n    });\n\n    it('should accept name with multiple segments', () => {\n      const result = validateChangeName('add-user-auth');\n      expect(result).toEqual({ valid: true });\n    });\n\n    it('should accept name with numeric suffix', () => {\n      const result = validateChangeName('add-feature-2');\n      expect(result).toEqual({ valid: true });\n    });\n\n    it('should accept single word name', () => {\n      const result = validateChangeName('refactor');\n      expect(result).toEqual({ valid: true });\n    });\n\n    it('should accept name with numbers in segments', () => {\n      const result = validateChangeName('upgrade-to-v2');\n      expect(result).toEqual({ valid: true });\n    });\n  });\n\n  describe('invalid names - uppercase rejected', () => {\n    it('should reject name with uppercase letters', () => {\n      const result = validateChangeName('Add-Auth');\n      expect(result.valid).toBe(false);\n      expect(result.error).toContain('lowercase');\n    });\n\n    it('should reject fully uppercase name', () => {\n      const result = validateChangeName('ADD-AUTH');\n      expect(result.valid).toBe(false);\n      expect(result.error).toContain('lowercase');\n    });\n  });\n\n  describe('invalid names - spaces rejected', () => {\n    it('should reject name with spaces', () => {\n      const result = validateChangeName('add auth');\n      expect(result.valid).toBe(false);\n      expect(result.error).toContain('spaces');\n    });\n  });\n\n  describe('invalid names - underscores rejected', () => {\n    it('should reject name with underscores', () => {\n      const result = validateChangeName('add_auth');\n      expect(result.valid).toBe(false);\n      expect(result.error).toContain('underscores');\n    });\n  });\n\n  describe('invalid names - special characters rejected', () => {\n    it('should reject name with exclamation mark', () => {\n      const result = validateChangeName('add-auth!');\n      expect(result.valid).toBe(false);\n      expect(result.error).toBeDefined();\n    });\n\n    it('should reject name with @ symbol', () => {\n      const result = validateChangeName('add@auth');\n      expect(result.valid).toBe(false);\n      expect(result.error).toBeDefined();\n    });\n  });\n\n  describe('invalid names - leading/trailing hyphens rejected', () => {\n    it('should reject name with leading hyphen', () => {\n      const result = validateChangeName('-add-auth');\n      expect(result.valid).toBe(false);\n      expect(result.error).toContain('start with a hyphen');\n    });\n\n    it('should reject name with trailing hyphen', () => {\n      const result = validateChangeName('add-auth-');\n      expect(result.valid).toBe(false);\n      expect(result.error).toContain('end with a hyphen');\n    });\n  });\n\n  describe('invalid names - consecutive hyphens rejected', () => {\n    it('should reject name with double hyphens', () => {\n      const result = validateChangeName('add--auth');\n      expect(result.valid).toBe(false);\n      expect(result.error).toContain('consecutive hyphens');\n    });\n  });\n\n  describe('invalid names - empty name rejected', () => {\n    it('should reject empty string', () => {\n      const result = validateChangeName('');\n      expect(result.valid).toBe(false);\n      expect(result.error).toContain('empty');\n    });\n  });\n});\n\ndescribe('createChange', () => {\n  let testDir: string;\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);\n    await fs.mkdir(testDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('creates directory', () => {\n    it('should create change directory', async () => {\n      await createChange(testDir, 'add-auth');\n\n      const changeDir = path.join(testDir, 'openspec', 'changes', 'add-auth');\n      const stats = await fs.stat(changeDir);\n      expect(stats.isDirectory()).toBe(true);\n    });\n\n    it('should create .openspec.yaml metadata file with default schema', async () => {\n      await createChange(testDir, 'add-auth');\n\n      const metaPath = path.join(testDir, 'openspec', 'changes', 'add-auth', '.openspec.yaml');\n      const content = await fs.readFile(metaPath, 'utf-8');\n      expect(content).toContain('schema: spec-driven');\n      expect(content).toMatch(/created: \\d{4}-\\d{2}-\\d{2}/);\n    });\n\n    it('should create .openspec.yaml with custom schema', async () => {\n      await createChange(testDir, 'add-auth', { schema: 'spec-driven' });\n\n      const metaPath = path.join(testDir, 'openspec', 'changes', 'add-auth', '.openspec.yaml');\n      const content = await fs.readFile(metaPath, 'utf-8');\n      expect(content).toContain('schema: spec-driven');\n    });\n  });\n\n  describe('schema validation', () => {\n    it('should throw error for unknown schema', async () => {\n      await expect(createChange(testDir, 'add-auth', { schema: 'unknown-schema' })).rejects.toThrow(\n        /Unknown schema/\n      );\n    });\n  });\n\n  describe('duplicate change throws error', () => {\n    it('should throw error if change already exists', async () => {\n      await createChange(testDir, 'add-auth');\n\n      await expect(createChange(testDir, 'add-auth')).rejects.toThrow(\n        /already exists/\n      );\n    });\n  });\n\n  describe('invalid name throws validation error', () => {\n    it('should throw error for uppercase name', async () => {\n      await expect(createChange(testDir, 'Add-Auth')).rejects.toThrow(\n        /lowercase/\n      );\n    });\n\n    it('should throw error for name with spaces', async () => {\n      await expect(createChange(testDir, 'add auth')).rejects.toThrow(\n        /spaces/\n      );\n    });\n\n    it('should throw error for empty name', async () => {\n      await expect(createChange(testDir, '')).rejects.toThrow(\n        /empty/\n      );\n    });\n  });\n\n  describe('creates parent directories if needed', () => {\n    it('should create openspec/changes/ directories if they do not exist', async () => {\n      const newProjectDir = path.join(testDir, 'new-project');\n      await fs.mkdir(newProjectDir);\n\n      // openspec/changes/ does not exist yet\n      await createChange(newProjectDir, 'add-auth');\n\n      const changeDir = path.join(newProjectDir, 'openspec', 'changes', 'add-auth');\n      const stats = await fs.stat(changeDir);\n      expect(stats.isDirectory()).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "test/utils/command-references.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { transformToHyphenCommands } from '../../src/utils/command-references.js';\n\ndescribe('transformToHyphenCommands', () => {\n  describe('basic transformations', () => {\n    it('should transform single command reference', () => {\n      expect(transformToHyphenCommands('/opsx:new')).toBe('/opsx-new');\n    });\n\n    it('should transform multiple command references', () => {\n      const input = '/opsx:new and /opsx:apply';\n      const expected = '/opsx-new and /opsx-apply';\n      expect(transformToHyphenCommands(input)).toBe(expected);\n    });\n\n    it('should transform command reference in context', () => {\n      const input = 'Use /opsx:apply to implement tasks';\n      const expected = 'Use /opsx-apply to implement tasks';\n      expect(transformToHyphenCommands(input)).toBe(expected);\n    });\n\n    it('should handle backtick-quoted commands', () => {\n      const input = 'Run `/opsx:continue` to proceed';\n      const expected = 'Run `/opsx-continue` to proceed';\n      expect(transformToHyphenCommands(input)).toBe(expected);\n    });\n  });\n\n  describe('edge cases', () => {\n    it('should return unchanged text with no command references', () => {\n      const input = 'This is plain text without commands';\n      expect(transformToHyphenCommands(input)).toBe(input);\n    });\n\n    it('should return empty string unchanged', () => {\n      expect(transformToHyphenCommands('')).toBe('');\n    });\n\n    it('should not transform similar but non-matching patterns', () => {\n      const input = '/ops:new opsx: /other:command';\n      expect(transformToHyphenCommands(input)).toBe(input);\n    });\n\n    it('should handle multiple occurrences on same line', () => {\n      const input = '/opsx:new /opsx:continue /opsx:apply';\n      const expected = '/opsx-new /opsx-continue /opsx-apply';\n      expect(transformToHyphenCommands(input)).toBe(expected);\n    });\n  });\n\n  describe('multiline content', () => {\n    it('should transform references across multiple lines', () => {\n      const input = `Use /opsx:new to start\nThen /opsx:continue to proceed\nFinally /opsx:apply to implement`;\n      const expected = `Use /opsx-new to start\nThen /opsx-continue to proceed\nFinally /opsx-apply to implement`;\n      expect(transformToHyphenCommands(input)).toBe(expected);\n    });\n  });\n\n  describe('all known commands', () => {\n    const commands = [\n      'new',\n      'continue',\n      'apply',\n      'ff',\n      'sync',\n      'archive',\n      'bulk-archive',\n      'verify',\n      'explore',\n      'onboard',\n    ];\n\n    for (const cmd of commands) {\n      it(`should transform /opsx:${cmd}`, () => {\n        expect(transformToHyphenCommands(`/opsx:${cmd}`)).toBe(`/opsx-${cmd}`);\n      });\n    }\n  });\n});\n"
  },
  {
    "path": "test/utils/file-system.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { randomUUID } from 'crypto';\nimport { FileSystemUtils } from '../../src/utils/file-system.js';\n\ndescribe('FileSystemUtils', () => {\n  let testDir: string;\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);\n    await fs.mkdir(testDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('createDirectory', () => {\n    it('should create a directory', async () => {\n      const dirPath = path.join(testDir, 'new-dir');\n      await FileSystemUtils.createDirectory(dirPath);\n      \n      const stats = await fs.stat(dirPath);\n      expect(stats.isDirectory()).toBe(true);\n    });\n\n    it('should create nested directories', async () => {\n      const dirPath = path.join(testDir, 'nested', 'deep', 'dir');\n      await FileSystemUtils.createDirectory(dirPath);\n      \n      const stats = await fs.stat(dirPath);\n      expect(stats.isDirectory()).toBe(true);\n    });\n\n    it('should not throw if directory already exists', async () => {\n      const dirPath = path.join(testDir, 'existing-dir');\n      await fs.mkdir(dirPath);\n      \n      await expect(FileSystemUtils.createDirectory(dirPath)).resolves.not.toThrow();\n    });\n  });\n\n  describe('fileExists', () => {\n    it('should return true for existing file', async () => {\n      const filePath = path.join(testDir, 'test.txt');\n      await fs.writeFile(filePath, 'test content');\n      \n      const exists = await FileSystemUtils.fileExists(filePath);\n      expect(exists).toBe(true);\n    });\n\n    it('should return false for non-existing file', async () => {\n      const filePath = path.join(testDir, 'non-existent.txt');\n      \n      const exists = await FileSystemUtils.fileExists(filePath);\n      expect(exists).toBe(false);\n    });\n\n    it('should return false for directory path', async () => {\n      const dirPath = path.join(testDir, 'dir');\n      await fs.mkdir(dirPath);\n      \n      const exists = await FileSystemUtils.fileExists(dirPath);\n      expect(exists).toBe(true); // fs.access doesn't distinguish between files and directories\n    });\n  });\n\n  describe('directoryExists', () => {\n    it('should return true for existing directory', async () => {\n      const dirPath = path.join(testDir, 'test-dir');\n      await fs.mkdir(dirPath);\n      \n      const exists = await FileSystemUtils.directoryExists(dirPath);\n      expect(exists).toBe(true);\n    });\n\n    it('should return false for non-existing directory', async () => {\n      const dirPath = path.join(testDir, 'non-existent-dir');\n      \n      const exists = await FileSystemUtils.directoryExists(dirPath);\n      expect(exists).toBe(false);\n    });\n\n    it('should return false for file path', async () => {\n      const filePath = path.join(testDir, 'file.txt');\n      await fs.writeFile(filePath, 'content');\n      \n      const exists = await FileSystemUtils.directoryExists(filePath);\n      expect(exists).toBe(false);\n    });\n  });\n\n  describe('writeFile', () => {\n    it('should write content to file', async () => {\n      const filePath = path.join(testDir, 'output.txt');\n      const content = 'Hello, World!';\n      \n      await FileSystemUtils.writeFile(filePath, content);\n      \n      const readContent = await fs.readFile(filePath, 'utf-8');\n      expect(readContent).toBe(content);\n    });\n\n    it('should create directory if it does not exist', async () => {\n      const filePath = path.join(testDir, 'nested', 'dir', 'output.txt');\n      const content = 'Nested content';\n      \n      await FileSystemUtils.writeFile(filePath, content);\n      \n      const readContent = await fs.readFile(filePath, 'utf-8');\n      expect(readContent).toBe(content);\n    });\n\n    it('should overwrite existing file', async () => {\n      const filePath = path.join(testDir, 'existing.txt');\n      await fs.writeFile(filePath, 'old content');\n      \n      const newContent = 'new content';\n      await FileSystemUtils.writeFile(filePath, newContent);\n      \n      const readContent = await fs.readFile(filePath, 'utf-8');\n      expect(readContent).toBe(newContent);\n    });\n  });\n\n  describe('readFile', () => {\n    it('should read file content', async () => {\n      const filePath = path.join(testDir, 'input.txt');\n      const content = 'Test content';\n      await fs.writeFile(filePath, content);\n      \n      const readContent = await FileSystemUtils.readFile(filePath);\n      expect(readContent).toBe(content);\n    });\n\n    it('should throw for non-existing file', async () => {\n      const filePath = path.join(testDir, 'non-existent.txt');\n      \n      await expect(FileSystemUtils.readFile(filePath)).rejects.toThrow();\n    });\n  });\n\n  describe('ensureWritePermissions', () => {\n    it('should return true for writable directory', async () => {\n      const hasPermission = await FileSystemUtils.ensureWritePermissions(testDir);\n      expect(hasPermission).toBe(true);\n    });\n\n    it('should return true for non-existing directory with writable parent', async () => {\n      const dirPath = path.join(testDir, 'new-dir');\n      const hasPermission = await FileSystemUtils.ensureWritePermissions(dirPath);\n      expect(hasPermission).toBe(true);\n    });\n\n    it('should handle deeply nested non-existing directories', async () => {\n      const dirPath = path.join(testDir, 'a', 'b', 'c', 'd');\n      const hasPermission = await FileSystemUtils.ensureWritePermissions(dirPath);\n      expect(hasPermission).toBe(true);\n    });\n  });\n\n  describe('canWriteFile', () => {\n    it('should return true for existing writable file', async () => {\n      const filePath = path.join(testDir, 'writable.txt');\n      await fs.writeFile(filePath, 'content');\n\n      const canWrite = await FileSystemUtils.canWriteFile(filePath);\n      expect(canWrite).toBe(true);\n    });\n\n    it('should return false for existing read-only file', async () => {\n      const filePath = path.join(testDir, 'readonly.txt');\n      await fs.writeFile(filePath, 'content');\n      await fs.chmod(filePath, 0o444); // Read-only\n\n      const canWrite = await FileSystemUtils.canWriteFile(filePath);\n      expect(canWrite).toBe(false);\n\n      // Cleanup: restore permissions so afterEach can delete\n      await fs.chmod(filePath, 0o644);\n    });\n\n    it('should return true for non-existent file in writable directory', async () => {\n      const filePath = path.join(testDir, 'new-file.txt');\n\n      const canWrite = await FileSystemUtils.canWriteFile(filePath);\n      expect(canWrite).toBe(true);\n    });\n\n    it('should return true for non-existent file in non-existent nested directories', async () => {\n      const filePath = path.join(testDir, 'deep', 'nested', 'path', 'file.txt');\n\n      const canWrite = await FileSystemUtils.canWriteFile(filePath);\n      expect(canWrite).toBe(true);\n    });\n\n    // Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows\n    // Windows uses ACLs which Node.js chmod doesn't control\n    it.skipIf(process.platform === 'win32')('should return false for non-existent file in read-only directory', async () => {\n      const readOnlyDir = path.join(testDir, 'readonly-dir');\n      await fs.mkdir(readOnlyDir);\n      await fs.chmod(readOnlyDir, 0o555); // Read-only + execute\n\n      const filePath = path.join(readOnlyDir, 'file.txt');\n      const canWrite = await FileSystemUtils.canWriteFile(filePath);\n      expect(canWrite).toBe(false);\n\n      // Cleanup\n      await fs.chmod(readOnlyDir, 0o755);\n    });\n\n    it('should return true when path points to existing directory', async () => {\n      const dirPath = path.join(testDir, 'some-dir');\n      await fs.mkdir(dirPath);\n\n      const canWrite = await FileSystemUtils.canWriteFile(dirPath);\n      expect(canWrite).toBe(true);\n    });\n\n    it('should traverse multiple non-existent parent directories', async () => {\n      const filePath = path.join(testDir, 'a', 'b', 'c', 'd', 'e', 'file.txt');\n\n      const canWrite = await FileSystemUtils.canWriteFile(filePath);\n      expect(canWrite).toBe(true);\n    });\n\n    it('should return false when intermediate path component is a file', async () => {\n      // Create a file where a directory should be\n      const fileInPath = path.join(testDir, 'blocking-file.txt');\n      await fs.writeFile(fileInPath, 'content');\n\n      // Try to check a path that goes \"through\" this file\n      const filePath = path.join(fileInPath, 'nested', 'file.txt');\n      const canWrite = await FileSystemUtils.canWriteFile(filePath);\n      expect(canWrite).toBe(false);\n    });\n\n    // Skip on Windows: creating symlinks requires elevated privileges or Developer Mode\n    it.skipIf(process.platform === 'win32')('should follow symbolic links to files', async () => {\n      const realFile = path.join(testDir, 'real-file.txt');\n      const linkFile = path.join(testDir, 'link-file.txt');\n      await fs.writeFile(realFile, 'content');\n      await fs.symlink(realFile, linkFile);\n\n      const canWrite = await FileSystemUtils.canWriteFile(linkFile);\n      expect(canWrite).toBe(true);\n    });\n\n    it('should handle platform-specific path separators', async () => {\n      const filePath = FileSystemUtils.joinPath(testDir, 'subdir', 'file.txt');\n      const canWrite = await FileSystemUtils.canWriteFile(filePath);\n      expect(canWrite).toBe(true);\n    });\n  });\n\n  describe('joinPath', () => {\n    it('should join POSIX-style paths', () => {\n      const result = FileSystemUtils.joinPath(\n        '/tmp/project',\n        '.claude/commands/openspec/proposal.md'\n      );\n      expect(result).toBe('/tmp/project/.claude/commands/openspec/proposal.md');\n    });\n\n    it('should join Linux home directory paths', () => {\n      const result = FileSystemUtils.joinPath(\n        '/home/dev/workspace/openspec',\n        '.cursor/commands/install.md'\n      );\n      expect(result).toBe('/home/dev/workspace/openspec/.cursor/commands/install.md');\n    });\n\n    it('should join Windows drive-letter paths with backslashes', () => {\n      const result = FileSystemUtils.joinPath(\n        'C:\\\\Users\\\\dev\\\\project',\n        '.claude/commands/openspec/proposal.md'\n      );\n      expect(result).toBe(\n        'C:\\\\Users\\\\dev\\\\project\\\\.claude\\\\commands\\\\openspec\\\\proposal.md'\n      );\n    });\n\n    it('should join Windows paths that use forward slashes', () => {\n      const result = FileSystemUtils.joinPath(\n        'D:/workspace/app',\n        '.cursor/commands/openspec-apply.md'\n      );\n      expect(result).toBe(\n        'D:\\\\workspace\\\\app\\\\.cursor\\\\commands\\\\openspec-apply.md'\n      );\n    });\n\n    it('should join UNC-style Windows paths', () => {\n      const result = FileSystemUtils.joinPath(\n        '\\\\server\\\\share\\\\repo',\n        '.windsurf/workflows/openspec-archive.md'\n      );\n      expect(result).toBe(\n        '\\\\server\\\\share\\\\repo\\\\.windsurf\\\\workflows\\\\openspec-archive.md'\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "test/utils/interactive.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { isInteractive, resolveNoInteractive, InteractiveOptions } from '../../src/utils/interactive.js';\n\ndescribe('interactive utilities', () => {\n  let originalOpenSpecInteractive: string | undefined;\n  let originalCI: string | undefined;\n  let originalStdinIsTTY: boolean | undefined;\n\n  beforeEach(() => {\n    // Save original environment\n    originalOpenSpecInteractive = process.env.OPEN_SPEC_INTERACTIVE;\n    originalCI = process.env.CI;\n    originalStdinIsTTY = process.stdin.isTTY;\n\n    // Clear environment for clean testing\n    delete process.env.OPEN_SPEC_INTERACTIVE;\n    delete process.env.CI;\n  });\n\n  afterEach(() => {\n    // Restore original environment\n    if (originalOpenSpecInteractive !== undefined) {\n      process.env.OPEN_SPEC_INTERACTIVE = originalOpenSpecInteractive;\n    } else {\n      delete process.env.OPEN_SPEC_INTERACTIVE;\n    }\n    if (originalCI !== undefined) {\n      process.env.CI = originalCI;\n    } else {\n      delete process.env.CI;\n    }\n    // Restore stdin.isTTY\n    Object.defineProperty(process.stdin, 'isTTY', {\n      value: originalStdinIsTTY,\n      writable: true,\n      configurable: true,\n    });\n  });\n\n  describe('resolveNoInteractive', () => {\n    it('should return true when noInteractive is true', () => {\n      expect(resolveNoInteractive({ noInteractive: true })).toBe(true);\n    });\n\n    it('should return true when interactive is false (Commander.js style)', () => {\n      // This is how Commander.js handles --no-interactive flag\n      expect(resolveNoInteractive({ interactive: false })).toBe(true);\n    });\n\n    it('should return false when noInteractive is false', () => {\n      expect(resolveNoInteractive({ noInteractive: false })).toBe(false);\n    });\n\n    it('should return false when interactive is true', () => {\n      expect(resolveNoInteractive({ interactive: true })).toBe(false);\n    });\n\n    it('should return false for empty options object', () => {\n      expect(resolveNoInteractive({})).toBe(false);\n    });\n\n    it('should return false for undefined', () => {\n      expect(resolveNoInteractive(undefined)).toBe(false);\n    });\n\n    it('should handle boolean value true', () => {\n      expect(resolveNoInteractive(true)).toBe(true);\n    });\n\n    it('should handle boolean value false', () => {\n      expect(resolveNoInteractive(false)).toBe(false);\n    });\n\n    it('should prioritize noInteractive over interactive when both set', () => {\n      // noInteractive: true should win\n      expect(resolveNoInteractive({ noInteractive: true, interactive: true })).toBe(true);\n      // If noInteractive is false, check interactive\n      expect(resolveNoInteractive({ noInteractive: false, interactive: false })).toBe(true);\n    });\n  });\n\n  describe('isInteractive', () => {\n    it('should return false when noInteractive is true', () => {\n      expect(isInteractive({ noInteractive: true })).toBe(false);\n    });\n\n    it('should return false when interactive is false (Commander.js --no-interactive)', () => {\n      expect(isInteractive({ interactive: false })).toBe(false);\n    });\n\n    it('should return false when OPEN_SPEC_INTERACTIVE env var is 0', () => {\n      process.env.OPEN_SPEC_INTERACTIVE = '0';\n      Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });\n      expect(isInteractive({})).toBe(false);\n    });\n\n    it('should return false when CI env var is set', () => {\n      process.env.CI = 'true';\n      Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });\n      expect(isInteractive({})).toBe(false);\n    });\n\n    it('should return false when CI env var is set to any value', () => {\n      // CI can be set to any value, not just \"true\"\n      process.env.CI = '1';\n      Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });\n      expect(isInteractive({})).toBe(false);\n    });\n\n    it('should return false when stdin is not a TTY', () => {\n      Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true, configurable: true });\n      expect(isInteractive({})).toBe(false);\n    });\n\n    it('should return true when stdin is TTY and no flags disable it', () => {\n      Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });\n      expect(isInteractive({})).toBe(true);\n    });\n\n    it('should return true when stdin is TTY and options are undefined', () => {\n      Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });\n      expect(isInteractive(undefined)).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "test/utils/marker-updates.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { FileSystemUtils, removeMarkerBlock } from '../../src/utils/file-system.js';\n\ndescribe('FileSystemUtils.updateFileWithMarkers', () => {\n  let testDir: string;\n  const START_MARKER = '<!-- OPENSPEC:START -->';\n  const END_MARKER = '<!-- OPENSPEC:END -->';\n\n  beforeEach(async () => {\n    testDir = path.join(os.tmpdir(), `openspec-marker-test-${Date.now()}`);\n    await fs.mkdir(testDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await fs.rm(testDir, { recursive: true, force: true });\n  });\n\n  describe('new file creation', () => {\n    it('should create new file with markers and content', async () => {\n      const filePath = path.join(testDir, 'new-file.md');\n      const content = 'OpenSpec content';\n      \n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        content,\n        START_MARKER,\n        END_MARKER\n      );\n      \n      const result = await fs.readFile(filePath, 'utf-8');\n      expect(result).toBe(`${START_MARKER}\\n${content}\\n${END_MARKER}`);\n    });\n  });\n\n  describe('existing file without markers', () => {\n    it('should prepend markers and content to existing file', async () => {\n      const filePath = path.join(testDir, 'existing.md');\n      const existingContent = '# Existing Content\\nUser content here';\n      await fs.writeFile(filePath, existingContent);\n      \n      const newContent = 'OpenSpec content';\n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        newContent,\n        START_MARKER,\n        END_MARKER\n      );\n      \n      const result = await fs.readFile(filePath, 'utf-8');\n      expect(result).toBe(\n        `${START_MARKER}\\n${newContent}\\n${END_MARKER}\\n\\n${existingContent}`\n      );\n    });\n  });\n\n  describe('existing file with markers', () => {\n    it('should replace content between markers', async () => {\n      const filePath = path.join(testDir, 'with-markers.md');\n      const beforeContent = '# Before\\nSome content before';\n      const oldManagedContent = 'Old OpenSpec content';\n      const afterContent = '# After\\nSome content after';\n      \n      const existingFile = `${beforeContent}\\n${START_MARKER}\\n${oldManagedContent}\\n${END_MARKER}\\n${afterContent}`;\n      await fs.writeFile(filePath, existingFile);\n      \n      const newContent = 'New OpenSpec content';\n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        newContent,\n        START_MARKER,\n        END_MARKER\n      );\n      \n      const result = await fs.readFile(filePath, 'utf-8');\n      expect(result).toBe(\n        `${beforeContent}\\n${START_MARKER}\\n${newContent}\\n${END_MARKER}\\n${afterContent}`\n      );\n    });\n\n    it('should preserve content before and after markers', async () => {\n      const filePath = path.join(testDir, 'preserve.md');\n      const userContentBefore = '# User Content Before\\nImportant user notes';\n      const userContentAfter = '## User Content After\\nMore user notes';\n      \n      const existingFile = `${userContentBefore}\\n${START_MARKER}\\nOld content\\n${END_MARKER}\\n${userContentAfter}`;\n      await fs.writeFile(filePath, existingFile);\n      \n      const newContent = 'Updated content';\n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        newContent,\n        START_MARKER,\n        END_MARKER\n      );\n      \n      const result = await fs.readFile(filePath, 'utf-8');\n      expect(result).toContain(userContentBefore);\n      expect(result).toContain(userContentAfter);\n      expect(result).toContain(newContent);\n      expect(result).not.toContain('Old content');\n    });\n\n    it('should handle markers at the beginning of file', async () => {\n      const filePath = path.join(testDir, 'markers-at-start.md');\n      const afterContent = 'User content after markers';\n      \n      const existingFile = `${START_MARKER}\\nOld content\\n${END_MARKER}\\n${afterContent}`;\n      await fs.writeFile(filePath, existingFile);\n      \n      const newContent = 'New content';\n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        newContent,\n        START_MARKER,\n        END_MARKER\n      );\n      \n      const result = await fs.readFile(filePath, 'utf-8');\n      expect(result).toBe(`${START_MARKER}\\n${newContent}\\n${END_MARKER}\\n${afterContent}`);\n    });\n\n    it('should handle markers at the end of file', async () => {\n      const filePath = path.join(testDir, 'markers-at-end.md');\n      const beforeContent = 'User content before markers';\n      \n      const existingFile = `${beforeContent}\\n${START_MARKER}\\nOld content\\n${END_MARKER}`;\n      await fs.writeFile(filePath, existingFile);\n      \n      const newContent = 'New content';\n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        newContent,\n        START_MARKER,\n        END_MARKER\n      );\n      \n      const result = await fs.readFile(filePath, 'utf-8');\n      expect(result).toBe(`${beforeContent}\\n${START_MARKER}\\n${newContent}\\n${END_MARKER}`);\n    });\n  });\n\n  describe('invalid marker states', () => {\n    it('should throw error if only start marker exists', async () => {\n      const filePath = path.join(testDir, 'invalid-start.md');\n      const existingFile = `Some content\\n${START_MARKER}\\nManaged content\\nNo end marker`;\n      await fs.writeFile(filePath, existingFile);\n      \n      await expect(\n        FileSystemUtils.updateFileWithMarkers(\n          filePath,\n          'New content',\n          START_MARKER,\n          END_MARKER\n        )\n      ).rejects.toThrow(/Invalid marker state/);\n    });\n\n    it('should throw error if only end marker exists', async () => {\n      const filePath = path.join(testDir, 'invalid-end.md');\n      const existingFile = `Some content\\nNo start marker\\nManaged content\\n${END_MARKER}`;\n      await fs.writeFile(filePath, existingFile);\n      \n      await expect(\n        FileSystemUtils.updateFileWithMarkers(\n          filePath,\n          'New content',\n          START_MARKER,\n          END_MARKER\n        )\n      ).rejects.toThrow(/Invalid marker state/);\n    });\n  });\n\n  describe('idempotency', () => {\n    it('should produce same result when called multiple times with same content', async () => {\n      const filePath = path.join(testDir, 'idempotent.md');\n      const content = 'Consistent content';\n      \n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        content,\n        START_MARKER,\n        END_MARKER\n      );\n      \n      const firstResult = await fs.readFile(filePath, 'utf-8');\n      \n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        content,\n        START_MARKER,\n        END_MARKER\n      );\n      \n      const secondResult = await fs.readFile(filePath, 'utf-8');\n      expect(secondResult).toBe(firstResult);\n    });\n  });\n\n  describe('edge cases', () => {\n    it('should handle empty content', async () => {\n      const filePath = path.join(testDir, 'empty-content.md');\n      \n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        '',\n        START_MARKER,\n        END_MARKER\n      );\n      \n      const result = await fs.readFile(filePath, 'utf-8');\n      expect(result).toBe(`${START_MARKER}\\n\\n${END_MARKER}`);\n    });\n\n    it('should handle content with special characters', async () => {\n      const filePath = path.join(testDir, 'special-chars.md');\n      const content = '# Special chars: ${}[]()<>|\\\\`*_~';\n      \n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        content,\n        START_MARKER,\n        END_MARKER\n      );\n      \n      const result = await fs.readFile(filePath, 'utf-8');\n      expect(result).toContain(content);\n    });\n\n    it('should handle multi-line content', async () => {\n      const filePath = path.join(testDir, 'multi-line.md');\n      const content = `Line 1\nLine 2\nLine 3\n\nLine 5 with gap`;\n      \n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        content,\n        START_MARKER,\n        END_MARKER\n      );\n      \n      const result = await fs.readFile(filePath, 'utf-8');\n      expect(result).toContain(content);\n    });\n\n    it('should ignore inline mentions of markers when updating content', async () => {\n      const filePath = path.join(testDir, 'inline-mentions.md');\n      const existingFile = `Intro referencing markers like ${START_MARKER} and ${END_MARKER} inside text.\n\n${START_MARKER}\nOriginal content\n${END_MARKER}\n`;\n\n      await fs.writeFile(filePath, existingFile);\n\n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        'Updated content',\n        START_MARKER,\n        END_MARKER\n      );\n\n      const firstResult = await fs.readFile(filePath, 'utf-8');\n      expect(firstResult).toContain('Intro referencing markers like');\n      expect(firstResult).toContain('Updated content');\n      expect(firstResult.match(new RegExp(START_MARKER, 'g'))?.length).toBe(2);\n      expect(firstResult.match(new RegExp(END_MARKER, 'g'))?.length).toBe(2);\n\n      await FileSystemUtils.updateFileWithMarkers(\n        filePath,\n        'Updated content',\n        START_MARKER,\n        END_MARKER\n      );\n\n      const secondResult = await fs.readFile(filePath, 'utf-8');\n      expect(secondResult).toBe(firstResult);\n    });\n  });\n});\n\ndescribe('removeMarkerBlock', () => {\n  const START_MARKER = '<!-- OPENSPEC:START -->';\n  const END_MARKER = '<!-- OPENSPEC:END -->';\n\n  describe('basic removal', () => {\n    it('should remove marker block and preserve content before', () => {\n      const content = `User content before\n${START_MARKER}\nOpenSpec content\n${END_MARKER}`;\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      expect(result).toBe('User content before\\n');\n      expect(result).not.toContain(START_MARKER);\n      expect(result).not.toContain(END_MARKER);\n    });\n\n    it('should remove marker block and preserve content after', () => {\n      const content = `${START_MARKER}\nOpenSpec content\n${END_MARKER}\nUser content after`;\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      expect(result).toBe('User content after\\n');\n    });\n\n    it('should remove marker block and preserve content before and after', () => {\n      const content = `User content before\n${START_MARKER}\nOpenSpec content\n${END_MARKER}\nUser content after`;\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      expect(result).toContain('User content before');\n      expect(result).toContain('User content after');\n      expect(result).not.toContain(START_MARKER);\n    });\n\n    it('should return empty string when only markers remain', () => {\n      const content = `${START_MARKER}\nOpenSpec content\n${END_MARKER}`;\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      expect(result).toBe('');\n    });\n  });\n\n  describe('invalid states', () => {\n    it('should return original content when markers are missing', () => {\n      const content = 'Plain content without markers';\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      expect(result).toBe('Plain content without markers');\n    });\n\n    it('should return original content when only start marker exists', () => {\n      const content = `${START_MARKER}\nContent without end marker`;\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      expect(result).toContain(START_MARKER);\n    });\n\n    it('should return original content when only end marker exists', () => {\n      const content = `Content without start marker\n${END_MARKER}`;\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      expect(result).toContain(END_MARKER);\n    });\n\n    it('should return original content when markers are in wrong order', () => {\n      const content = `${END_MARKER}\nContent\n${START_MARKER}`;\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      expect(result).toContain(END_MARKER);\n      expect(result).toContain(START_MARKER);\n    });\n  });\n\n  describe('whitespace handling', () => {\n    it('should clean up double blank lines', () => {\n      const content = `Line 1\n\n\n${START_MARKER}\nOpenSpec content\n${END_MARKER}\n\n\nLine 2`;\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      expect(result).not.toMatch(/\\n{3,}/);\n    });\n\n    it('should handle markers with whitespace on same line', () => {\n      const content = `User content\n  ${START_MARKER}\nOpenSpec content\n  ${END_MARKER}\nMore content`;\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      expect(result).toContain('User content');\n      expect(result).toContain('More content');\n      expect(result).not.toContain(START_MARKER);\n    });\n  });\n\n  describe('inline marker mentions', () => {\n    it('should ignore inline mentions and only remove actual marker block', () => {\n      const content = `Intro referencing markers like ${START_MARKER} and ${END_MARKER} inside text.\n\n${START_MARKER}\nOriginal content\n${END_MARKER}\n`;\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      // Inline mentions should be preserved\n      expect(result).toContain('Intro referencing markers like');\n      expect(result).toContain(`${START_MARKER} and ${END_MARKER} inside text`);\n      // Original content between markers should be removed\n      expect(result).not.toContain('Original content');\n    });\n\n    it('should handle multiple inline mentions before actual block', () => {\n      const content = `The ${START_MARKER} marker starts a block.\nThe ${END_MARKER} marker ends it.\nHere is the actual block:\n${START_MARKER}\nManaged content\n${END_MARKER}\nAfter block content`;\n      const result = removeMarkerBlock(content, START_MARKER, END_MARKER);\n      expect(result).toContain(`The ${START_MARKER} marker starts a block`);\n      expect(result).toContain(`The ${END_MARKER} marker ends it`);\n      expect(result).toContain('After block content');\n      expect(result).not.toContain('Managed content');\n    });\n  });\n\n  describe('shell markers', () => {\n    const SHELL_START = '# OPENSPEC:START';\n    const SHELL_END = '# OPENSPEC:END';\n\n    it('should work with shell-style markers', () => {\n      const content = `# User config\nexport PATH=\"/usr/local/bin:$PATH\"\n\n${SHELL_START}\n# OpenSpec managed\nalias openspec=\"npx openspec\"\n${SHELL_END}\n\n# More user config\nexport EDITOR=\"vim\"`;\n      const result = removeMarkerBlock(content, SHELL_START, SHELL_END);\n      expect(result).toContain('export PATH');\n      expect(result).toContain('export EDITOR');\n      expect(result).not.toContain('alias openspec');\n      expect(result).not.toContain(SHELL_START);\n    });\n  });\n});\n"
  },
  {
    "path": "test/utils/shell-detection.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { detectShell, SupportedShell } from '../../src/utils/shell-detection.js';\n\ndescribe('shell-detection', () => {\n  let originalShell: string | undefined;\n  let originalPSModulePath: string | undefined;\n  let originalComspec: string | undefined;\n  let originalPlatform: NodeJS.Platform;\n\n  beforeEach(() => {\n    // Save original environment\n    originalShell = process.env.SHELL;\n    originalPSModulePath = process.env.PSModulePath;\n    originalComspec = process.env.COMSPEC;\n    originalPlatform = process.platform;\n\n    // Clear environment for clean testing\n    delete process.env.SHELL;\n    delete process.env.PSModulePath;\n    delete process.env.COMSPEC;\n  });\n\n  afterEach(() => {\n    // Restore original environment\n    if (originalShell !== undefined) {\n      process.env.SHELL = originalShell;\n    } else {\n      delete process.env.SHELL;\n    }\n    if (originalPSModulePath !== undefined) {\n      process.env.PSModulePath = originalPSModulePath;\n    } else {\n      delete process.env.PSModulePath;\n    }\n    if (originalComspec !== undefined) {\n      process.env.COMSPEC = originalComspec;\n    } else {\n      delete process.env.COMSPEC;\n    }\n    Object.defineProperty(process, 'platform', {\n      value: originalPlatform,\n    });\n  });\n\n  describe('detectShell', () => {\n    it('should detect zsh from SHELL environment variable', () => {\n      process.env.SHELL = '/bin/zsh';\n      const result = detectShell();\n      expect(result.shell).toBe('zsh');\n      expect(result.detected).toBe('zsh');\n    });\n\n    it('should detect zsh from various zsh paths', () => {\n      const zshPaths = [\n        '/usr/bin/zsh',\n        '/usr/local/bin/zsh',\n        '/opt/homebrew/bin/zsh',\n        '/home/user/.local/bin/zsh',\n      ];\n\n      for (const path of zshPaths) {\n        process.env.SHELL = path;\n        const result = detectShell();\n        expect(result.shell).toBe('zsh');\n        expect(result.detected).toBe('zsh');\n      }\n    });\n\n    it('should detect bash from SHELL environment variable', () => {\n      process.env.SHELL = '/bin/bash';\n      const result = detectShell();\n      expect(result.shell).toBe('bash');\n      expect(result.detected).toBe('bash');\n    });\n\n    it('should detect bash from various bash paths', () => {\n      const bashPaths = [\n        '/usr/bin/bash',\n        '/usr/local/bin/bash',\n        '/opt/homebrew/bin/bash',\n        '/home/user/.local/bin/bash',\n      ];\n\n      for (const path of bashPaths) {\n        process.env.SHELL = path;\n        const result = detectShell();\n        expect(result.shell).toBe('bash');\n        expect(result.detected).toBe('bash');\n      }\n    });\n\n    it('should detect fish from SHELL environment variable', () => {\n      process.env.SHELL = '/usr/bin/fish';\n      const result = detectShell();\n      expect(result.shell).toBe('fish');\n      expect(result.detected).toBe('fish');\n    });\n\n    it('should detect fish from various fish paths', () => {\n      const fishPaths = [\n        '/bin/fish',\n        '/usr/local/bin/fish',\n        '/opt/homebrew/bin/fish',\n        '/home/user/.local/bin/fish',\n      ];\n\n      for (const path of fishPaths) {\n        process.env.SHELL = path;\n        const result = detectShell();\n        expect(result.shell).toBe('fish');\n        expect(result.detected).toBe('fish');\n      }\n    });\n\n    it('should be case-insensitive when detecting shell', () => {\n      process.env.SHELL = '/BIN/ZSH';\n      let result = detectShell();\n      expect(result.shell).toBe('zsh');\n\n      process.env.SHELL = '/USR/BIN/BASH';\n      result = detectShell();\n      expect(result.shell).toBe('bash');\n\n      process.env.SHELL = '/USR/BIN/FISH';\n      result = detectShell();\n      expect(result.shell).toBe('fish');\n    });\n\n    it('should detect PowerShell from PSModulePath environment variable', () => {\n      process.env.PSModulePath = 'C:\\\\Program Files\\\\PowerShell\\\\Modules';\n      const result = detectShell();\n      expect(result.shell).toBe('powershell');\n      expect(result.detected).toBe('powershell');\n    });\n\n    it('should detect PowerShell on Windows platform with PSModulePath', () => {\n      Object.defineProperty(process, 'platform', {\n        value: 'win32',\n      });\n      process.env.PSModulePath = 'C:\\\\Windows\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\Modules';\n      const result = detectShell();\n      expect(result.shell).toBe('powershell');\n      expect(result.detected).toBe('powershell');\n    });\n\n    it('should return detected name for unsupported shell', () => {\n      process.env.SHELL = '/bin/tcsh';\n      const result = detectShell();\n      expect(result.shell).toBeUndefined();\n      expect(result.detected).toBe('tcsh');\n    });\n\n    it('should return undefined when SHELL is not set and not on Windows', () => {\n      Object.defineProperty(process, 'platform', {\n        value: 'linux',\n      });\n      const result = detectShell();\n      expect(result.shell).toBeUndefined();\n      expect(result.detected).toBeUndefined();\n    });\n\n    it('should return detected name for cmd.exe on Windows', () => {\n      Object.defineProperty(process, 'platform', {\n        value: 'win32',\n      });\n      process.env.COMSPEC = 'C:\\\\Windows\\\\System32\\\\cmd.exe';\n      const result = detectShell();\n      expect(result.shell).toBeUndefined();\n      expect(result.detected).toBe('cmd.exe');\n    });\n\n    it('should return undefined when no shell information is available', () => {\n      const result = detectShell();\n      expect(result.shell).toBeUndefined();\n      expect(result.detected).toBeUndefined();\n    });\n  });\n\n  describe('SupportedShell type', () => {\n    it('should accept valid shell types', () => {\n      const shells: SupportedShell[] = ['zsh', 'bash', 'fish', 'powershell'];\n      expect(shells).toHaveLength(4);\n    });\n  });\n});\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"lib\": [\"ES2022\"],\n    \"moduleResolution\": \"NodeNext\",\n    \"rootDir\": \"./src\",\n    \"outDir\": \"./dist\",\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"sourceMap\": true,\n    \"resolveJsonModule\": true,\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\", \"test\"]\n}"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\nimport os from 'node:os';\n\nfunction resolveMaxWorkers(): number | undefined {\n  // Allow callers (CI/agents) to override without editing config.\n  const raw = process.env.VITEST_MAX_WORKERS;\n  if (raw) {\n    const parsed = Number(raw);\n    if (Number.isFinite(parsed) && parsed > 0) {\n      return parsed;\n    }\n  }\n\n  // Vitest v3 defaults to `pool: \"forks\"` and scales worker processes with CPU.\n  // This repo's tests can spawn many Node processes (CLI invocations, temp FS),\n  // so cap parallelism to avoid runaway CPU/memory usage in automation.\n  const cpuCount = typeof os.availableParallelism === 'function'\n    ? os.availableParallelism()\n    : os.cpus().length;\n  return Math.min(4, Math.max(1, cpuCount));\n}\n\nexport default defineConfig({\n  test: {\n    globals: true,\n    environment: 'node',\n    globalSetup: './vitest.setup.ts',\n    // Tests rely on per-file process isolation (e.g., `process.cwd()` assumptions).\n    pool: 'forks',\n    maxWorkers: resolveMaxWorkers(),\n    include: ['test/**/*.test.ts'],\n    coverage: {\n      reporter: ['text', 'json', 'html'],\n      exclude: [\n        'node_modules/',\n        'dist/',\n        'bin/',\n        '*.config.ts',\n        'build.js',\n        'test/**'\n      ]\n    },\n    testTimeout: 10000,\n    hookTimeout: 10000,\n    teardownTimeout: 3000\n  }\n});\n"
  },
  {
    "path": "vitest.setup.ts",
    "content": "import { ensureCliBuilt } from './test/helpers/run-cli.js';\n\n// Ensure the CLI bundle exists before tests execute\nexport async function setup() {\n  await ensureCliBuilt();\n}\n\n// Global teardown to ensure clean exit\nexport async function teardown() {\n  // Force exit after a short grace period if the process hasn't exited cleanly.\n  // This handles cases where child processes or open handles keep the worker alive.\n  setTimeout(() => {\n    process.exit(0);\n  }, 1000).unref();\n}\n"
  }
]