diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d9d313cf3..dc4426804c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -208,7 +208,7 @@ - [#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 - - Fixed incorrect archive path in onboarding documentation — the template now shows the correct path `openspec/changes/archive/YYYY-MM-DD-/` instead of the incorrect `openspec/archive/YYYY-MM-DD--/` + - Legacy behavior: fixed the then-current nested archive path in onboarding documentation. Current releases use the sibling `openspec/archive/YYYY-MM-DD-/` layout. ## 1.0.0 diff --git a/README.md b/README.md index 7501ea94ab..58ecfd2188 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ AI: Implementing tasks... All tasks complete! You: /opsx:archive -AI: Archived to openspec/changes/archive/2025-01-23-add-dark-mode/ +AI: Archived to openspec/archive/2025-01-23-add-dark-mode/ Specs updated. Ready for the next feature. ``` diff --git a/docs/agent-contract.md b/docs/agent-contract.md index dae386b9f7..0454ceee1f 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -67,8 +67,8 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id ### 4.7 `new change --json` Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "root" }`. Failure: `{ "change": null, "status": [d] }`, exit 1. -### 4.8 `archive --json` -Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code. +### 4.8 `archive --json` +Success: `{ "archive": { "change", "archivedAs": "[domain/]YYYY-MM-DD-name", "path", "specsUpdated", "totals"? }, "root" }`. The optional `domain/` contains every domain segment from the full change ID. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code. ### 4.9 `doctor --json` `{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "status": [] } | null, "references": [...], "status": [] }`. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1. @@ -97,7 +97,7 @@ setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, regis `no_openspec_root`, `no_root_with_registered_stores`, `no_registered_stores`, `unknown_store`, `store_identity_mismatch`, `unhealthy_store_root`, `store_path_not_supported`, `invalid_store_pointer`, `initiative_option_removed`, `areas_option_removed`; pass-through: `invalid_store_id`, `invalid_store_registry`, `invalid_store_metadata`. ### OpenSpec-root health (error, no fix) -`openspec_store_root_missing`, `openspec_store_root_not_directory`, `openspec_root_missing`, `openspec_root_not_directory`, `openspec_config_missing`, `openspec_config_not_file`, `openspec_specs_not_directory`, `openspec_changes_not_directory`, `openspec_archive_not_directory`. During the stores beta, `openspec/specs/`, `openspec/changes/`, and `openspec/changes/archive/` may be absent in a healthy root; they are only health errors when present but not directories. +`openspec_store_root_missing`, `openspec_store_root_not_directory`, `openspec_root_missing`, `openspec_root_not_directory`, `openspec_config_missing`, `openspec_config_not_file`, `openspec_specs_not_directory`, `openspec_changes_not_directory`, `openspec_archive_not_directory`. During the stores beta, `openspec/specs/`, `openspec/changes/`, and `openspec/archive/` may be absent in a healthy root; they are only health errors when present but not directories. ### Store registry/identity/state `invalid_store_id`, `invalid_store_registry`, `invalid_store_metadata`, `store_registry_busy`, `store_not_found`, `no_store_registry`, `store_registry_changed`, `store_metadata_missing`, `store_metadata_id_mismatch`, `store_metadata_invalid`, `store_id_conflict`, `store_path_conflict`, `store_already_registered` (info). diff --git a/docs/cli.md b/docs/cli.md index fb591f2bcc..dc08caaba6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -57,7 +57,7 @@ These commands support `--json` output for programmatic use by AI agents and scr | `openspec store remove ` | Delete a registered local store folder | `--yes --json` for non-interactive deletion | | `openspec store list` | Browse registered stores | `--json` for structured registrations | | `openspec store doctor` | Check local store setup | `--json` for structured diagnostics | -| `openspec new change ` | Create repo-local change scaffolding | `--json`, plus `--store ` to use a registered store as the OpenSpec root | +| `openspec new change ` | Create repo-local change scaffolding | Explicit `--domain ` or `--domain ""`; add `--json` for structured output and `--store ` for a registered Store | | `openspec workset create [name]` | Compose a personal working view | `--member --json` for non-interactive composition | | `openspec workset list` | Browse saved worksets | `--json` for structured views | | `openspec workset remove ` | Delete a saved view | `--yes --json` for non-interactive removal | @@ -218,7 +218,7 @@ openspec store setup team-context --path ~/openspec/team-context --no-init-git - Register an existing local store folder. During the stores beta, a root may be registered before any changes exist, specs have been applied, or changes have been archived; in that case `openspec/changes/`, `openspec/specs/`, and -`openspec/changes/archive/` may be absent until normal commands create them. +`openspec/archive/` may be absent until normal commands create them. A config-only repo that declares `store: ` remains a pointer to another store and is not registered as a store root unless that pointer is removed. @@ -571,7 +571,7 @@ Validating add-dark-mode... Archive a completed change and merge delta specs into main specs. ``` -openspec archive [change-name] [options] +openspec archive [change-id] [options] ``` **Arguments:** @@ -609,7 +609,7 @@ openspec archive update-ci-config --skip-specs 1. Validates the change (unless `--no-validate`) 2. Prompts for confirmation (unless `--yes`) 3. Merges delta specs into `openspec/specs/` -4. Moves change folder to `openspec/changes/archive/YYYY-MM-DD-/` +4. Moves a root change to `openspec/archive/YYYY-MM-DD-/`, or preserves its domain at `openspec/archive//YYYY-MM-DD-/` --- @@ -622,7 +622,7 @@ These commands support the artifact-driven OPSX workflow. They're useful for bot Create a change directory and optional checked-in metadata in the resolved OpenSpec root. ```bash -openspec new change [options] +openspec new change --domain [options] ``` Change names must use lowercase kebab-case. They start with a lowercase letter, @@ -639,14 +639,15 @@ prefix it with a word, for example `ticket-123-add-notifications` instead of | `--description ` | Description to add to `README.md` | | `--goal ` | Optional goal metadata to store with the change | | `--schema ` | Workflow schema to use | +| `--domain ` | Mandatory domain decision; use a slash-delimited path, or `--domain ""` for root placement | | `--store ` | Store id to use as the OpenSpec root (a store is a standalone OpenSpec repo you've registered) | | `--json` | Output JSON | Examples: ```bash -openspec new change add-billing-api -openspec new change add-billing-api --store team-context --json +openspec new change add-billing-api --domain billing +openspec new change add-billing-api --domain "" --store team-context --json ``` ### `openspec status` @@ -661,7 +662,7 @@ openspec status [options] | Option | Description | |--------|-------------| -| `--change ` | Change name (prompts if omitted) | +| `--change ` | Full slash-delimited change ID (prompts if omitted) | | `--schema ` | Schema override (auto-detected from change's config) | | `--json` | Output as JSON | diff --git a/docs/commands.md b/docs/commands.md index 6737eb305c..4ac6295aa5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -180,7 +180,7 @@ Create the next artifact in the dependency chain. Creates one artifact at a time **Syntax:** ``` -/opsx:continue [change-name] +/opsx:continue [change-id] ``` **Arguments:** @@ -274,7 +274,7 @@ Implement tasks from the change. Works through the task list, writing code and c **Syntax:** ``` -/opsx:apply [change-name] +/opsx:apply [change-id] ``` **Arguments:** @@ -336,7 +336,7 @@ Revise a change's existing planning artifacts and keep them coherent with one an **What it does:** -- Reads the change's artifacts via `openspec status --change --json` +- Reads the change's artifacts via `openspec status --change --json` - Applies your requested revision, or reviews the artifacts for contradictions if you didn't name one - Reconciles the other existing artifacts in any direction (a design edit may ripple back to the proposal) - Confirms every edit with you before writing, one artifact at a time @@ -373,7 +373,7 @@ Validate that implementation matches your change artifacts. Checks completeness, **Syntax:** ``` -/opsx:verify [change-name] +/opsx:verify [change-id] ``` **Arguments:** @@ -498,7 +498,7 @@ Archive a completed change. Finalizes the change and moves it to the archive fol **Syntax:** ``` -/opsx:archive [change-name] +/opsx:archive [change-id] ``` **Arguments:** @@ -510,7 +510,7 @@ Archive a completed change. Finalizes the change and moves it to the archive fol - Checks artifact completion status - Checks task completion (warns if incomplete) - Offers to sync delta specs if not already synced -- Moves change folder to `openspec/changes/archive/YYYY-MM-DD-/` +- Moves a root change to `openspec/archive/YYYY-MM-DD-/`, or a domain change to `openspec/archive//YYYY-MM-DD-/` - Preserves all artifacts for audit trail **Example:** @@ -531,7 +531,7 @@ AI: Archiving add-dark-mode... You: Yes AI: ✓ Synced specs to openspec/specs/ui/spec.md - ✓ Moved to openspec/changes/archive/2025-01-24-add-dark-mode/ + ✓ Moved to openspec/archive/2025-01-24-add-dark-mode/ Change archived successfully. ``` @@ -716,7 +716,7 @@ The command couldn't identify which change to work on. All artifacts are either complete or blocked by missing dependencies. **Solutions:** -- Run `openspec status --change ` to see what's blocking +- Run `openspec status --change ` to see what's blocking - Check if required artifacts exist - Create missing dependency artifacts first diff --git a/docs/concepts.md b/docs/concepts.md index b929a588a7..bb6c968a7b 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -209,7 +209,7 @@ Packaging a change as a folder has several benefits: 2. **Parallel work.** Multiple changes can exist simultaneously without conflicting. Work on `add-dark-mode` while `fix-auth-bug` is also in progress. -3. **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. +3. **Clean history.** When archived, changes move to the sibling `archive/` tree with their full domain context preserved. You can look back and understand not just what changed, but why. 4. **Review-friendly.** A change folder is easy to review — open it, read the proposal, check the design, see the spec deltas. @@ -543,7 +543,7 @@ openspec/ 1. **Merge deltas.** Each delta spec section (ADDED/MODIFIED/REMOVED) is applied to the corresponding main spec. -2. **Move to archive.** The change folder moves to `changes/archive/` with a date prefix for chronological ordering. +2. **Move to archive.** The change folder moves to the sibling `archive/` tree with a date prefix for chronological ordering and its domain path preserved. 3. **Preserve context.** All artifacts remain intact in the archive. You can always look back to understand why a change was made. diff --git a/docs/customization.md b/docs/customization.md index 3c20a1d657..77ec99a494 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -51,10 +51,10 @@ rules: ```bash # Without config -openspec new change my-feature --schema spec-driven +openspec new change my-feature --schema spec-driven --domain "" # With config - schema is automatic -openspec new change my-feature +openspec new change my-feature --domain "" ``` **Context and rules injection:** @@ -241,7 +241,7 @@ Once created, use your schema with: ```bash # Specify on command -openspec new change feature --schema my-workflow +openspec new change feature --schema my-workflow --domain platform # Or set as default in config.yaml schema: my-workflow diff --git a/docs/examples.md b/docs/examples.md index cedf85c377..88bae22874 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -38,7 +38,7 @@ You: /opsx:archive AI: Archiving add-logout-button... ✓ Merged specs into openspec/specs/auth/spec.md - ✓ Moved to openspec/changes/archive/2026-06-22-add-logout-button/ + ✓ Moved to openspec/archive/2026-06-22-add-logout-button/ Done. Ready for the next change. ``` diff --git a/docs/faq.md b/docs/faq.md index 9b9198fd32..888b4bf774 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -108,7 +108,7 @@ A spec that describes only what's changing, using `ADDED`, `MODIFIED`, and `REMO ### Where do archived changes go? -To `openspec/changes/archive/YYYY-MM-DD-/`, with all artifacts preserved. Nothing is deleted; the change just moves out of your active list. +To `openspec/archive/YYYY-MM-DD-/` for a root change, or `openspec/archive//YYYY-MM-DD-/` for a domain change, with all artifacts preserved. Nothing is deleted; the change just moves out of your active list. ## Configuration and customization diff --git a/docs/getting-started.md b/docs/getting-started.md index caf6bf846b..543bc8514c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -141,7 +141,7 @@ When you archive a change: 2. **MODIFIED** requirements replace the existing version 3. **REMOVED** requirements are deleted from the main spec -The change folder moves to `openspec/changes/archive/` for audit history. +The change folder moves to the sibling `openspec/archive/` tree for audit history, preserving any domain path. ## Example: Your First Change @@ -248,7 +248,7 @@ You: /opsx:archive AI: Archiving add-dark-mode... ✓ Merged specs into openspec/specs/ui/spec.md - ✓ Moved to openspec/changes/archive/2025-01-24-add-dark-mode/ + ✓ Moved to openspec/archive/2025-01-24-add-dark-mode/ Done! Ready for the next feature. ``` diff --git a/docs/glossary.md b/docs/glossary.md index 397cbe36da..90c542de18 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -36,7 +36,7 @@ Terms are grouped by topic, then alphabetized within each group. ## The lifecycle -**Archive.** The act of finishing a change. Its delta specs merge into the main specs, and the change folder moves to `openspec/changes/archive/YYYY-MM-DD-/`. After archiving, your specs describe the new reality. See [Concepts](concepts.md#archive). +**Archive.** The act of finishing a change. Its delta specs merge into the main specs, and the change folder moves to `openspec/archive/YYYY-MM-DD-/` or the domain-preserving `openspec/archive//YYYY-MM-DD-/`. After archiving, your specs describe the new reality. See [Concepts](concepts.md#archive). **Sync.** Merging a change's delta specs into the main specs *without* archiving the change. Usually automatic (archive offers to do it), but available on its own as `/opsx:sync` for long-running changes. See [Commands](commands.md#opsxsync). diff --git a/docs/installation.md b/docs/installation.md index 3714f187bb..b793f1f056 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -97,7 +97,7 @@ npm uninstall -g @fission-ai/openspec # or: pnpm rm -g / yarn global remove / rm -rf openspec/ ``` -Think before you do this: `openspec/specs/` and `openspec/changes/archive/` are your record of how the system behaves and why it changed. If you might want that history, keep the folder (or keep it in git) even after uninstalling. +Think before you do this: `openspec/specs/` and `openspec/archive/` are your record of how the system behaves and why it changed. If you might want that history, keep the folder (or keep it in git) even after uninstalling. **3. Remove generated AI tool files (optional).** OpenSpec writes skill and command files into per-tool directories like `.claude/skills/openspec-*/`, `.cursor/commands/opsx-*`, and so on. Delete the `openspec-*` skills and `opsx-*` commands for whichever tools you configured. The exact paths per tool are listed in [Supported Tools](supported-tools.md). diff --git a/docs/overview.md b/docs/overview.md index 6321a3439a..e597742851 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -23,7 +23,7 @@ proposal ──► specs ──► design ──► tasks ──► implement You can revisit any of them at any time. They're enablers, not gates. (More on that below.) -**5. Archiving folds the change back into the truth.** When the work is done, you archive the change. Its delta specs merge into your main specs, and the change folder moves to `changes/archive/` with a date stamp. Now your specs describe the new reality, and you're ready for the next change. The cycle closes. +**5. Archiving folds the change back into the truth.** When the work is done, you archive the change. Its delta specs merge into your main specs, and the change folder moves to the sibling `archive/` tree with a date stamp and its domain preserved. Now your specs describe the new reality, and you're ready for the next change. The cycle closes. ## The picture diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 3711777e15..93c120c206 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -65,19 +65,20 @@ OpenSpec root: ready Registry: registered Next: run normal OpenSpec commands against this store, for example: - openspec new change --store team-plans + openspec new change --domain --store team-plans + openspec new change --domain "" --store team-plans Share this store by committing and pushing it like any Git repo. ``` ```bash -openspec new change add-login --store team-plans +openspec new change add-login --domain auth --store team-plans ``` ``` Using OpenSpec root: team-plans (/Users/you/openspec/team-plans) -Created change 'add-login' at /Users/you/openspec/team-plans/openspec/changes/add-login/ +Created change 'auth/add-login' at /Users/you/openspec/team-plans/openspec/changes/auth/add-login/ Schema: spec-driven -Next: openspec status --change add-login --store team-plans +Next: openspec status --change auth/add-login --store team-plans ``` That's the whole model. From here the lifecycle is exactly what you know — @@ -309,7 +310,7 @@ tells you which case you're in. A stale checkout shows stale specs until *you* pull; references are indexed live from whatever is on disk. - **Empty planning folders can be absent.** A new store may not have - `openspec/changes/`, `openspec/specs/`, or `openspec/changes/archive/` in Git + `openspec/changes/`, `openspec/specs/`, or `openspec/archive/` in Git yet. That is accepted during the beta; those folders appear once normal commands create files for them. - **Pointer repos stay pointers.** A config-only repo whose diff --git a/docs/superpowers/plans/2026-06-03-domain-archive-restructure.md b/docs/superpowers/plans/2026-06-03-domain-archive-restructure.md new file mode 100644 index 0000000000..96540e6f41 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-domain-archive-restructure.md @@ -0,0 +1,1481 @@ +# Domain Support & Archive Restructure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure OpenSpec so that `changes/` and `archive/` support multi-level domain hierarchies, `archive/` moves to be a sibling of `changes/` under `openspec/`, and creating a change interactively guides the user to optionally pick or create a domain level-by-level. + +**Architecture:** Introduce `src/utils/change-path.ts` as the single source of truth for change-id splitting, recursive leaf discovery, and archive-path calculation. All consumers (`archive.ts`, `list.ts`, `view.ts`, `item-discovery.ts`, `change-utils.ts`, `shared.ts`) are updated to call these helpers. The interactive domain-selection loop is encapsulated in `new-change.ts`. + +**Tech Stack:** TypeScript, Node.js `fs/promises`, `@inquirer/prompts` (select / input), Vitest for tests. + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/utils/change-path.ts` | **Create** | `splitChangeId`, `findAllChangeIds`, `findAllArchivedChangeIds`, `buildArchivePath`, `validateDomainPath` | +| `src/core/archive.ts` | Modify | Use `findAllChangeIds` for selection; use `buildArchivePath` + new archive root | +| `src/core/list.ts` | Modify | Use `findAllChangeIds` instead of single-level `readdir` | +| `src/core/view.ts` | Modify | Use `findAllChangeIds` instead of single-level `readdirSync` | +| `src/utils/item-discovery.ts` | Modify | `getActiveChangeIds` → `findAllChangeIds`; `getArchivedChangeIds` → `findAllArchivedChangeIds` | +| `src/utils/change-utils.ts` | Modify | `createChange` accepts slash-separated id; `changesDir + id` for directory path | +| `src/commands/workflow/shared.ts` | Modify | `getAvailableChanges` → `findAllChangeIds`; `validateChangeExists` handles slash ids | +| `src/commands/workflow/new-change.ts` | Modify | Add `--domain` option; insert domain-selection interactive step | +| `src/core/planning-home.ts` | Modify | `getChangeDir` / `formatChangeLocation` work with multi-segment ids | +| `src/commands/completion.ts` | Modify | `getArchivedChangeIds` points to new archive root | +| `src/core/templates/workflows/archive-change.ts` | Modify | Update archive-path instructions in skill/command templates | +| `src/core/templates/workflows/bulk-archive-change.ts` | Modify | Same | +| `test/utils/change-path.test.ts` | **Create** | Unit tests for all helpers in `change-path.ts` | +| `test/core/archive.test.ts` | Modify | Update setup (no `changes/archive/`), add domain-aware archive tests | +| `test/utils/change-utils.test.ts` | Modify | Add tests for slash-id `createChange` | +| `test/commands/workflow/new-change-domain.test.ts` | **Create** | Tests for `--domain` flag in `newChangeCommand` | + +--- + +## Task 1: Create `src/utils/change-path.ts` with helpers + +**Files:** +- Create: `src/utils/change-path.ts` +- Create: `test/utils/change-path.test.ts` + +- [ ] **Step 1: Write failing tests** + +```typescript +// test/utils/change-path.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { + splitChangeId, + buildArchivePath, + findAllChangeIds, + findAllArchivedChangeIds, + validateDomainPath, +} from '../../src/utils/change-path.js'; + +describe('splitChangeId', () => { + it('returns empty domain and name for root-level id', () => { + expect(splitChangeId('add-login')).toEqual({ domain: [], name: 'add-login' }); + }); + + it('returns single-level domain', () => { + expect(splitChangeId('auth/add-login')).toEqual({ domain: ['auth'], name: 'add-login' }); + }); + + it('returns multi-level domain', () => { + expect(splitChangeId('auth/oauth/add-login')).toEqual({ + domain: ['auth', 'oauth'], + name: 'add-login', + }); + }); +}); + +describe('buildArchivePath', () => { + it('builds path for no-domain change', () => { + const result = buildArchivePath('/openspec/archive', 'add-login', '2025-01-23'); + expect(result).toBe(path.join('/openspec/archive', '2025-01-23-add-login')); + }); + + it('builds path for single-domain change', () => { + const result = buildArchivePath('/openspec/archive', 'auth/add-login', '2025-01-23'); + expect(result).toBe(path.join('/openspec/archive', 'auth', '2025-01-23-add-login')); + }); + + it('builds path for multi-domain change', () => { + const result = buildArchivePath('/openspec/archive', 'auth/oauth/add-login', '2025-01-23'); + expect(result).toBe(path.join('/openspec/archive', 'auth', 'oauth', '2025-01-23-add-login')); + }); +}); + +describe('validateDomainPath', () => { + it('accepts empty string (no domain)', () => { + expect(validateDomainPath('')).toEqual({ valid: true }); + }); + + it('accepts single kebab-case segment', () => { + expect(validateDomainPath('auth')).toEqual({ valid: true }); + }); + + it('accepts multi-level kebab-case path', () => { + expect(validateDomainPath('auth/oauth')).toEqual({ valid: true }); + }); + + it('rejects empty segment (double slash)', () => { + const result = validateDomainPath('auth//oauth'); + expect(result.valid).toBe(false); + expect(result.error).toContain('empty'); + }); + + it('rejects uppercase segment', () => { + const result = validateDomainPath('Auth/oauth'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Auth'); + }); + + it('rejects segment with underscore', () => { + const result = validateDomainPath('my_domain'); + expect(result.valid).toBe(false); + expect(result.error).toContain('my_domain'); + }); +}); + +describe('findAllChangeIds', () => { + let tempDir: string; + let changesDir: string; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-cp-test-${randomUUID()}`); + changesDir = path.join(tempDir, 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('returns empty array when changesDir does not exist', async () => { + const result = await findAllChangeIds(path.join(tempDir, 'nonexistent')); + expect(result).toEqual([]); + }); + + it('finds root-level change', async () => { + await fs.mkdir(path.join(changesDir, 'add-login'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'add-login', '.openspec.yaml'), 'schema: spec-driven\ncreated: 2025-01-01\n'); + const result = await findAllChangeIds(changesDir); + expect(result).toEqual(['add-login']); + }); + + it('finds domain-nested change', async () => { + await fs.mkdir(path.join(changesDir, 'auth', 'add-login'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'auth', 'add-login', '.openspec.yaml'), 'schema: spec-driven\ncreated: 2025-01-01\n'); + const result = await findAllChangeIds(changesDir); + expect(result).toContain(path.join('auth', 'add-login').replace(/\\/g, '/')); + }); + + it('finds multi-level domain change', async () => { + await fs.mkdir(path.join(changesDir, 'auth', 'oauth', 'add-login'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'auth', 'oauth', 'add-login', '.openspec.yaml'), 'schema: spec-driven\ncreated: 2025-01-01\n'); + const result = await findAllChangeIds(changesDir); + expect(result).toContain('auth/oauth/add-login'); + }); + + it('does not return domain container directories (no .openspec.yaml or proposal.md)', async () => { + // domain container only — no leaf marker + await fs.mkdir(path.join(changesDir, 'auth'), { recursive: true }); + const result = await findAllChangeIds(changesDir); + expect(result).toEqual([]); + }); + + it('uses proposal.md as leaf marker when .openspec.yaml absent', async () => { + await fs.mkdir(path.join(changesDir, 'add-login'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'add-login', 'proposal.md'), '# proposal'); + const result = await findAllChangeIds(changesDir); + expect(result).toEqual(['add-login']); + }); +}); + +describe('findAllArchivedChangeIds', () => { + let tempDir: string; + let archiveDir: string; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-arc-test-${randomUUID()}`); + archiveDir = path.join(tempDir, 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('finds root-level archived change', async () => { + await fs.mkdir(path.join(archiveDir, '2025-01-23-add-login'), { recursive: true }); + await fs.writeFile(path.join(archiveDir, '2025-01-23-add-login', '.openspec.yaml'), 'schema: spec-driven\ncreated: 2025-01-01\n'); + const result = await findAllArchivedChangeIds(archiveDir); + expect(result).toEqual(['2025-01-23-add-login']); + }); + + it('finds domain-nested archived change', async () => { + await fs.mkdir(path.join(archiveDir, 'auth', '2025-01-23-add-login'), { recursive: true }); + await fs.writeFile(path.join(archiveDir, 'auth', '2025-01-23-add-login', '.openspec.yaml'), 'schema: spec-driven\ncreated: 2025-01-01\n'); + const result = await findAllArchivedChangeIds(archiveDir); + expect(result).toContain('auth/2025-01-23-add-login'); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +pnpm test test/utils/change-path.test.ts +``` +Expected: multiple failures — `change-path.ts` does not exist yet. + +- [ ] **Step 3: Create `src/utils/change-path.ts`** + +```typescript +// src/utils/change-path.ts +import { promises as fs } from 'fs'; +import path from 'path'; +import { validateChangeName } from './change-utils.js'; + +export interface SplitChangeId { + domain: string[]; + name: string; +} + +export interface ValidationResult { + valid: boolean; + error?: string; +} + +/** + * Splits a full change id (e.g. "auth/oauth/add-login") into domain segments + * and the change name (last segment). + */ +export function splitChangeId(id: string): SplitChangeId { + const parts = id.split('/'); + return { + domain: parts.slice(0, -1), + name: parts[parts.length - 1], + }; +} + +/** + * Validates that a domain path string (e.g. "auth/oauth") consists only of + * valid kebab-case segments. Empty string means no domain and is always valid. + */ +export function validateDomainPath(domainPath: string): ValidationResult { + if (domainPath === '') return { valid: true }; + + const segments = domainPath.split('/'); + for (const segment of segments) { + if (segment === '') { + return { valid: false, error: `Domain path '${domainPath}' contains an empty segment (double slash)` }; + } + const check = validateChangeName(segment); + if (!check.valid) { + return { valid: false, error: `Domain segment '${segment}' is invalid: ${check.error}` }; + } + } + return { valid: true }; +} + +/** + * Computes the archive destination path for a change, preserving domain hierarchy. + * + * Examples: + * buildArchivePath('/openspec/archive', 'add-login', '2025-01-23') + * → '/openspec/archive/2025-01-23-add-login' + * buildArchivePath('/openspec/archive', 'auth/add-login', '2025-01-23') + * → '/openspec/archive/auth/2025-01-23-add-login' + * buildArchivePath('/openspec/archive', 'auth/oauth/add-login', '2025-01-23') + * → '/openspec/archive/auth/oauth/2025-01-23-add-login' + */ +export function buildArchivePath(archiveDir: string, changeId: string, date: string): string { + const { domain, name } = splitChangeId(changeId); + return path.join(archiveDir, ...domain, `${date}-${name}`); +} + +/** + * Returns true if `dir` is a leaf change directory (contains .openspec.yaml or proposal.md). + */ +async function isLeafChange(dir: string): Promise { + try { + await fs.access(path.join(dir, '.openspec.yaml')); + return true; + } catch { + try { + await fs.access(path.join(dir, 'proposal.md')); + return true; + } catch { + return false; + } + } +} + +/** + * Recursively walks `rootDir` and returns relative slash-separated ids for all + * leaf change directories (directories that contain .openspec.yaml or proposal.md). + */ +async function walkForLeaves(rootDir: string, prefix: string): Promise { + const results: string[] = []; + let entries: Awaited>; + try { + entries = await fs.readdir(rootDir, { withFileTypes: true }); + } catch { + return results; + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const fullPath = path.join(rootDir, entry.name); + const relId = prefix ? `${prefix}/${entry.name}` : entry.name; + + if (await isLeafChange(fullPath)) { + results.push(relId); + } else { + // It's a domain container — recurse + const children = await walkForLeaves(fullPath, relId); + results.push(...children); + } + } + return results; +} + +/** + * Recursively finds all active change ids (relative to changesDir) by looking + * for leaf-node directories that contain .openspec.yaml or proposal.md. + * Returns forward-slash separated ids regardless of OS. + */ +export async function findAllChangeIds(changesDir: string): Promise { + try { + await fs.access(changesDir); + } catch { + return []; + } + const ids = await walkForLeaves(changesDir, ''); + return ids.sort(); +} + +/** + * Same as findAllChangeIds but for the archive root directory. + */ +export async function findAllArchivedChangeIds(archiveDir: string): Promise { + try { + await fs.access(archiveDir); + } catch { + return []; + } + const ids = await walkForLeaves(archiveDir, ''); + return ids.sort(); +} +``` + +- [ ] **Step 4: Run tests — expect green** + +```bash +pnpm test test/utils/change-path.test.ts +``` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/change-path.ts test/utils/change-path.test.ts +git commit -m "feat: add change-path utilities (splitChangeId, buildArchivePath, findAllChangeIds)" +``` + +--- + +## Task 2: Update `src/utils/item-discovery.ts` + +**Files:** +- Modify: `src/utils/item-discovery.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// test/utils/item-discovery.test.ts (create this new file) + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { getActiveChangeIds, getArchivedChangeIds } from '../../src/utils/item-discovery.js'; + +describe('getActiveChangeIds (domain-aware)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-disc-test-${randomUUID()}`); + await fs.mkdir(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); + process.chdir(tempDir); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('returns domain-nested change id', async () => { + await fs.mkdir(path.join(tempDir, 'openspec', 'changes', 'auth', 'add-login'), { recursive: true }); + await fs.writeFile( + path.join(tempDir, 'openspec', 'changes', 'auth', 'add-login', 'proposal.md'), + '# proposal' + ); + const ids = await getActiveChangeIds(tempDir); + expect(ids).toContain('auth/add-login'); + }); +}); + +describe('getArchivedChangeIds (domain-aware)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-arc-disc-${randomUUID()}`); + await fs.mkdir(path.join(tempDir, 'openspec', 'archive'), { recursive: true }); + process.chdir(tempDir); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('reads from openspec/archive/ (not openspec/changes/archive/)', async () => { + await fs.mkdir( + path.join(tempDir, 'openspec', 'archive', '2025-01-23-add-login'), + { recursive: true } + ); + await fs.writeFile( + path.join(tempDir, 'openspec', 'archive', '2025-01-23-add-login', 'proposal.md'), + '# proposal' + ); + const ids = await getArchivedChangeIds(tempDir); + expect(ids).toContain('2025-01-23-add-login'); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +pnpm test test/utils/item-discovery.test.ts +``` + +- [ ] **Step 3: Update `src/utils/item-discovery.ts`** + +Replace the file entirely: + +```typescript +import path from 'path'; +import { findAllChangeIds, findAllArchivedChangeIds } from './change-path.js'; + +export async function getActiveChangeIds(root: string = process.cwd()): Promise { + const changesPath = path.join(root, 'openspec', 'changes'); + return findAllChangeIds(changesPath); +} + +export async function getSpecIds(root: string = process.cwd()): Promise { + const { promises: fs } = await import('fs'); + const specsPath = path.join(root, 'openspec', 'specs'); + const result: string[] = []; + try { + const entries = await fs.readdir(specsPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const specFile = path.join(specsPath, entry.name, 'spec.md'); + try { + await fs.access(specFile); + result.push(entry.name); + } catch { + // ignore + } + } + } catch { + // ignore + } + return result.sort(); +} + +export async function getArchivedChangeIds(root: string = process.cwd()): Promise { + const archivePath = path.join(root, 'openspec', 'archive'); + return findAllArchivedChangeIds(archivePath); +} +``` + +- [ ] **Step 4: Run tests — expect green** + +```bash +pnpm test test/utils/item-discovery.test.ts +``` + +- [ ] **Step 5: Run full suite to catch regressions** + +```bash +pnpm test +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/utils/item-discovery.ts test/utils/item-discovery.test.ts +git commit -m "feat: item-discovery uses findAllChangeIds and new archive root" +``` + +--- + +## Task 3: Update `src/core/list.ts` and `src/core/view.ts` + +**Files:** +- Modify: `src/core/list.ts` +- Modify: `src/core/view.ts` + +- [ ] **Step 1: Write failing tests for domain-aware listing** + +```typescript +// Add to test/core/list.test.ts — find and append these cases +// (look for existing describe block for ListCommand) + +it('lists domain-nested change with full relative id', async () => { + // Setup: create auth/add-login under changesDir + await fs.mkdir(path.join(tempDir, 'openspec', 'changes', 'auth', 'add-login'), { recursive: true }); + await fs.writeFile( + path.join(tempDir, 'openspec', 'changes', 'auth', 'add-login', '.openspec.yaml'), + 'schema: spec-driven\ncreated: 2025-01-01\n' + ); + const listCommand = new ListCommand(); + // Capture stdout + const lines: string[] = []; + const origLog = console.log; + console.log = (...args: any[]) => lines.push(args.join(' ')); + await listCommand.execute(tempDir, 'changes', {}); + console.log = origLog; + const output = lines.join('\n'); + expect(output).toContain('auth/add-login'); +}); +``` + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +pnpm test test/core/list.test.ts +``` + +- [ ] **Step 3: Update `src/core/list.ts`** — replace the readdir block for changes mode + +Find this block (around line 85–95): +```typescript +// Get all directories in changes (excluding archive) +const entries = await fs.readdir(changesDir, { withFileTypes: true }); +const changeDirs = entries + .filter(entry => entry.isDirectory() && entry.name !== 'archive') + .map(entry => entry.name); +``` + +Replace with: +```typescript +import { findAllChangeIds } from '../utils/change-path.js'; + +// ...inside execute(), changes mode: +const changeDirs = await findAllChangeIds(changesDir); +``` + +Also update where `getTaskProgressForChange` is called — it currently takes `(changesDir, changeDir)` where the second arg is just the name. With domain ids the name is now the full relative id. Verify the call signature in `src/utils/task-progress.ts` — if it does `path.join(changesDir, name)` then passing `auth/add-login` already works correctly. + +- [ ] **Step 4: Update `src/core/view.ts`** — replace the `readdirSync` + `entry.name !== 'archive'` block inside `getChangesData` + +Find (around line 95–100): +```typescript +const entries = fs.readdirSync(changesDir, { withFileTypes: true }); + +for (const entry of entries) { + if (entry.isDirectory() && entry.name !== 'archive') { + const progress = await getTaskProgressForChange(changesDir, entry.name); + // ... + draft/active/completed.push({ name: entry.name ... }) +``` + +Replace with: +```typescript +import { findAllChangeIds } from '../utils/change-path.js'; + +// inside getChangesData (make the method async if not already): +const changeIds = await findAllChangeIds(changesDir); + +for (const id of changeIds) { + const progress = await getTaskProgressForChange(changesDir, id); + if (progress.total === 0) { + draft.push({ name: id }); + } else if (progress.completed === progress.total) { + completed.push({ name: id }); + } else { + active.push({ name: id, progress }); + } +} +``` + +Note: `getChangesData` is called with `await` in `execute()`, so making it `async` is safe. + +- [ ] **Step 5: Run tests** + +```bash +pnpm test test/core/list.test.ts test/core/view.test.ts +``` + +- [ ] **Step 6: Run full suite** + +```bash +pnpm test +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/core/list.ts src/core/view.ts +git commit -m "feat: list and view use recursive domain-aware change discovery" +``` + +--- + +## Task 4: Update `src/commands/workflow/shared.ts` + +**Files:** +- Modify: `src/commands/workflow/shared.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// Add to test/commands/artifact-workflow.test.ts or a new file +// test/commands/workflow/shared-domain.test.ts + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { getAvailableChanges, validateChangeExists } from '../../../src/commands/workflow/shared.js'; + +describe('getAvailableChanges (domain-aware)', () => { + let tempDir: string; + let changesDir: string; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-shared-${randomUUID()}`); + changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('returns domain-nested id', async () => { + await fs.mkdir(path.join(changesDir, 'auth', 'add-login'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'auth', 'add-login', '.openspec.yaml'), 'schema: spec-driven\ncreated: 2025-01-01\n'); + const ids = await getAvailableChanges(tempDir, changesDir); + expect(ids).toContain('auth/add-login'); + }); +}); + +describe('validateChangeExists (domain-aware)', () => { + let tempDir: string; + let changesDir: string; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-vce-${randomUUID()}`); + changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('resolves domain-nested change id', async () => { + await fs.mkdir(path.join(changesDir, 'auth', 'add-login'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'auth', 'add-login', '.openspec.yaml'), 'schema: spec-driven\ncreated: 2025-01-01\n'); + const result = await validateChangeExists('auth/add-login', tempDir, changesDir); + expect(result).toBe('auth/add-login'); + }); +}); +``` + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +pnpm test test/commands/workflow/shared-domain.test.ts +``` + +- [ ] **Step 3: Update `getAvailableChanges` in `src/commands/workflow/shared.ts`** + +Replace: +```typescript +export async function getAvailableChanges( + projectRoot: string, + changesDir = path.join(projectRoot, 'openspec', 'changes') +): Promise { + const changesPath = changesDir; + try { + const entries = await fs.promises.readdir(changesPath, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.')) + .map((e) => e.name); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} +``` + +With: +```typescript +import { findAllChangeIds } from '../../utils/change-path.js'; + +export async function getAvailableChanges( + projectRoot: string, + changesDir = path.join(projectRoot, 'openspec', 'changes') +): Promise { + return findAllChangeIds(changesDir); +} +``` + +- [ ] **Step 4: Update `validateChangeExists` in the same file** + +The existing function calls `validateChangeName(changeName)` which rejects slashes. Replace the validation call with a domain-path-aware check: + +```typescript +import { validateDomainPath, splitChangeId } from '../../utils/change-path.js'; + +export async function validateChangeExists( + changeName: string | undefined, + projectRoot: string, + changesDir = path.join(projectRoot, 'openspec', 'changes') +): Promise { + if (!changeName) { + const available = await getAvailableChanges(projectRoot, changesDir); + if (available.length === 0) { + throw new Error('No changes found. Create one with: openspec new change '); + } + throw new Error( + `Missing required option --change. Available changes:\n ${available.join('\n ')}` + ); + } + + // Validate: split into domain + name, validate each segment + const { domain, name } = splitChangeId(changeName); + const domainValidation = validateDomainPath(domain.join('/')); + if (!domainValidation.valid) { + throw new Error(`Invalid change id '${changeName}': ${domainValidation.error}`); + } + const nameValidation = validateChangeName(name); // validateChangeName already imported at top of file + if (!nameValidation.valid) { + throw new Error(`Invalid change name '${name}': ${nameValidation.error}`); + } + + // Check directory existence + const changePath = path.join(changesDir, ...changeName.split('/')); + const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory(); + + if (!exists) { + const available = await getAvailableChanges(projectRoot, changesDir); + if (available.length === 0) { + throw new Error( + `Change '${changeName}' not found. No changes exist. Create one with: openspec new change ` + ); + } + throw new Error( + `Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}` + ); + } + + return changeName; +} +``` + +- [ ] **Step 5: Run tests** + +```bash +pnpm test test/commands/workflow/shared-domain.test.ts +pnpm test +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/commands/workflow/shared.ts test/commands/workflow/shared-domain.test.ts +git commit -m "feat: workflow/shared supports domain-nested change ids" +``` + +--- + +## Task 5: Update `src/core/archive.ts` + +**Files:** +- Modify: `src/core/archive.ts` +- Modify: `test/core/archive.test.ts` + +- [ ] **Step 1: Write failing tests for domain-aware archive** + +Add these cases to `test/core/archive.test.ts`. In `beforeEach`, remove the `changes/archive` mkdir — it should no longer be pre-created: + +```typescript +it('archives to openspec/archive/ (sibling of changes/)', async () => { + const changeName = 'test-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1'); + + await archiveCommand.execute(changeName, { yes: true }); + + const archiveRoot = path.join(tempDir, 'openspec', 'archive'); + const archives = await fs.readdir(archiveRoot); + expect(archives.length).toBe(1); + expect(archives[0]).toMatch(/^\d{4}-\d{2}-\d{2}-test-feature$/); + + // Old location must NOT exist + const oldArchive = path.join(tempDir, 'openspec', 'changes', 'archive'); + await expect(fs.access(oldArchive)).rejects.toThrow(); +}); + +it('preserves domain hierarchy when archiving', async () => { + const changeId = 'auth/add-login'; + const changeDir = path.join(tempDir, 'openspec', 'changes', 'auth', 'add-login'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1'); + + await archiveCommand.execute(changeId, { yes: true }); + + const archiveRoot = path.join(tempDir, 'openspec', 'archive'); + const domainDir = await fs.readdir(path.join(archiveRoot, 'auth')); + expect(domainDir.length).toBe(1); + expect(domainDir[0]).toMatch(/^\d{4}-\d{2}-\d{2}-add-login$/); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +pnpm test test/core/archive.test.ts +``` + +- [ ] **Step 3: Update `src/core/archive.ts`** — change archive root and path calculation + +At the top of `execute()`, change: + +```typescript +// OLD +const changesDir = path.join(targetPath, 'openspec', 'changes'); +const archiveDir = path.join(changesDir, 'archive'); +const mainSpecsDir = path.join(targetPath, 'openspec', 'specs'); +``` + +To: + +```typescript +import { findAllChangeIds, buildArchivePath } from '../utils/change-path.js'; + +// NEW +const openspecDir = path.join(targetPath, 'openspec'); +const changesDir = path.join(openspecDir, 'changes'); +const archiveDir = path.join(openspecDir, 'archive'); +const mainSpecsDir = path.join(openspecDir, 'specs'); +``` + +Then update the final archive section (around line 267–287). Replace: +```typescript +const archiveName = `${this.getArchiveDate()}-${changeName}`; +const archivePath = path.join(archiveDir, archiveName); + +try { + await fs.access(archivePath); + throw new Error(`Archive '${archiveName}' already exists.`); +} catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } +} + +await fs.mkdir(archiveDir, { recursive: true }); +await moveDirectory(changeDir, archivePath); +console.log(`Change '${changeName}' archived as '${archiveName}'.`); +``` + +With: +```typescript +const archivePath = buildArchivePath(archiveDir, changeName, this.getArchiveDate()); + +try { + await fs.access(archivePath); + const archiveName = path.basename(archivePath); + throw new Error(`Archive '${archiveName}' already exists at ${archivePath}.`); +} catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } +} + +await fs.mkdir(path.dirname(archivePath), { recursive: true }); +await moveDirectory(changeDir, archivePath); +console.log(`Change '${changeName}' archived to '${archivePath}'.`); +``` + +- [ ] **Step 4: Update `selectChange()` in `src/core/archive.ts`** + +Replace the `readdir` + filter block inside `selectChange`: +```typescript +// OLD +const entries = await fs.readdir(changesDir, { withFileTypes: true }); +const changeDirs = entries + .filter(entry => entry.isDirectory() && entry.name !== 'archive') + .map(entry => entry.name) + .sort(); +``` + +With: +```typescript +const changeDirs = await findAllChangeIds(changesDir); +``` + +Also update how progress is retrieved — `getTaskProgressForChange(changesDir, id)` already uses `path.join(changesDir, id)` internally; verify this in `src/utils/task-progress.ts`. If so, no further change needed. + +- [ ] **Step 5: Update `changeDir` resolution** — the line that resolves from the provided `changeName` + +```typescript +// OLD +const changeDir = path.join(changesDir, changeName); +``` + +With (handles both flat and domain-nested ids): +```typescript +const changeDir = path.join(changesDir, ...changeName.split('/')); +``` + +- [ ] **Step 6: Run tests** + +```bash +pnpm test test/core/archive.test.ts +pnpm test +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/core/archive.ts test/core/archive.test.ts +git commit -m "feat: archive moves to openspec/archive/ and preserves domain hierarchy" +``` + +--- + +## Task 6: Update `src/utils/change-utils.ts` and `src/core/planning-home.ts` + +**Files:** +- Modify: `src/utils/change-utils.ts` +- Modify: `src/core/planning-home.ts` +- Modify: `test/utils/change-utils.test.ts` + +- [ ] **Step 1: Write failing test for domain-nested `createChange`** + +Add to `test/utils/change-utils.test.ts`: +```typescript +it('creates change directory at domain-nested path', async () => { + const result = await createChange(testDir, 'auth/add-login'); + + const changeDir = path.join(testDir, 'openspec', 'changes', 'auth', 'add-login'); + const stats = await fs.stat(changeDir); + expect(stats.isDirectory()).toBe(true); + expect(result.changeDir).toBe(changeDir); +}); + +it('throws if change with domain already exists', async () => { + await createChange(testDir, 'auth/add-login'); + await expect(createChange(testDir, 'auth/add-login')).rejects.toThrow("already exists"); +}); +``` + +- [ ] **Step 2: Run tests to confirm failure** + +```bash +pnpm test test/utils/change-utils.test.ts +``` + +- [ ] **Step 3: Update `createChange` in `src/utils/change-utils.ts`** + +The `name` parameter now carries the full id (`auth/add-login`). Split it to validate: + +```typescript +import { splitChangeId, validateDomainPath } from './change-path.js'; + +export async function createChange( + projectRoot: string, + name: string, // now a full id, e.g. "auth/add-login" or "add-login" + options: CreateChangeOptions = {} +): Promise { + const { domain, name: changeName } = splitChangeId(name); + + // Validate change name (last segment) + const validation = validateChangeName(changeName); + if (!validation.valid) { + throw new Error(validation.error); + } + + // Validate domain segments + if (domain.length > 0) { + const domainValidation = validateDomainPath(domain.join('/')); + if (!domainValidation.valid) { + throw new Error(domainValidation.error); + } + } + + // ... rest of the function is unchanged, except: + // Build changeDir using the full id (supports slashes) + const changeDir = path.join( + options.changesDir ?? path.join(projectRoot, 'openspec', 'changes'), + ...name.split('/') + ); + + // ... rest unchanged (FileSystemUtils.directoryExists, createDirectory, writeChangeMetadata) +} +``` + +- [ ] **Step 4: Verify `src/core/planning-home.ts` — `getChangeDir` and `formatChangeLocation`** + +Read the current implementation. These two functions call `FileSystemUtils.joinPath(planningHome.changesDir, changeName)`. Since `changeName` is now a slash-separated id, and `FileSystemUtils.joinPath` likely uses `path.join`, this will work on all OSes automatically. Confirm by reading: + +```bash +grep -n "joinPath\|getChangeDir\|formatChangeLocation" src/core/planning-home.ts src/utils/file-system.ts +``` + +If `joinPath` uses `path.join(...parts.split('/'))` or similar, no change is needed. If it doesn't split on `/`, update `getChangeDir`: + +```typescript +export function getChangeDir(planningHome: PlanningHome, changeName: string): string { + return path.join(planningHome.changesDir, ...changeName.split('/')); +} +``` + +- [ ] **Step 5: Run tests** + +```bash +pnpm test test/utils/change-utils.test.ts test/core/planning-home.test.ts +pnpm test +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/utils/change-utils.ts src/core/planning-home.ts test/utils/change-utils.test.ts +git commit -m "feat: createChange and planning-home support domain-nested change ids" +``` + +--- + +## Task 7: Update `src/commands/workflow/new-change.ts` — domain selection + +**Files:** +- Create: `test/commands/workflow/new-change-domain.test.ts` +- Modify: `src/commands/workflow/new-change.ts` +- Modify: `src/cli/index.ts` (add `--domain` option to `new change` command) + +- [ ] **Step 1: Write failing tests for `--domain` flag** + +```typescript +// test/commands/workflow/new-change-domain.test.ts +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { newChangeCommand } from '../../../src/commands/workflow/new-change.js'; + +// Prevent interactive prompts from firing +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + input: vi.fn(), +})); + +describe('newChangeCommand --domain', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-nc-domain-${randomUUID()}`); + await fs.mkdir(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); + // write a minimal openspec/config.yaml so schema resolution works + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + ); + process.chdir(tempDir); + }); + + afterEach(async () => { + vi.clearAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('creates change under domain when --domain provided', async () => { + await newChangeCommand('add-login', { domain: 'auth', json: true }); + + const changeDir = path.join(tempDir, 'openspec', 'changes', 'auth', 'add-login'); + const stat = await fs.stat(changeDir); + expect(stat.isDirectory()).toBe(true); + }); + + it('creates change under multi-level domain', async () => { + await newChangeCommand('add-login', { domain: 'auth/oauth', json: true }); + + const changeDir = path.join(tempDir, 'openspec', 'changes', 'auth', 'oauth', 'add-login'); + const stat = await fs.stat(changeDir); + expect(stat.isDirectory()).toBe(true); + }); + + it('throws on invalid domain segment', async () => { + await expect( + newChangeCommand('add-login', { domain: 'Auth', json: true }) + ).rejects.toThrow(); + }); + + it('throws when non-interactive (json mode) and --domain not provided', async () => { + // No --domain, --json forces non-interactive path + await expect( + newChangeCommand('add-login', { json: true }) + ).rejects.toThrow(/Domain selection is required/); + }); + + it('throws when stdin is not a TTY and --domain not provided', async () => { + // Simulate non-TTY stdin + const origIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + try { + await expect( + newChangeCommand('add-login', {}) + ).rejects.toThrow(/Domain selection is required/); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true }); + } + }); + + it('accepts explicit empty --domain as "no domain"', async () => { + await newChangeCommand('add-login', { domain: '', json: true }); + const changeDir = path.join(tempDir, 'openspec', 'changes', 'add-login'); + const stat = await fs.stat(changeDir); + expect(stat.isDirectory()).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm failure** + +```bash +pnpm test test/commands/workflow/new-change-domain.test.ts +``` + +- [ ] **Step 3: Add `domain` to `NewChangeOptions` and validate it** + +In `src/commands/workflow/new-change.ts`, update `NewChangeOptions`: + +```typescript +export interface NewChangeOptions { + description?: string; + goal?: string; + areas?: string; + schema?: string; + domain?: string; // ← new + initiative?: string; + store?: string; + storePath?: string; + json?: boolean; +} +``` + +At the top of `newChangeCommand`, after `assertInitiativeSelectorsHaveReference(options)` and before creating the change, add domain resolution: + +```typescript +import { validateDomainPath } from '../../utils/change-path.js'; + +// Resolve domain. Domain selection is MANDATORY — the CLI must never silently +// default to "no domain" when running non-interactively (agents/CI/--json), +// because the agent should consult the user instead of guessing. +let resolvedDomain: string; + +if (options.domain !== undefined) { + // Explicit --domain (including --domain "" for "no domain") + const domainValidation = validateDomainPath(options.domain); + if (!domainValidation.valid) { + throw new Error(domainValidation.error); + } + resolvedDomain = options.domain; +} else { + // --domain not provided. Decide based on environment. + const canPromptInteractively = !options.json && process.stdin.isTTY === true; + + if (!canPromptInteractively) { + // Non-interactive (agent / CI / --json / piped stdin): + // Refuse to default. Tell the caller (likely an AI agent) to ask the user. + const existing = await findAllChangeIds(planningHome.changesDir); + const existingDomains = Array.from( + new Set( + existing + .map((id) => id.split('/').slice(0, -1).join('/')) + .filter((d) => d.length > 0) + ) + ).sort(); + + const existingHint = existingDomains.length > 0 + ? `\nExisting domains in this project:\n ${existingDomains.join('\n ')}` + : '\n(No domains exist yet in this project.)'; + + throw new Error( + `Domain selection is required and cannot be skipped.\n\n` + + `This environment does not support interactive prompts ` + + `(no TTY / --json mode).\n` + + `If you are an AI agent: please ask the user which domain to place this ` + + `change under, then re-run with one of:\n\n` + + ` --domain e.g. --domain auth/oauth\n` + + ` --domain "" to place the change at the root (no domain)\n` + + existingHint + ); + } + + // Interactive path: prompt level by level + resolvedDomain = await selectDomainInteractive(planningHome.changesDir); +} + +// Build the full change id: domain/name or just name +const fullId = resolvedDomain ? `${resolvedDomain}/${name}` : name; +``` + +Then pass `fullId` instead of `name` to `createChange`: +```typescript +const result = await createChange(projectRoot, fullId, { ... }); +``` + +And update `outputForCreatedChange` call to use `fullId`: +```typescript +const payload = outputForCreatedChange(fullId, result.changeDir, result.schema, initiative); +``` + +- [ ] **Step 4: Implement `selectDomainInteractive` in the same file** + +Add this function before `newChangeCommand`: + +```typescript +import { findAllChangeIds } from '../../utils/change-path.js'; + +/** + * Interactively guides the user to pick or create a domain, level by level. + * Returns a slash-separated domain string (e.g. "auth/oauth") or "" for no domain. + */ +async function selectDomainInteractive(changesDir: string): Promise { + const { select, input } = await import('@inquirer/prompts'); + const { validateChangeName } = await import('../../utils/change-utils.js'); + const { promises: fs } = await import('fs'); + const path = await import('path'); + + let currentPath = changesDir; + const selectedSegments: string[] = []; + + while (true) { + // Discover immediate subdirectories that are domain containers (not leaf changes) + let subDomains: string[] = []; + try { + const entries = await fs.readdir(currentPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const fullPath = path.join(currentPath, entry.name); + // Only offer domain containers (directories without .openspec.yaml and without proposal.md) + let hasYaml = false; + let hasProposal = false; + try { await fs.access(path.join(fullPath, '.openspec.yaml')); hasYaml = true; } catch {} + try { await fs.access(path.join(fullPath, 'proposal.md')); hasProposal = true; } catch {} + if (!hasYaml && !hasProposal) { + subDomains.push(entry.name); + } + } + } catch {} + subDomains.sort(); + + const currentDomainLabel = selectedSegments.length > 0 + ? selectedSegments.join('/') + '/' + : ''; + + const choices = [ + { + name: selectedSegments.length === 0 + ? '[Create here, no domain]' + : `[Create under ${currentDomainLabel}]`, + value: '__here__', + }, + ...subDomains.map(d => ({ + name: currentDomainLabel + d + '/', + value: d, + })), + { + name: selectedSegments.length === 0 ? '[Create new domain…]' : '[Create new sub-domain…]', + value: '__new__', + }, + ]; + + const answer = await select({ + message: selectedSegments.length === 0 + ? 'Select domain (optional)' + : `Place under ${currentDomainLabel}, or go deeper?`, + choices, + }); + + if (answer === '__here__') { + return selectedSegments.join('/'); + } + + if (answer === '__new__') { + let newSegment = ''; + while (true) { + newSegment = await input({ + message: 'Enter new domain name (kebab-case):', + }); + const check = validateChangeName(newSegment); + if (check.valid) break; + console.log(`Invalid: ${check.error}. Try again.`); + } + selectedSegments.push(newSegment); + return selectedSegments.join('/'); + } + + // Descend into chosen subdomain + selectedSegments.push(answer); + currentPath = path.join(currentPath, answer); + } +} +``` + +- [ ] **Step 5: Add `--domain` option to `src/cli/index.ts`** + +Find the `newCmd` block: +```typescript +newCmd + .command('change ') + .description('Create a new change directory') + .option('--description ', ...) + // ... + .option('--schema ', ...) + .option('--json', 'Output as JSON') + .action(async (name: string, options: NewChangeOptions) => { +``` + +Add `--domain` option: +```typescript + .option('--domain ', 'Domain path for the change (e.g. auth/oauth), slash-separated') +``` + +- [ ] **Step 6: Run tests** + +```bash +pnpm test test/commands/workflow/new-change-domain.test.ts +pnpm test +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/commands/workflow/new-change.ts src/cli/index.ts test/commands/workflow/new-change-domain.test.ts +git commit -m "feat: new change command supports domain selection (interactive and --domain flag)" +``` + +--- + +## Task 8: Update completion and skill templates + +**Files:** +- Modify: `src/commands/completion.ts` +- Modify: `src/core/templates/workflows/archive-change.ts` +- Modify: `src/core/templates/workflows/bulk-archive-change.ts` + +- [ ] **Step 1: Update `src/commands/completion.ts`** + +`getArchivedChangeIds` already points to `openspec/archive/` after Task 2. Verify the import is `from '../utils/item-discovery.js'` and no further changes are needed here. + +Run: +```bash +pnpm test test/commands/completion.test.ts +``` + +- [ ] **Step 2: Update archive path in `src/core/templates/workflows/archive-change.ts`** + +Find all occurrences of the shell snippet: +```bash +mkdir -p "/archive" +mv "" "/archive/YYYY-MM-DD-" +``` + +Replace with: +```bash +mkdir -p "/archive/" +mv "" "/archive//YYYY-MM-DD-" +``` + +And update the explanatory text block that says "Create an `archive` directory under `planningHome.changesDir`" to say "Create the archive path under `openspecDir/archive/`, preserving domain hierarchy from the change id." + +Also update the **Output On Success** example to show `archive/auth/oauth/2025-01-23-add-login/` for domain-nested changes. + +- [ ] **Step 3: Update `src/core/templates/workflows/bulk-archive-change.ts`** + +Apply the same replacements as Step 2 — same shell snippet, same explanatory text, same output examples. + +- [ ] **Step 4: Run full test suite** + +```bash +pnpm test +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/completion.ts src/core/templates/workflows/archive-change.ts src/core/templates/workflows/bulk-archive-change.ts +git commit -m "feat: update completion and skill templates for new archive structure" +``` + +--- + +## Task 9: Final integration check + +- [ ] **Step 1: Build the project** + +```bash +pnpm run build +``` +Expected: no TypeScript errors. + +- [ ] **Step 2: Run the full test suite** + +```bash +pnpm test +``` +Expected: all green. + +- [ ] **Step 3: Manual smoke test — create a domain-nested change** + +```bash +node bin/openspec.js new change add-login --domain auth/oauth --json +``` +Expected JSON output containing `"id": "auth/oauth/add-login"` and the correct path. + +Verify directory was created: +```bash +ls openspec/changes/auth/oauth/ +``` +Expected: `add-login/` directory exists. + +- [ ] **Step 4: Manual smoke test — archive a domain-nested change** + +```bash +node bin/openspec.js archive auth/oauth/add-login --yes +``` +Expected output: `Change 'auth/oauth/add-login' archived to 'openspec/archive/auth/oauth/-add-login'.` + +Verify: +```bash +ls openspec/archive/auth/oauth/ +``` +Expected: `-add-login/` directory. + +- [ ] **Step 4b: Manual smoke test — non-interactive enforcement** + +```bash +node bin/openspec.js new change add-something --json +``` +Expected: command exits non-zero with an error message containing `Domain selection is required` and instructing the agent to re-run with `--domain ` or `--domain ""`. + +```bash +node bin/openspec.js new change add-something --domain "" --json +``` +Expected: succeeds and creates `openspec/changes/add-something/` (no domain). + +- [ ] **Step 5: Commit if any final fixes were needed; otherwise just tag** + +```bash +git add -A +git commit -m "fix: integration smoke test fixes" # only if needed +``` diff --git a/docs/superpowers/plans/2026-06-04-loosen-domain-validation.md b/docs/superpowers/plans/2026-06-04-loosen-domain-validation.md new file mode 100644 index 0000000000..badce5b85b --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-loosen-domain-validation.md @@ -0,0 +1,601 @@ +# Loosen Domain Validation + Skill Soft Check Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Demote OpenSpec's "domain segments must be lowercase kebab-case" rule from CLI hard-validation to a skill-surfaced recommendation, so AI agents stop silently lowercase-and-kebab-casing user-provided domain literals like `Performance/SignificanceManager`. + +**Architecture:** CLI's `validateDomainPath` is rewritten to a lenient filesystem-safety check (allows letters of any case, digits, hyphens, underscores, dots; rejects empty, `.`, `..`, whitespace). Both `openspec-propose` and `openspec-new-change` skill templates get a soft-check step that compares user input against the kebab-case pattern and uses AskUserQuestion to offer convert / keep-as-is / pick-different. Plus a Guardrail forbidding silent transformation. The mandatory-domain CLI error gains a cross-platform case-insensitivity note. + +**Tech Stack:** TypeScript, Node.js `fs/promises`, Vitest, the existing OpenSpec skill template machinery. + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/utils/change-path.ts` | Modify (lines 24–42) | Rewrite `validateDomainPath` to lenient mode | +| `test/utils/change-path.test.ts` | Modify (lines 48–78) | Flip uppercase/underscore tests; add CamelCase/dots/`.`/`..`/whitespace cases | +| `src/commands/workflow/new-change.ts` | Modify (mandatory-domain error block, ~line 240–260) | Append cross-platform case note | +| `test/commands/workflow/new-change-domain.test.ts` | Modify | Replace "rejects uppercase" with "accepts uppercase"; keep double-slash reject; add cross-platform note assertion | +| `src/core/templates/workflows/propose.ts` | Modify (skill + command templates) | Insert soft-check step before `openspec new change`; add Guardrail | +| `src/core/templates/workflows/new-change.ts` | Modify (skill + command templates) | Same | +| `test/core/templates/skill-templates-parity.test.ts` | Modify | Update 4 SHA-256 hashes (2 functions × 2 templates) + 2 generated payloads | + +--- + +## Task 1: Loosen `validateDomainPath` + +**Files:** +- Modify: `src/utils/change-path.ts:24-42` +- Modify: `test/utils/change-path.test.ts:48-78` + +- [ ] **Step 1: Replace the `validateDomainPath` test block** + +Replace lines 48-78 of `test/utils/change-path.test.ts` with: + +```typescript +describe('validateDomainPath', () => { + it('accepts empty string (no domain)', () => { + expect(validateDomainPath('')).toEqual({ valid: true }); + }); + + it('accepts single kebab-case segment', () => { + expect(validateDomainPath('auth')).toEqual({ valid: true }); + }); + + it('accepts multi-level kebab-case path', () => { + expect(validateDomainPath('auth/oauth')).toEqual({ valid: true }); + }); + + it('accepts uppercase segment (lenient mode)', () => { + expect(validateDomainPath('Auth')).toEqual({ valid: true }); + }); + + it('accepts CamelCase segment (lenient mode)', () => { + expect(validateDomainPath('Performance/SignificanceManager')).toEqual({ valid: true }); + }); + + it('accepts segment with underscore (lenient mode)', () => { + expect(validateDomainPath('my_domain')).toEqual({ valid: true }); + }); + + it('accepts segment with dot (e.g. v1.2)', () => { + expect(validateDomainPath('release/v1.2')).toEqual({ valid: true }); + }); + + it('accepts mixed-case multi-level path', () => { + expect(validateDomainPath('Auth/OAuth/v2_beta')).toEqual({ valid: true }); + }); + + it('rejects empty segment (double slash)', () => { + const result = validateDomainPath('auth//oauth'); + expect(result.valid).toBe(false); + expect(result.error).toContain('empty'); + }); + + it('rejects leading slash', () => { + const result = validateDomainPath('/auth'); + expect(result.valid).toBe(false); + expect(result.error).toContain('empty'); + }); + + it('rejects trailing slash', () => { + const result = validateDomainPath('auth/'); + expect(result.valid).toBe(false); + expect(result.error).toContain('empty'); + }); + + it("rejects pure-dot segment '.'", () => { + const result = validateDomainPath('.'); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/'\.'/); + }); + + it("rejects pure-dot segment '..'", () => { + const result = validateDomainPath('foo/..'); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/'\.\.'/); + }); + + it('rejects segment containing whitespace', () => { + const result = validateDomainPath('foo bar'); + expect(result.valid).toBe(false); + expect(result.error).toContain('foo bar'); + }); + + it('rejects segment with backslash', () => { + const result = validateDomainPath('foo\\bar'); + expect(result.valid).toBe(false); + expect(result.error).toContain('foo\\bar'); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +node_modules/.bin/vitest run test/utils/change-path.test.ts +``` + +Expected: failures on `accepts uppercase`, `accepts CamelCase`, `accepts underscore`, `accepts dot`, `accepts mixed-case`, `rejects pure-dot '.'`, `rejects pure-dot '..'`, `rejects whitespace`, `rejects backslash`. + +- [ ] **Step 3: Rewrite `validateDomainPath` in `src/utils/change-path.ts`** + +Replace lines 24-42 with: + +```typescript +/** + * Validates that a domain path string (e.g. "auth/oauth") is filesystem-safe. + * + * Lenient mode: accepts any segment composed of letters (any case), digits, + * hyphens, underscores, and dots. Rejects empty segments (e.g. "//" or + * leading/trailing slash), pure-dot segments ("." or ".."), whitespace, and + * backslashes. Empty string means "no domain" and is always valid. + * + * Note: lowercase kebab-case is the OpenSpec recommendation but NOT enforced + * here. Skills (openspec-propose, openspec-new-change) surface the + * recommendation to users via AskUserQuestion. See + * docs/superpowers/specs/2026-06-04-loosen-domain-validation-design.md. + */ +export function validateDomainPath(domainPath: string): ValidationResult { + if (domainPath === '') return { valid: true }; + + const ALLOWED_SEGMENT = /^[A-Za-z0-9._-]+$/; + const segments = domainPath.split('/'); + + for (const segment of segments) { + if (segment === '') { + return { + valid: false, + error: `Domain path '${domainPath}' contains an empty segment (double slash, leading slash, or trailing slash)`, + }; + } + if (segment === '.' || segment === '..') { + return { + valid: false, + error: `Domain segment '${segment}' is not allowed (filesystem traversal)`, + }; + } + if (!ALLOWED_SEGMENT.test(segment)) { + return { + valid: false, + error: `Domain segment '${segment}' contains invalid characters; allowed: letters, digits, '-', '_', '.'`, + }; + } + } + + return { valid: true }; +} +``` + +Also remove the now-unused `validateChangeName` import IF nothing else in the file uses it. Verify by: + +```bash +node_modules/.bin/grep -n "validateChangeName" src/utils/change-path.ts +``` + +If only the import line matches, remove it. (Note: keep the `ValidationResult` type import.) + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +node_modules/.bin/vitest run test/utils/change-path.test.ts +``` + +Expected: all 16+ tests pass. + +- [ ] **Step 5: Run full suite to catch regressions** + +```bash +node_modules/.bin/vitest run +``` + +Expected: any pre-existing test that depended on uppercase being rejected (most likely in `test/commands/workflow/new-change-domain.test.ts`) now fails. We fix those in Task 5. Other tests should pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/utils/change-path.ts test/utils/change-path.test.ts +git commit -m "feat: loosen validateDomainPath to lenient filesystem-safety check" +``` + +--- + +## Task 2: Append cross-platform case note to mandatory-domain error + +**Files:** +- Modify: `src/commands/workflow/new-change.ts` (the mandatory-domain throw block) + +- [ ] **Step 1: Locate the mandatory-domain throw** + +Run: +```bash +node_modules/.bin/grep -n "Domain selection is required" src/commands/workflow/new-change.ts +``` + +Expected: one match around line 245-260, inside the `if (!canPromptInteractively)` block. + +- [ ] **Step 2: Append the cross-platform note line** + +Find the line: +```typescript +existingHint +); +``` + +Just before that line, change `existingHint` to be followed by a `crossPlatformNote`: + +Replace this section in `src/commands/workflow/new-change.ts`: + +```typescript + throw new Error( + `Domain selection is required and cannot be skipped.\n\n` + + `This environment does not support interactive prompts ` + + `(no TTY / --json mode).\n` + + `If you are an AI agent: please ask the user which domain to place ` + + `this change under, then re-run with one of:\n\n` + + ` --domain e.g. --domain auth/oauth\n` + + ` --domain "" to place the change at the root (no domain)\n` + + existingHint + ); +``` + +With: + +```typescript + throw new Error( + `Domain selection is required and cannot be skipped.\n\n` + + `This environment does not support interactive prompts ` + + `(no TTY / --json mode).\n` + + `If you are an AI agent: please ask the user which domain to place ` + + `this change under, then re-run with one of:\n\n` + + ` --domain e.g. --domain auth/oauth\n` + + ` --domain "" to place the change at the root (no domain)\n` + + existingHint + + `\n\nNote: domain segments are case-preserving. On case-insensitive ` + + `file systems (Windows, macOS default APFS) different-case variants ` + + `of the same name (e.g. 'Performance' vs 'performance') will collide.` + ); +``` + +- [ ] **Step 3: Run existing domain tests to confirm nothing broke** + +```bash +node_modules/.bin/vitest run test/commands/workflow/new-change-domain.test.ts +``` + +The existing assertion `toThrow(/Domain selection is required/)` still matches (the prefix is unchanged). Expected: existing 9 tests still pass; the test that asserts uppercase rejection (~line 67) now fails — we fix in Task 5. + +- [ ] **Step 4: Add a test asserting the cross-platform note appears** + +Append to the `'non-interactive enforcement (mandatory domain decision)'` describe block in `test/commands/workflow/new-change-domain.test.ts`: + +```typescript + it('mandatory error includes cross-platform case note', async () => { + try { + await newChangeCommand('add-login', { json: true }); + throw new Error('expected newChangeCommand to throw'); + } catch (err) { + const msg = (err as Error).message; + expect(msg).toMatch(/case-insensitive/); + expect(msg).toMatch(/Performance.*performance/); + } + }); +``` + +- [ ] **Step 5: Run the new test** + +```bash +node_modules/.bin/vitest run test/commands/workflow/new-change-domain.test.ts -t "case note" +``` + +Expected: pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/commands/workflow/new-change.ts test/commands/workflow/new-change-domain.test.ts +git commit -m "feat: add cross-platform case note to mandatory-domain error" +``` + +--- + +## Task 3: Update `openspec-propose` skill — soft check + Guardrail + +**Files:** +- Modify: `src/core/templates/workflows/propose.ts` (both `getOpsxProposeSkillTemplate` and `getOpsxProposeCommandTemplate`) +- Modify: `test/core/templates/skill-templates-parity.test.ts` + +- [ ] **Step 1: Insert soft-check step in the skill template** + +In `src/core/templates/workflows/propose.ts`, find the `**Steps**` section in the `instructions` field (around line 27). The current Step 2 reads: + +``` +2. **Create the change directory** + ```bash + openspec new change "" + ``` + This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. +``` + +Replace it with: + +``` +2. **Determine the domain (mandatory; never skip)** + + Use the **AskUserQuestion tool** to ask: + > "What domain should this change live under? (e.g. \`auth\`, \`auth/oauth\`, or leave empty for no domain.) OpenSpec recommends lowercase kebab-case." + + When the user replies, check whether their answer matches \`/^[a-z0-9-]+(\\/[a-z0-9-]+)*$/\` (or is empty). + + - **If it matches** (or is empty): proceed to Step 3 with that value. + - **If it does not match** (e.g. user typed \`Performance/SignificanceManager\`): compute a kebab-case suggestion (lowercase letters, split CamelCase boundaries with hyphens, replace underscores with hyphens, e.g. \`Performance/SignificanceManager\` → \`performance/significance-manager\`). Then use **AskUserQuestion** again with these options: + - \`Convert to \` (recommended) + - \`Keep as-is: \` (CLI accepts it; just doesn't match the convention) + - \`Pick a different domain\` (Other → re-ask) + + **NEVER convert silently.** The user owns their literal — only transform with explicit consent. + +3. **Create the change directory** + \`\`\`bash + openspec new change "" --domain "" + \`\`\` + Pass the value the user chose in Step 2 as \`--domain\` (use \`--domain ""\` for "no domain"). This creates a scaffolded change in the planning home resolved by the CLI with \`.openspec.yaml\`. +``` + +Renumber subsequent steps (4, 5, 6) accordingly. + +- [ ] **Step 2: Add Guardrail to the skill template** + +In the same `instructions` field, find the `**Guardrails**` block (last section). Append: + +``` +- **Don't silently transform user-specified literals** - When the user provides a domain that doesn't match OpenSpec's kebab-case recommendation, present the choice via AskUserQuestion (convert / keep as-is / pick different) and let the user decide. NEVER lowercase, kebab-case, or otherwise modify the literal on your own. (Deriving a kebab-case name from a freeform description like "add user auth" is fine — there's no specific literal there to preserve.) +``` + +- [ ] **Step 3: Apply the same insertion to the command template** + +In `getOpsxProposeCommandTemplate.content` (around line 119–225), repeat both edits — Step 2 insertion and Guardrail addition — so the slash-command and skill stay in sync. Use the exact same text. + +- [ ] **Step 4: Run parity tests to observe the new hashes** + +```bash +node_modules/.bin/vitest run test/core/templates/skill-templates-parity.test.ts +``` + +Expected: 2 failures (`getOpsxProposeSkillTemplate`, `getOpsxProposeCommandTemplate`, and `openspec-propose` generated payload). The output diff prints actual hashes prefixed with `+`. Note them. + +- [ ] **Step 5: Update expected hashes** + +In `test/core/templates/skill-templates-parity.test.ts`, replace: +- the value of `getOpsxProposeSkillTemplate` in `EXPECTED_FUNCTION_HASHES` with the new actual hash +- the value of `getOpsxProposeCommandTemplate` in `EXPECTED_FUNCTION_HASHES` with the new actual hash +- the value of `'openspec-propose'` in `EXPECTED_GENERATED_SKILL_CONTENT_HASHES` with the new actual hash + +- [ ] **Step 6: Re-run parity test** + +```bash +node_modules/.bin/vitest run test/core/templates/skill-templates-parity.test.ts +``` + +Expected: all 30 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/core/templates/workflows/propose.ts test/core/templates/skill-templates-parity.test.ts +git commit -m "feat(propose-skill): add domain soft-check step and don't-transform guardrail" +``` + +--- + +## Task 4: Update `openspec-new-change` skill — soft check + Guardrail + +**Files:** +- Modify: `src/core/templates/workflows/new-change.ts` (both `getNewChangeSkillTemplate` and `getOpsxNewCommandTemplate`) +- Modify: `test/core/templates/skill-templates-parity.test.ts` + +- [ ] **Step 1: Insert the soft-check step** + +In `src/core/templates/workflows/new-change.ts`, find Step 3 of `getNewChangeSkillTemplate.instructions` (the `openspec new change ""` block, around line 38–43). Insert a new step BEFORE it: + +``` +3. **Determine the domain (mandatory; never skip)** + + Use the **AskUserQuestion tool** to ask: + > "What domain should this change live under? (e.g. \`auth\`, \`auth/oauth\`, or leave empty for no domain.) OpenSpec recommends lowercase kebab-case." + + When the user replies, check whether their answer matches \`/^[a-z0-9-]+(\\/[a-z0-9-]+)*$/\` (or is empty). + + - **If it matches** (or is empty): proceed with that value. + - **If it does not match** (e.g. \`Performance/SignificanceManager\`): compute a kebab-case suggestion (lowercase letters, split CamelCase boundaries with hyphens, replace underscores with hyphens). Then use **AskUserQuestion** with: + - \`Convert to \` (recommended) + - \`Keep as-is: \` + - \`Pick a different domain\` + + **NEVER convert silently.** +``` + +The current Step 3 (`Create the change directory`) becomes Step 4. Update its bash to pass the resolved domain: + +```bash +openspec new change "" --domain "" +``` + +Renumber subsequent steps (5, 6, 7) accordingly. + +- [ ] **Step 2: Add Guardrail** + +Append to the `**Guardrails**` block of `getNewChangeSkillTemplate.instructions`: + +``` +- **Don't silently transform user-specified literals** - When the user provides a domain that doesn't match OpenSpec's kebab-case recommendation, present the choice via AskUserQuestion (convert / keep as-is / pick different) and let the user decide. NEVER lowercase, kebab-case, or otherwise modify the literal on your own. +``` + +- [ ] **Step 3: Apply the same edits to the command template** + +In `getOpsxNewCommandTemplate.content` (lower half of the file), insert the same new step + same Guardrail. + +- [ ] **Step 4: Run parity tests to observe the new hashes** + +```bash +node_modules/.bin/vitest run test/core/templates/skill-templates-parity.test.ts +``` + +Expected: 2 failures (`getNewChangeSkillTemplate`, `getOpsxNewCommandTemplate`, and `openspec-new-change` generated payload). Note the new actual hashes from the diff. + +- [ ] **Step 5: Update expected hashes** + +In `test/core/templates/skill-templates-parity.test.ts`, replace: +- `getNewChangeSkillTemplate` in `EXPECTED_FUNCTION_HASHES` +- `getOpsxNewCommandTemplate` in `EXPECTED_FUNCTION_HASHES` +- `'openspec-new-change'` in `EXPECTED_GENERATED_SKILL_CONTENT_HASHES` + +- [ ] **Step 6: Re-run parity test** + +```bash +node_modules/.bin/vitest run test/core/templates/skill-templates-parity.test.ts +``` + +Expected: all 30 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/core/templates/workflows/new-change.ts test/core/templates/skill-templates-parity.test.ts +git commit -m "feat(new-change-skill): add domain soft-check step and don't-transform guardrail" +``` + +--- + +## Task 5: Adjust the existing "rejects uppercase" domain test + +**Files:** +- Modify: `test/commands/workflow/new-change-domain.test.ts` + +The current test asserts uppercase domains throw. With the lenient validation from Task 1, they no longer throw. Flip the assertion. + +- [ ] **Step 1: Find the test** + +```bash +node_modules/.bin/grep -n "rejects invalid domain segment" test/commands/workflow/new-change-domain.test.ts +``` + +Expected: one match around line 64–68. + +- [ ] **Step 2: Replace the test** + +Find: + +```typescript + it('rejects invalid domain segment (uppercase)', async () => { + await expect( + newChangeCommand('add-login', { domain: 'Auth', json: true }) + ).rejects.toThrow(); + }); +``` + +Replace with: + +```typescript + it('accepts uppercase / CamelCase domain (lenient mode)', async () => { + await newChangeCommand('add-login', { domain: 'Performance/SignificanceManager', json: true }); + const changeDir = path.join( + tempDir, + 'openspec', + 'changes', + 'Performance', + 'SignificanceManager', + 'add-login' + ); + const stat = await fs.stat(changeDir); + expect(stat.isDirectory()).toBe(true); + }); +``` + +- [ ] **Step 3: Run the file** + +```bash +node_modules/.bin/vitest run test/commands/workflow/new-change-domain.test.ts +``` + +Expected: all tests pass (the new test creates the directory; double-slash test still rejects). + +- [ ] **Step 4: Commit** + +```bash +git add test/commands/workflow/new-change-domain.test.ts +git commit -m "test: assert uppercase/CamelCase domain accepted in lenient mode" +``` + +--- + +## Task 6: Final integration check + smoke tests + +- [ ] **Step 1: Build** + +```bash +node build.js +``` +Expected: `✅ Build completed successfully!` + +- [ ] **Step 2: Run the full test suite** + +```bash +node_modules/.bin/vitest run +``` +Expected: all green (1696+ tests pass, 0 failures). + +- [ ] **Step 3: Smoke test — uppercase domain via CLI** + +Since openspec is `npm link`-ed to this dir, dist updates are live for the global `openspec`. + +```bash +SMOKE_DIR="/tmp/openspec-loosen-smoke-$$" && \ + mkdir -p "$SMOKE_DIR/openspec/changes" && \ + echo 'schema: spec-driven' > "$SMOKE_DIR/openspec/config.yaml" && \ + cd "$SMOKE_DIR" && \ + openspec new change foo --json --domain "Performance/SignificanceManager"; \ + echo "exit=$?"; \ + ls openspec/changes/Performance/SignificanceManager/foo/ ; \ + cd / && rm -rf "$SMOKE_DIR" +``` + +Expected: exit 0; JSON output containing `"id": "Performance/SignificanceManager/foo"`; the directory listing shows `.openspec.yaml` (or proposal.md). + +- [ ] **Step 4: Smoke test — invalid domain still rejected** + +```bash +SMOKE_DIR="/tmp/openspec-loosen-smoke-$$" && \ + mkdir -p "$SMOKE_DIR/openspec/changes" && \ + echo 'schema: spec-driven' > "$SMOKE_DIR/openspec/config.yaml" && \ + cd "$SMOKE_DIR" && \ + openspec new change foo --json --domain "auth//oauth"; \ + echo "exit=$?"; \ + cd / && rm -rf "$SMOKE_DIR" +``` + +Expected: exit 1; error message contains `empty segment`. + +- [ ] **Step 5: Smoke test — `..` rejected** + +```bash +SMOKE_DIR="/tmp/openspec-loosen-smoke-$$" && \ + mkdir -p "$SMOKE_DIR/openspec/changes" && \ + echo 'schema: spec-driven' > "$SMOKE_DIR/openspec/config.yaml" && \ + cd "$SMOKE_DIR" && \ + openspec new change foo --json --domain "auth/.."; \ + echo "exit=$?"; \ + cd / && rm -rf "$SMOKE_DIR" +``` + +Expected: exit 1; error message contains `filesystem traversal`. + +- [ ] **Step 6: Final commit (only if integration fixes were needed)** + +```bash +git status +# If clean: nothing to commit; skip. +# Otherwise: review, stage, commit with a descriptive message. +``` diff --git a/docs/superpowers/plans/2026-07-15-domain-archive-upstream-reimplementation.md b/docs/superpowers/plans/2026-07-15-domain-archive-upstream-reimplementation.md new file mode 100644 index 0000000000..f288864812 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-domain-archive-upstream-reimplementation.md @@ -0,0 +1,144 @@ +# Domain Archive Upstream Reimplementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reimplement the approved multi-level domain, sibling archive, mandatory domain decision, and lenient domain validation requirements on official OpenSpec 1.6.0 at upstream commit `0a99f41`. + +**Architecture:** Add `src/utils/change-path.ts` as the canonical path and discovery layer. Route both local roots and registered stores through `ResolvedOpenSpecRoot`, whose archive directory becomes `openspec/archive`; all CLI, workflow, discovery, archive, completion, initialization, and generated-agent consumers use the same helpers and root fields. + +**Tech Stack:** TypeScript, Node.js filesystem APIs, Commander, `@inquirer/prompts`, Vitest, pnpm. + +## Global Constraints + +- Preserve official OpenSpec 1.6.0 Store and root-selection behavior. +- Change IDs use forward-slash relative paths such as `auth/oauth/add-login` on every OS. +- Leaf change names remain lowercase kebab-case; domain segments are case-preserving and filesystem-safe. +- Non-interactive or JSON creation requires an explicit `--domain`, including `--domain ""` for root placement. +- Legacy `openspec/changes/archive` is not created and is ignored during active-change discovery. +- No automatic migration of legacy archives. + +--- + +### Task 1: Canonical domain path helpers + +**Files:** +- Create: `src/utils/change-path.ts` +- Create: `test/utils/change-path.test.ts` + +**Interfaces:** +- Produces: `splitChangeId(id)`, `validateDomainPath(domainPath)`, `buildArchivePath(archiveDir, changeId, date)`, `findAllChangeIds(changesDir)`, and `findAllArchivedChangeIds(archiveDir)`. +- Discovery recognizes `.openspec.yaml` or `proposal.md`, returns sorted forward-slash IDs, skips hidden directories, and skips only the root-level legacy `archive` directory for active changes. + +- [ ] Write failing tests for root, one-level, and multi-level IDs; archive path preservation; lenient valid segments; traversal, whitespace, backslash, empty, and leading-dot rejection; recursive discovery; and legacy archive exclusion. +- [ ] Run `node_modules/.bin/vitest.cmd run test/utils/change-path.test.ts` and confirm the module-not-found failure. +- [ ] Implement the five exported helpers with an internal recursive walker and an explicit `skipLegacyArchive` option. +- [ ] Run the helper tests and confirm all pass. + +### Task 2: Root structure and Store parity + +**Files:** +- Modify: `src/core/openspec-root.ts` +- Modify: `src/core/root-selection.ts` +- Modify: `src/core/init.ts` +- Modify: `src/utils/change-utils.ts` +- Modify: Store/root fixtures and tests under `test/core`, `test/commands`, `test/helpers`, and `test/cli-e2e`. + +**Interfaces:** +- `OPENSPEC_ARCHIVE_DIR` becomes `openspec/archive`. +- Every `ResolvedOpenSpecRoot.archiveDir` becomes `/openspec/archive`. +- Initialization anchors `openspec/specs` and `openspec/archive`, never `openspec/changes/archive`. + +- [ ] Update root, Store, init, and create-change tests to expect sibling archive layout. +- [ ] Run the focused tests and confirm failures reference the old nested archive path. +- [ ] Change root constants, root construction, initialization, scaffold, and fixture paths to the sibling archive. +- [ ] Run focused root/Store/init tests and confirm local and registered Store behavior passes. + +### Task 3: Recursive consumers and slash-ID validation + +**Files:** +- Modify: `src/utils/item-discovery.ts` +- Modify: `src/core/list.ts` +- Modify: `src/core/view.ts` +- Modify: `src/commands/workflow/shared.ts` +- Modify: `src/core/planning-home.ts` if path formatting requires normalization. +- Modify: corresponding tests. + +**Interfaces:** +- All active consumers call `findAllChangeIds`. +- Archived consumers call `findAllArchivedChangeIds(root.archiveDir)`. +- `validateChangeExists` validates the final change segment strictly and domain segments with `validateDomainPath`. + +- [ ] Add failing nested-domain tests for list, view, item discovery, available changes, and existence validation. +- [ ] Replace top-level directory scans with canonical discovery helpers. +- [ ] Resolve paths with split ID segments and preserve forward-slash IDs in output. +- [ ] Run focused consumer tests and confirm all pass. + +### Task 4: Domain-aware creation with mandatory user decision + +**Files:** +- Modify: `src/utils/change-utils.ts` +- Modify: `src/commands/workflow/new-change.ts` +- Modify: `src/cli/index.ts` +- Create: `test/commands/workflow/new-change-domain.test.ts` +- Modify: existing CLI and Store tests that create changes non-interactively. + +**Interfaces:** +- `NewChangeOptions.domain?: string` and CLI `--domain `. +- `createChange(root, fullId, options)` validates domain and leaf name independently. +- Interactive creation selects or creates domain levels; JSON/no-TTY creation without `domain` emits an actionable error and existing-domain hints. + +- [ ] Add failing tests for local and Store root creation, explicit empty domain, nested domain, lenient casing, invalid paths, and mandatory non-interactive enforcement. +- [ ] Implement slash-ID creation and interactive domain selection without changing Store selection semantics. +- [ ] Add `--domain` to Commander and update all scripted tests to state `--domain ""` where root placement is intended. +- [ ] Run new-change, artifact workflow, Store, and root-selection tests. + +### Task 5: Domain-preserving archive and spec application + +**Files:** +- Modify: `src/core/archive.ts` +- Modify: `src/core/specs-apply.ts` if domain prefix is not already supported. +- Modify: `test/core/archive.test.ts` +- Modify: Store archive E2E tests. + +**Interfaces:** +- Archive source is `//`. +- Archive destination is `buildArchivePath(root.archiveDir, changeId, date)`. +- Delta specs from a domain change apply beneath the matching domain prefix in `openspec/specs`. + +- [ ] Add failing root, nested-domain, Store, collision, and domain-prefixed spec-apply tests. +- [ ] Use recursive selection, slash-ID source resolution, recursive parent creation, and canonical archive path building. +- [ ] Pass the domain prefix into spec target resolution while keeping root-level behavior unchanged. +- [ ] Run archive and Store archive tests. + +### Task 6: Agent templates, completions, and user guidance + +**Files:** +- Modify: `src/core/templates/workflows/propose.ts` +- Modify: `src/core/templates/workflows/new-change.ts` +- Modify: `src/core/templates/workflows/ff-change.ts` +- Modify: `src/core/templates/workflows/onboard.ts` +- Modify: `src/core/templates/workflows/archive-change.ts` +- Modify: `src/core/templates/workflows/bulk-archive-change.ts` +- Modify: template parity tests and completion tests. + +**Interfaces:** +- Creation templates ask for a mandatory domain, recommend kebab-case, and never silently transform a literal. +- Archive templates derive the domain from the full change ID and target `openspec/archive//YYYY-MM-DD-`. +- Generated commands preserve `--store` and add the resolved `--domain`. + +- [ ] Add/update template assertions before changing generated content. +- [ ] Update all creation and archive guidance, including fast-forward and onboarding flows. +- [ ] Refresh parity hashes from deterministic generated content. +- [ ] Run template, completion, init, and update tests. + +### Task 7: Integration verification + +**Files:** +- Modify only files required by failures proven to be regressions from Tasks 1-6. + +- [ ] Run `node build.js` and confirm TypeScript compilation succeeds. +- [ ] Run `node_modules/.bin/vitest.cmd run` and confirm zero failures. +- [ ] Smoke-test local root and registered Store creation with `--domain "Performance/SignificanceManager"`. +- [ ] Smoke-test `auth//oauth`, `auth/..`, and missing non-interactive `--domain` failures. +- [ ] Smoke-test archiving a nested change to sibling `openspec/archive` with its domain preserved. +- [ ] Review `git diff --check`, `git status`, and the diff against `upstream/main`. diff --git a/docs/superpowers/specs/2026-06-03-domain-archive-restructure-design.md b/docs/superpowers/specs/2026-06-03-domain-archive-restructure-design.md new file mode 100644 index 0000000000..3de337603b --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-domain-archive-restructure-design.md @@ -0,0 +1,175 @@ +# Design: Domain Support & Archive Restructure + +**Date:** 2026-06-03 +**Status:** Approved + +## Summary + +Restructure the OpenSpec `changes/` and `archive/` directories to support multi-level domain grouping. Move `archive/` to be a sibling of `changes/` rather than nested inside it, and add an interactive domain-selection step when creating a new change. + +## Goals + +1. Remove `changes/archive/` — archive is no longer nested inside changes +2. `archive/` is a sibling of `changes/` under `openspec/` +3. `changes/` and `archive/` both support arbitrary multi-level domain hierarchies +4. Archiving a change preserves its domain hierarchy in `archive/` +5. Creating a change prompts the user to optionally select or create a domain, level by level + +## Directory Structure + +``` +openspec/ + changes/ + / # no domain (root level) + // # one-level domain + /// # multi-level domain + archive/ + -/ # no domain + /-/ # one-level domain + //-/ # multi-level domain +``` + +## Change ID Convention + +A change's **id** is its path relative to `changes/`, with the final segment being the change name (kebab-case) and preceding segments being domain levels: + +``` +add-login # id with no domain +auth/add-login # id with domain = auth +auth/oauth/add-login # id with domain = auth/oauth +``` + +**Naming constraints:** Every segment (domain segments and change name) must satisfy the existing kebab-case validation rule. No two changes may share the same id within a planning home. + +## Domain Selection Interaction (new change) + +When running `openspec new change ` interactively, after validating the change name, an optional domain-selection step is inserted before the directory is created. + +**Flow — level by level:** + +``` +? Select domain (optional, press Enter to skip) + ❯ [Create here, no domain] + auth/ + infra/ + [Create new domain…] + +# If auth/ is selected, descend one level: +? Place under auth/, or go deeper? + ❯ [Create under auth/] + auth/oauth/ + [Create new sub-domain…] + +# If [Create new domain…] or [Create new sub-domain…] is selected: +? Enter new domain name (kebab-case): _ +``` + +| Option | Meaning | +|--------|---------| +| `[Create here, no domain]` | Place change directly under `changes/` | +| `auth/` (existing directory) | Descend into that directory for the next level | +| `[Create under auth/]` | Place change under `changes/auth/` | +| `[Create new domain…]` | Prompt for a new kebab-case name; create that directory level | + +**Non-interactive / scripted use:** A new `--domain ` CLI option accepts a slash-separated domain path (e.g. `--domain auth/oauth`). Each segment is validated against the kebab-case rule. Use `--domain ""` (explicit empty string) to declare "no domain". + +**Agent / non-TTY enforcement (mandatory domain decision):** Domain selection cannot be silently skipped. When the CLI detects it cannot prompt interactively (no TTY on stdin, or `--json` mode) AND `--domain` was not provided, the command **must fail** with an agent-readable error instructing the caller to consult the human user. Example error: + +``` +Error: Domain selection is required and cannot be skipped. + +This environment does not support interactive prompts (no TTY / --json mode). +If you are an AI agent: please ask the user which domain to place this +change under, then re-run with one of: + + --domain e.g. --domain auth/oauth + --domain "" to place the change at the root (no domain) +``` + +The exit code is non-zero so agents can detect the failure programmatically. Existing changes' domain layout (under `openspec/changes/`) should be surfaced to the agent so it can present meaningful options to the user. + +## Archive Logic + +**Archive root:** `openspec/archive/` (sibling of `changes/`, not nested inside it). + +**Archive path calculation:** The domain portion of the change id is preserved; only the final segment (change name) receives the date prefix. + +``` +# No domain +changes/add-login/ +→ archive/2025-01-23-add-login/ + +# One-level domain +changes/auth/add-login/ +→ archive/auth/2025-01-23-add-login/ + +# Multi-level domain +changes/auth/oauth/add-login/ +→ archive/auth/oauth/2025-01-23-add-login/ +``` + +**Changes to `ArchiveCommand`:** +1. `archiveDir` becomes `path.join(openspecDir, 'archive')` instead of `path.join(changesDir, 'archive')` +2. Target path is built by joining `archiveDir`, the domain segments from the change id, and the date-prefixed change name +3. `selectChange()` is updated to recursively scan `changes/` for leaf-node change directories and display their full relative-path ids + +**Existing `changes/archive/` compatibility:** No automatic migration and no error. The old directory is simply ignored by all updated code paths. Users may manually migrate if desired. + +## Affected Files + +### Modified + +| File | Change | +|------|--------| +| `src/core/archive.ts` | New `archiveDir`; domain-aware path calc; recursive leaf scan | +| `src/core/list.ts` | Recursive scan of `changes/`, display full relative-path id | +| `src/core/view.ts` | Recursive scan replacing single-level `readdir` | +| `src/utils/item-discovery.ts` | `getActiveChangeIds` recurses to leaf nodes; `getArchivedChangeIds` scans `openspec/archive/` | +| `src/utils/change-utils.ts` | `createChange` accepts slash-separated id; joins `changesDir` + id | +| `src/commands/workflow/new-change.ts` | Domain-selection interactive step; `--domain` option | +| `src/core/planning-home.ts` | `getChangeDir` / `formatChangeLocation` compatible with multi-segment ids | +| `src/commands/completion.ts` | `getArchivedChangeIds` targets new archive root | +| `src/core/templates/workflows/archive-change.ts` | Update archive-path instructions in skill template | +| `src/core/templates/workflows/bulk-archive-change.ts` | Same | + +### Not Modified + +- `src/core/validation/` — content-based, path-agnostic +- `src/core/artifact-graph/` — receives `changeRoot` as parameter, no hardcoded paths +- `src/core/schemas/` — schema definitions, path-agnostic +- `src/telemetry/` — records command names only + +## New Helper Functions + +Suggested location: `src/utils/change-path.ts` + +```ts +// Split a full change id into domain segments and change name +splitChangeId(id: string): { domain: string[]; name: string } + +// Recursively find all leaf-node change directories under changesDir, +// returning relative-path ids +findAllChangeIds(changesDir: string): Promise + +// Same, but for the archive root +findAllArchivedChangeIds(archiveDir: string): Promise + +// Compute the archive target path for a given change id and date string +buildArchivePath(archiveDir: string, changeId: string, date: string): string +``` + +A "leaf node" is a directory that contains `.openspec.yaml` or `proposal.md`, indicating it is a change rather than a domain container. + +## Error Handling + +- If the user provides `--domain auth//oauth` or a segment that fails kebab-case validation, fail with a clear error message listing the invalid segment +- If a new domain name entered interactively fails validation, re-prompt rather than aborting +- If the computed archive target already exists, fail with an actionable message (same behaviour as today) +- **If the environment is non-interactive (no TTY, or `--json`) AND `--domain` was not provided, fail with a non-zero exit and the agent-readable error described in the "Agent / non-TTY enforcement" section above. Do NOT default to "no domain".** + +## Testing Notes + +- All path assertions in tests must use `path.join()` — no hardcoded slashes +- Add test cases for: no-domain change, single-level domain, two-level domain, archive path calculation for each case +- Add test cases for the `splitChangeId` and `buildArchivePath` helpers +- The domain selection interaction should be testable via the `--domain` flag without interactive prompts diff --git a/docs/superpowers/specs/2026-06-04-loosen-domain-validation-design.md b/docs/superpowers/specs/2026-06-04-loosen-domain-validation-design.md new file mode 100644 index 0000000000..8c6c0cacce --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-loosen-domain-validation-design.md @@ -0,0 +1,93 @@ +# Design: Loosen Domain Validation + Skill-Side Soft Check + +**Date:** 2026-06-04 +**Status:** Approved + +## Summary + +Demote OpenSpec's "domain segments must be lowercase kebab-case" rule from a CLI-enforced constraint to a skill-surfaced recommendation. The CLI accepts any filesystem-safe segment; the openspec-propose / openspec-new-change skills detect non-conforming domains and offer the user an explicit choice (convert / keep as-is / pick different) via AskUserQuestion. AI agents never silently transform user-provided domain literals. + +## Motivation + +A real-world failure: user asked for domain `Performance/SignificanceManager`. CLI rejected on kebab-case validation. AI silently transformed to `performance/significance-manager` and proceeded — without telling the user. The user only discovered the rename after the change directory was created. + +This is the same class of issue as the earlier "silently default to no domain" — AI taking unilateral decisions on behalf of the user when the CLI surfaces a constraint. + +The previous fix (mandatory `--domain` enforcement) closed the silent-default loophole. This fix closes the silent-transform loophole, while also acknowledging that the kebab-case rule for domain segments is a project convention, not a technical necessity. + +## Design + +### CLI: loosen `validateDomainPath` + +Today `validateDomainPath` calls `validateChangeName` on each segment, which enforces lowercase kebab-case. Replace with a lenient filesystem-safety check that allows: + +- Letters (any case): `a-z`, `A-Z` +- Digits: `0-9` +- Hyphens, underscores: `-`, `_` +- Dots inside a segment (but not as the entire segment) + +And rejects: + +- Empty segments (double slash `auth//oauth`) +- Pure-dot segments (`.` or `..`) +- Path separators inside a segment (handled by split) +- Whitespace (leading/trailing/internal) +- OS-reserved names (best effort: skip explicit reservation list — let OS surface its own error if user picks one) + +`validateChangeName` (used for the leaf change name like `add-login`) **stays strict** because the leaf name is used as a stable identifier in many places. + +### Skill: soft kebab-case check + explicit choice + +In both `openspec-propose` and `openspec-new-change` skill templates (and their command counterparts), insert a check step before invoking `openspec new change`: + +``` +If user-provided domain doesn't match /^[a-z0-9-]+(\/[a-z0-9-]+)*$/: + Use AskUserQuestion to surface: + - Convert to kebab-case form (compute by replacing CamelCase boundaries + with hyphens, lowercasing, replacing underscores with hyphens) + - Use as-is (the user's literal) + - Pick a different domain + + Pass whichever value the user picks to --domain. + NEVER convert silently. +``` + +Add a Guardrail in both skills: + +> **Don't silently transform user-specified literals** - When the user provides +> a domain that doesn't match OpenSpec's kebab-case recommendation, present the +> choice via AskUserQuestion (convert / keep as-is / pick different) and let +> the user decide. NEVER lowercase, kebab-case, or otherwise modify the literal +> on your own. (Deriving a kebab-case name from a freeform description like +> "add user auth" is fine — there's no specific literal there to preserve.) + +### Cross-platform note + +Windows and macOS default file systems are case-insensitive. On those systems, `Performance/` and `performance/` resolve to the same directory and may collide. We don't enforce uniqueness in the CLI (would require maintaining a case-folded index); we rely on filesystem error if the user actually creates a colliding directory. Document the risk in the CLI's mandatory-domain error block: + +> Note: domain segments are case-preserving. On case-insensitive file systems +> (Windows, macOS default APFS) different-case variants of the same name will +> collide. + +## Affected Files + +| File | Change | +|------|--------| +| `src/utils/change-path.ts` | Rewrite `validateDomainPath` to lenient mode | +| `test/utils/change-path.test.ts` | Add cases: `Performance/SignificanceManager` passes, `auth//oauth` still rejected, segments with whitespace rejected, `.` and `..` rejected | +| `src/core/templates/workflows/propose.ts` | Add soft-check step + Guardrail (template + command) | +| `src/core/templates/workflows/new-change.ts` | Add soft-check step + Guardrail (template + command) | +| `test/core/templates/skill-templates-parity.test.ts` | Update SHA-256 hashes for the 4 modified templates + 2 generated skill payloads | +| `src/commands/workflow/new-change.ts` | Append cross-platform note to mandatory-domain error message | +| `test/commands/workflow/new-change-domain.test.ts` | Update existing "rejects invalid domain segment (uppercase)" / "rejects domain with empty segment" tests — uppercase now passes; double-slash still rejects | + +## Out of Scope + +- Migrating existing changes' domain casing +- Auto-detecting case-collisions across siblings +- Configurable strictness (project-level setting to opt back into strict kebab-case) +- Touching `validateChangeName` for leaf change names + +## Testing Notes + +The skill changes are tested via the parity hash test (mechanical) — not via subagent pressure scenarios in this iteration, because the user has already done the field-test (RED phase) by reporting the silent-transform failure. The CLI changes are tested via unit tests on `validateDomainPath`. diff --git a/docs/team-workflow.md b/docs/team-workflow.md index 76c83817b8..1b55751f7e 100644 --- a/docs/team-workflow.md +++ b/docs/team-workflow.md @@ -48,7 +48,7 @@ A reviewer who disagrees with the *approach* can say so against the proposal, ch ## When to archive -Archiving folds a change's deltas into your main `openspec/specs/` and moves the change folder to `openspec/changes/archive/YYYY-MM-DD-/`. Because `specs/` is the **shared source of truth**, the timing matters on a team. Two workable conventions: +Archiving folds a change's deltas into your main `openspec/specs/` and moves the change folder to `openspec/archive/YYYY-MM-DD-/` or `openspec/archive//YYYY-MM-DD-/`. Because `specs/` is the **shared source of truth**, the timing matters on a team. Two workable conventions: - **Archive after the PR merges (recommended).** The branch carries the active change; once it's merged to your main branch, archive there (often a tiny follow-up commit or a scheduled cleanup). This keeps the shared `specs/` moving forward only with work that actually shipped. - **Archive inside the PR.** Simpler for small teams: the same PR that adds the code also syncs and archives. The tradeoff is that your `specs/` diff and your code diff land together, which can make the PR noisier. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e2b69b3364..f5015b5b4f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -75,7 +75,7 @@ Also confirm you're in the right project directory. Every artifact is either already created or blocked waiting on a dependency. See what's blocking: ```bash -openspec status --change +openspec status --change ``` Then create the missing dependency first. Remember the order: proposal enables specs and design; specs and design together enable tasks. diff --git a/docs/workflows.md b/docs/workflows.md index 82f1e1efa2..67d6370d36 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -324,7 +324,7 @@ AI: Archiving add-auth... You: Yes AI: ✓ Synced specs to openspec/specs/auth/spec.md - ✓ Moved to openspec/changes/archive/2025-01-24-add-auth/ + ✓ Moved to openspec/archive/2025-01-24-add-auth/ Change archived successfully. ``` diff --git a/src/cli/index.ts b/src/cli/index.ts index 98505b02fe..f8d4bc1383 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -560,6 +560,7 @@ newCmd .command('change ') .description('Create a new change directory') .option('--description ', 'Description to add to README.md') + .option('--domain ', 'Domain path, or an empty string for the root domain') .option('--goal ', 'Optional goal metadata to store with the change') .option('--schema ', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) .option('--json', 'Output as JSON') diff --git a/src/commands/change.ts b/src/commands/change.ts index 561fb3d6ab..5670d75220 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -8,9 +8,7 @@ import type { RootOutput } from '../core/root-selection.js'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds } from '../utils/item-discovery.js'; import { getTaskProgressForChange } from '../utils/task-progress.js'; - -// Constants for better maintainability -const ARCHIVE_DIR = 'archive'; +import { findAllChangeIds, resolveExistingChangeId } from '../utils/change-path.js'; export class ChangeCommand { private converter: JsonConverter; @@ -58,7 +56,8 @@ export class ChangeCommand { } } - const proposalPath = path.join(changesPath, changeName, 'proposal.md'); + const resolvedChange = await resolveExistingChangeId(changeName, changesPath); + const proposalPath = path.join(resolvedChange.path, 'proposal.md'); try { await fs.access(proposalPath); @@ -194,13 +193,7 @@ export class ChangeCommand { } } - const changeDir = path.join(changesPath, changeName); - - try { - await fs.access(changeDir); - } catch { - throw new Error(`Change "${changeName}" not found at ${changeDir}`); - } + const { path: changeDir } = await resolveExistingChangeId(changeName, changesPath); const validator = new Validator(options?.strict || false); const report = await validator.validateChangeDeltaSpecs(changeDir); @@ -228,19 +221,7 @@ export class ChangeCommand { private async getActiveChanges(changesPath: string): Promise { try { - const entries = await fs.readdir(changesPath, { withFileTypes: true }); - const result: string[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === ARCHIVE_DIR) continue; - const proposalPath = path.join(changesPath, entry.name, 'proposal.md'); - try { - await fs.access(proposalPath); - result.push(entry.name); - } catch { - // skip directories without proposal.md - } - } - return result.sort(); + return await findAllChangeIds(changesPath); } catch { return []; } diff --git a/src/commands/show.ts b/src/commands/show.ts index 408f11a7ca..ecffefce8b 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -123,7 +123,11 @@ export class ShowCommand { isSpec = specs.includes(itemName); } - const resolvedType = params.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined); + const resolvedType: ItemType | undefined = params.typeOverride === 'change' + ? (isChange ? 'change' : undefined) + : params.typeOverride === 'spec' + ? (isSpec ? 'spec' : undefined) + : (isChange ? 'change' : isSpec ? 'spec' : undefined); if (!resolvedType) { const suggestions = nearestMatches(itemName, [...changes, ...specs]); diff --git a/src/commands/spec.ts b/src/commands/spec.ts index d3d176873b..7493e2ad2b 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -1,5 +1,5 @@ import { program } from 'commander'; -import { existsSync, readdirSync, readFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { MarkdownParser } from '../core/parsers/markdown-parser.js'; import { Validator } from '../core/validation/validator.js'; @@ -155,30 +155,28 @@ export function registerSpecCommand(rootProgram: typeof program) { .description('List all available specifications') .option('--json', 'Output as JSON') .option('--long', 'Show id and title with counts') - .action((options: { json?: boolean; long?: boolean }) => { + .action(async (options: { json?: boolean; long?: boolean }) => { try { if (!existsSync(SPECS_DIR)) { console.log('No items found'); return; } - const specs = readdirSync(SPECS_DIR, { withFileTypes: true }) - .filter(dirent => dirent.isDirectory()) - .map(dirent => { - const specPath = join(SPECS_DIR, dirent.name, 'spec.md'); + const specs = (await getSpecIds()).map(specId => { + const specPath = join(SPECS_DIR, ...specId.split('/'), 'spec.md'); if (existsSync(specPath)) { try { - const spec = parseSpecFromFile(specPath, dirent.name); + const spec = parseSpecFromFile(specPath, specId); return { - id: dirent.name, + id: specId, title: spec.name, requirementCount: spec.requirements.length }; } catch { return { - id: dirent.name, - title: dirent.name, + id: specId, + title: specId, requirementCount: 0 }; } diff --git a/src/commands/store.ts b/src/commands/store.ts index 1a91d89984..a660c7b04a 100644 --- a/src/commands/store.ts +++ b/src/commands/store.ts @@ -406,7 +406,8 @@ function printMutationHuman( } console.log(''); console.log('Next: run normal OpenSpec commands against this store, for example:'); - console.log(` openspec new change --store ${payload.store.id}`); + console.log(` openspec new change --domain --store ${payload.store.id}`); + console.log(` openspec new change --domain "" --store ${payload.store.id}`); if (payload.git.is_repository) { const shareRemote = remotes?.canonical ?? remotes?.observed; console.log( @@ -773,11 +774,11 @@ export function registerStoreCommand(program: Command): void { process.exitCode = 1; return; } - let example = 'openspec new change --store '; + let example = 'openspec new change --domain --store '; if (!hasFlagLikeToken && attempted.length > 0 && lifecycleRedirects.has(attempted[0])) { if (attempted[0] === 'new') { - const changeId = attempted[1] === 'change' && attempted[2] ? attempted[2] : ''; - example = `openspec new change ${changeId} --store `; + const changeName = attempted[1] === 'change' && attempted[2] ? attempted[2] : ''; + example = `openspec new change ${changeName} --domain --store `; } else { example = `openspec ${attempted.join(' ')} --store `; } @@ -794,6 +795,9 @@ export function registerStoreCommand(program: Command): void { 'To create or work on a change in a store, use the normal command with --store, for example:' ); console.error(` ${example}`); + if (example.startsWith('openspec new change ')) { + console.error(` ${example.replace('--domain ', '--domain ""')}`); + } process.exitCode = 1; }); } diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 708c66024e..5fc6be749b 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -134,7 +134,11 @@ export class ValidateCommand { const isChange = changes.includes(itemName); const isSpec = specs.includes(itemName); - const type = opts.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined); + const type: ItemType | undefined = opts.typeOverride === 'change' + ? (isChange ? 'change' : undefined) + : opts.typeOverride === 'spec' + ? (isSpec ? 'spec' : undefined) + : (isChange ? 'change' : isSpec ? 'spec' : undefined); if (!type) { const suggestions = nearestMatches(itemName, [...changes, ...specs]); diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 10a5fac166..6fb7871224 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -116,7 +116,12 @@ export async function instructionsCommand( options.change, projectRoot, root.changesDir, - { newChangeHint: withStoreFlag(root, 'openspec new change ') } + { + newChangeHint: [ + withStoreFlag(root, 'openspec new change --domain '), + withStoreFlag(root, 'openspec new change --domain ""'), + ].join(' or '), + } ); // Validate schema if explicitly provided @@ -448,7 +453,12 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions options.change, projectRoot, root.changesDir, - { newChangeHint: withStoreFlag(root, 'openspec new change ') } + { + newChangeHint: [ + withStoreFlag(root, 'openspec new change --domain '), + withStoreFlag(root, 'openspec new change --domain ""'), + ].join(' or '), + } ); // Validate schema if explicitly provided diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 3e059242dc..4094338463 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -8,8 +8,11 @@ */ import ora from 'ora'; +import { promises as fs } from 'fs'; import path from 'path'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; +import { validateDomainPath } from '../../utils/change-path.js'; +import { isInteractive } from '../../utils/interactive.js'; import { formatChangeLocation } from '../../core/planning-home.js'; import { resolveRootForCommand, @@ -29,6 +32,7 @@ import { printJson, statusFromError, validateSchemaExists } from './shared.js'; export interface NewChangeOptions { description?: string; + domain?: string; goal?: string; schema?: string; store?: string; @@ -48,6 +52,11 @@ interface NewChangeOutput { root: RootOutput; } +type DomainChoice = + | { kind: 'existing'; segment: string } + | { kind: 'here' } + | { kind: 'new' }; + // ----------------------------------------------------------------------------- // Command Implementation // ----------------------------------------------------------------------------- @@ -70,6 +79,147 @@ function assertRemovedOptionsAbsent(options: NewChangeOptions): void { } } +async function isChangeDirectory(directory: string): Promise { + for (const marker of ['.openspec.yaml', 'proposal.md']) { + try { + if ((await fs.stat(path.join(directory, marker))).isFile()) { + return true; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } + + return false; +} + +async function listDomainChildren( + changesDir: string, + domain: string[] +): Promise { + const currentDir = path.join(changesDir, ...domain); + let entries; + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + + const children: string[] = []; + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.name.startsWith('.') || + (domain.length === 0 && entry.name === 'archive') + ) { + continue; + } + + if (!(await isChangeDirectory(path.join(currentDir, entry.name)))) { + children.push(entry.name); + } + } + + return children.sort((left, right) => left.localeCompare(right)); +} + +async function findExistingDomains(changesDir: string): Promise { + const domains: string[] = []; + + async function visit(domain: string[]): Promise { + for (const child of await listDomainChildren(changesDir, domain)) { + const nested = [...domain, child]; + domains.push(nested.join('/')); + await visit(nested); + } + } + + await visit([]); + return domains; +} + +function validateExplicitDomain(domain: string): string { + const validation = validateDomainPath(domain); + if (!validation.valid) { + throw new Error(validation.error); + } + return domain; +} + +function validateNewDomainSegment(segment: string): string | undefined { + if (segment !== segment.trim()) { + return 'Domain name cannot contain leading or trailing whitespace'; + } + + if (!segment || segment.includes('/') || segment.includes('\\')) { + return 'Domain name must be a single path segment'; + } + + const validation = validateDomainPath(segment); + return validation.valid ? undefined : validation.error; +} + +async function promptForDomain(changesDir: string): Promise { + const { input, select } = await import('@inquirer/prompts'); + const domain: string[] = []; + + while (true) { + const children = await listDomainChildren(changesDir, domain); + const location = domain.length === 0 ? 'root' : domain.join('/'); + const choice = await select({ + message: `Select a domain (${location})`, + choices: [ + ...children.map((segment) => ({ + name: segment, + value: { kind: 'existing' as const, segment }, + })), + { name: `Create here (${location})`, value: { kind: 'here' as const } }, + { name: 'New subdomain', value: { kind: 'new' as const } }, + ], + }); + + if (choice.kind === 'existing') { + domain.push(choice.segment); + continue; + } + + if (choice.kind === 'here') { + return domain.join('/'); + } + + while (true) { + const segment = await input({ message: 'New subdomain name' }); + const error = validateNewDomainSegment(segment); + if (!error) { + return [...domain, segment].join('/'); + } + console.error(error); + } + } +} + +async function domainRequiredError(changesDir: string): Promise { + const domains = await findExistingDomains(changesDir); + const existing = domains.length > 0 ? domains.join(', ') : '(none)'; + const message = [ + 'Domain selection is required.', + 'Use --domain to create in a domain or --domain "" to create at the root.', + 'Agents must ask the user which domain to use; do not choose one automatically.', + `Existing domains in the selected root: ${existing}.`, + 'Domain casing is preserved, but collisions are possible on case-insensitive file systems.', + ].join(' '); + + return new RootSelectionError(message, 'domain_required', { + target: 'change.domain', + fix: 'Ask the user, then pass --domain or --domain "" explicitly.', + }); +} + function printCreatedChangeHuman( payload: NewChangeOutput, root: ResolvedOpenSpecRoot @@ -109,6 +259,15 @@ export async function newChangeCommand(name: string | undefined, options: NewCha } const projectRoot = root.path; + let domain: string; + if (options.domain !== undefined) { + domain = validateExplicitDomain(options.domain); + } else if (!options.json && isInteractive()) { + domain = await promptForDomain(root.changesDir); + } else { + throw await domainRequiredError(root.changesDir); + } + const changeId = domain ? `${domain}/${name}` : name; // Validate schema if provided if (options.schema) { @@ -117,10 +276,10 @@ export async function newChangeCommand(name: string | undefined, options: NewCha const resolvedSchema = options.schema ?? root.defaultSchema; if (spinner) { - spinner.start(`Creating change '${name}' with schema '${resolvedSchema}'...`); + spinner.start(`Creating change '${changeId}' with schema '${resolvedSchema}'...`); } - const result = await createChange(projectRoot, name, { + const result = await createChange(projectRoot, changeId, { schema: options.schema, defaultSchema: root.defaultSchema, changesDir: root.changesDir, @@ -138,7 +297,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha const payload: NewChangeOutput = { change: { - id: name, + id: changeId, path: result.changeDir, metadataPath: path.join(result.changeDir, '.openspec.yaml'), schema: result.schema, diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index f4350a6d9c..91bcea9079 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -7,11 +7,14 @@ import chalk from 'chalk'; import path from 'path'; -import * as fs from 'fs'; import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js'; import type { ReferenceIndexEntry } from '../../core/references.js'; import { isRootSelectionError } from '../../core/root-selection.js'; -import { validateChangeName } from '../../utils/change-utils.js'; +import { + ChangeNotFoundError, + findAllChangeIds, + resolveExistingChangeId, +} from '../../utils/change-path.js'; // ----------------------------------------------------------------------------- // Types @@ -122,16 +125,7 @@ export async function getAvailableChanges( projectRoot: string, changesDir = path.join(projectRoot, 'openspec', 'changes') ): Promise { - const changesPath = changesDir; - try { - const entries = await fs.promises.readdir(changesPath, { withFileTypes: true }); - return entries - .filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.')) - .map((e) => e.name); - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; - throw error; - } + return findAllChangeIds(changesDir); } /** @@ -146,7 +140,8 @@ export async function validateChangeExists( ): Promise { // Hints must stay pasteable: callers with a selected store pass a // store-carrying hint so following it lands in the same root. - const newChangeHint = hints.newChangeHint ?? 'openspec new change '; + const newChangeHint = hints.newChangeHint ?? + 'openspec new change --domain or openspec new change --domain ""'; if (!changeName) { const available = await getAvailableChanges(projectRoot, changesDir); @@ -158,17 +153,12 @@ export async function validateChangeExists( ); } - // Validate change name format to prevent path traversal - const nameValidation = validateChangeName(changeName); - if (!nameValidation.valid) { - throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`); - } - - // Check directory existence directly - const changePath = path.join(changesDir, changeName); - const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory(); - - if (!exists) { + try { + await resolveExistingChangeId(changeName, changesDir); + } catch (error) { + if (!(error instanceof ChangeNotFoundError)) { + throw error; + } const available = await getAvailableChanges(projectRoot, changesDir); if (available.length === 0) { throw new Error( diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 4374744bf5..12c9c33fcc 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -57,7 +57,10 @@ export async function statusCommand(options: StatusOptions): Promise { const planningHome = toPlanningHome(root); const projectRoot = root.path; const rootOutput = toRootOutput(root); - const newChangeHint = withStoreFlag(root, 'openspec new change '); + const newChangeHint = [ + withStoreFlag(root, 'openspec new change --domain '), + withStoreFlag(root, 'openspec new change --domain ""'), + ].join(' or '); // Handle no-changes case gracefully — status is informational, // so "no changes" is a valid state, not an error. diff --git a/src/core/archive.ts b/src/core/archive.ts index 850d4cd5ba..5dd7ed1ffb 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -18,28 +18,15 @@ import { writeUpdatedSpec, type SpecUpdate, } from './specs-apply.js'; - -function isMissingPathError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as NodeJS.ErrnoException).code === 'ENOENT' - ); -} - -async function listActiveChangeNames(changesDir: string): Promise { - try { - const entries = await fs.readdir(changesDir, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory() && entry.name !== 'archive') - .map((entry) => entry.name) - .sort(); - } catch (error) { - if (!isMissingPathError(error)) throw error; - return []; - } -} +import { + assertProspectivePathContained, + buildArchivePath, + ChangeNotFoundError, + findAllChangeIds, + pathExistsWithoutFollowingLinks, + resolveExistingChangeId, + type ResolvedChangeId, +} from '../utils/change-path.js'; export interface ArchiveOptions { yes?: boolean; @@ -219,16 +206,14 @@ export class ArchiveCommand { changeName = selectedChange; } - const changeDir = path.join(changesDir, changeName); - - // Verify change exists + let resolvedChange: ResolvedChangeId; try { - const stat = await fs.stat(changeDir); - if (!stat.isDirectory()) { - throw new Error(`Change '${changeName}' not found.`); + resolvedChange = await resolveExistingChangeId(changeName, changesDir); + } catch (error) { + if (!(error instanceof ChangeNotFoundError)) { + throw error; } - } catch { - const available = await listActiveChangeNames(changesDir); + const available = await findAllChangeIds(changesDir); throw new ArchiveBlockedError( 'archive_change_not_found', available.length > 0 @@ -237,6 +222,21 @@ export class ArchiveCommand { ); } + const { domain, name, path: changeDir } = resolvedChange; + const archiveDate = this.getArchiveDate(); + const archiveName = `${archiveDate}-${name}`; + const archivedAs = [...domain, archiveName].join('/'); + const archivePath = buildArchivePath(archiveDir, changeName, archiveDate); + + // Block before validation or spec preparation can mutate any target. + if (await pathExistsWithoutFollowingLinks(archivePath)) { + throw new ArchiveBlockedError( + 'archive_target_exists', + `Archive '${archivedAs}' already exists.` + ); + } + await assertProspectivePathContained(archiveDir, archivePath, 'Archive'); + const skipValidation = options.validate === false || options.noValidate === true; // Validate specs and change before archiving @@ -383,7 +383,7 @@ export class ArchiveCommand { } } else { // Find specs to update - const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir); + const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir, domain); if (specUpdates.length > 0) { if (!json) { @@ -468,7 +468,11 @@ export class ArchiveCommand { await writeUpdatedSpec(p.update, p.rebuilt, p.counts, { silent: json, // Cross-root paths must be absolute when a store is selected. - ...(isStoreSelectedRoot(root) ? { displayPath: p.update.target } : {}), + displayPath: isStoreSelectedRoot(root) + ? p.update.target + : path.join('openspec', 'specs', path.relative(mainSpecsDir, p.update.target)) + .split(path.sep) + .join('/'), }); writeTotals.added += p.counts.added; writeTotals.modified += p.counts.modified; @@ -487,37 +491,19 @@ export class ArchiveCommand { } } - // Create archive directory with date prefix - const archiveName = `${this.getArchiveDate()}-${changeName}`; - const archivePath = path.join(archiveDir, archiveName); - - // Check if archive already exists - let archiveExists = false; - try { - await fs.access(archivePath); - archiveExists = true; - } catch (error: any) { - if (error.code !== 'ENOENT') { - throw error; - } - } - if (archiveExists) { - throw new ArchiveBlockedError('archive_target_exists', `Archive '${archiveName}' already exists.`); - } - // Create archive directory if needed - await fs.mkdir(archiveDir, { recursive: true }); + await fs.mkdir(path.dirname(archivePath), { recursive: true }); // Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows) await moveDirectory(changeDir, archivePath); if (!json) { - console.log(`Change '${changeName}' archived as '${archiveName}'.`); + console.log(`Change '${changeName}' archived as '${archivedAs}'.`); } return { change: changeName, - archivedAs: archiveName, + archivedAs, path: archivePath, specsUpdated, ...(totals ? { totals } : {}), @@ -526,7 +512,7 @@ export class ArchiveCommand { private async selectChange(changesDir: string): Promise { const { select } = await import('@inquirer/prompts'); - const changeDirs = await listActiveChangeNames(changesDir); + const changeDirs = await findAllChangeIds(changesDir); if (changeDirs.length === 0) { console.log('No active changes found.'); diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 76f2a28587..6e71bb634d 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -231,6 +231,11 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Description to add to README.md', takesValue: true, }, + { + name: 'domain', + description: 'Domain path, or an empty string for the root domain', + takesValue: true, + }, { name: 'goal', description: 'Optional goal metadata to store with the change', diff --git a/src/core/init.ts b/src/core/init.ts index b6ab31ab77..7054f7ad50 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -489,7 +489,7 @@ export class InitCommand { openspecPath, path.join(openspecPath, 'specs'), path.join(openspecPath, 'changes'), - path.join(openspecPath, 'changes', 'archive'), + path.join(openspecPath, 'archive'), ]; for (const dir of directories) { @@ -504,7 +504,7 @@ export class InitCommand { openspecPath, path.join(openspecPath, 'specs'), path.join(openspecPath, 'changes'), - path.join(openspecPath, 'changes', 'archive'), + path.join(openspecPath, 'archive'), ]; for (const dir of directories) { diff --git a/src/core/list.ts b/src/core/list.ts index 0c19048e21..86e82ccc6a 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -1,10 +1,11 @@ import { promises as fs } from 'fs'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; -import { readFileSync, type Dirent } from 'fs'; +import { readFileSync } from 'fs'; import { join } from 'path'; import { MarkdownParser } from './parsers/markdown-parser.js'; import type { RootOutput } from './root-selection.js'; +import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js'; interface ChangeInfo { name: string; @@ -19,22 +20,8 @@ interface ListOptions { root?: RootOutput; } -function isMissingPathError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as NodeJS.ErrnoException).code === 'ENOENT' - ); -} - -async function readChangeDirectoryEntries(changesDir: string): Promise { - try { - return await fs.readdir(changesDir, { withFileTypes: true }); - } catch (error) { - if (isMissingPathError(error)) return []; - throw error; - } +function getChangePath(changesDir: string, changeId: string): string { + return path.join(changesDir, ...changeId.split('/')); } /** @@ -100,12 +87,7 @@ export class ListCommand { if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); - - // Get all directories in changes (excluding archive) - const entries = await readChangeDirectoryEntries(changesDir); - const changeDirs = entries - .filter(entry => entry.isDirectory() && entry.name !== 'archive') - .map(entry => entry.name); + const changeDirs = await getActiveChangeIds(targetPath); if (changeDirs.length === 0) { if (json) { @@ -121,7 +103,7 @@ export class ListCommand { for (const changeDir of changeDirs) { const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath); - const changePath = path.join(changesDir, changeDir); + const changePath = getChangePath(changesDir, changeDir); const lastModified = await getLastModified(changePath); changes.push({ name: changeDir, @@ -166,19 +148,7 @@ export class ListCommand { // specs mode const specsDir = path.join(targetPath, 'openspec', 'specs'); - try { - await fs.access(specsDir); - } catch { - if (json) { - console.log(JSON.stringify({ specs: [], ...(root ? { root } : {}) }, null, 2)); - } else { - console.log('No specs found.'); - } - return; - } - - const entries = await fs.readdir(specsDir, { withFileTypes: true }); - const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name); + const specDirs = await getSpecIds(targetPath); if (specDirs.length === 0) { if (json) { console.log(JSON.stringify({ specs: [], ...(root ? { root } : {}) }, null, 2)); @@ -191,7 +161,7 @@ export class ListCommand { type SpecInfo = { id: string; requirementCount: number }; const specs: SpecInfo[] = []; for (const id of specDirs) { - const specPath = join(specsDir, id, 'spec.md'); + const specPath = join(specsDir, ...id.split('/'), 'spec.md'); try { const content = readFileSync(specPath, 'utf-8'); const parser = new MarkdownParser(content); diff --git a/src/core/openspec-root.ts b/src/core/openspec-root.ts index d65882ee21..55463b2887 100644 --- a/src/core/openspec-root.ts +++ b/src/core/openspec-root.ts @@ -13,7 +13,7 @@ export const OPENSPEC_CONFIG_YAML = 'openspec/config.yaml'; export const OPENSPEC_CONFIG_YML = 'openspec/config.yml'; export const OPENSPEC_SPECS_DIR = 'openspec/specs'; export const OPENSPEC_CHANGES_DIR = 'openspec/changes'; -export const OPENSPEC_ARCHIVE_DIR = 'openspec/changes/archive'; +export const OPENSPEC_ARCHIVE_DIR = 'openspec/archive'; export const DEFAULT_OPENSPEC_SCHEMA = 'spec-driven'; export const DIRECTORY_ANCHOR_FILE_NAME = '.gitkeep'; @@ -196,7 +196,7 @@ export async function inspectOpenSpecRoot(storeRoot: string): Promise { +export async function findSpecUpdates( + changeDir: string, + mainSpecsDir: string, + changeDomain: string[] = [] +): Promise { const updates: SpecUpdate[] = []; const changeSpecsDir = path.join(changeDir, 'specs'); + let entries; try { - const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - const specFile = path.join(changeSpecsDir, entry.name, 'spec.md'); - const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md'); - - try { - await fs.access(specFile); - - // Check if target exists - let exists = false; - try { - await fs.access(targetFile); - exists = true; - } catch { - exists = false; - } - - updates.push({ - source: specFile, - target: targetFile, - exists, - }); - } catch { - // Source spec doesn't exist, skip - } - } - } + entries = await fs.readdir(changeSpecsDir, { withFileTypes: true }); } catch { // No specs directory in change + return updates; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const specFile = path.join(changeSpecsDir, entry.name, 'spec.md'); + const targetFile = path.join(mainSpecsDir, ...changeDomain, entry.name, 'spec.md'); + + try { + await fs.access(specFile); + } catch { + // Source spec doesn't exist, skip + continue; + } + + await assertProspectivePathContained(mainSpecsDir, targetFile, 'Spec'); + + // Check if target exists + let exists = false; + try { + await fs.access(targetFile); + exists = true; + } catch { + exists = false; + } + + updates.push({ + source: specFile, + target: targetFile, + exists, + }); } return updates; @@ -444,21 +460,22 @@ export async function applySpecs( silent?: boolean; } = {} ): Promise { - const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); - const mainSpecsDir = path.join(projectRoot, 'openspec', 'specs'); - - // Verify change exists + const changesDir = path.join(projectRoot, 'openspec', 'changes'); + let resolvedChange; try { - const stat = await fs.stat(changeDir); - if (!stat.isDirectory()) { - throw new Error(`Change '${changeName}' not found.`); + resolvedChange = await resolveExistingChangeId(changeName, changesDir); + } catch (error) { + if (!(error instanceof ChangeNotFoundError)) { + throw error; } - } catch { throw new Error(`Change '${changeName}' not found.`); } + const { domain, path: changeDir } = resolvedChange; + const mainSpecsDir = path.join(projectRoot, 'openspec', 'specs'); + // Find specs to update - const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir); + const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir, domain); if (specUpdates.length === 0) { return { @@ -503,6 +520,11 @@ export async function applySpecs( for (const p of prepared) { const capability = path.basename(path.dirname(p.update.target)); + const displayPath = path.join( + 'openspec', + 'specs', + path.relative(mainSpecsDir, p.update.target) + ).split(path.sep).join('/'); if (!options.dryRun) { // Write the updated spec @@ -511,14 +533,14 @@ export async function applySpecs( await fs.writeFile(p.update.target, p.rebuilt); if (!options.silent) { - console.log(`Applying changes to openspec/specs/${capability}/spec.md:`); + console.log(`Applying changes to ${displayPath}:`); if (p.counts.added) console.log(` + ${p.counts.added} added`); if (p.counts.modified) console.log(` ~ ${p.counts.modified} modified`); if (p.counts.removed) console.log(` - ${p.counts.removed} removed`); if (p.counts.renamed) console.log(` → ${p.counts.renamed} renamed`); } } else if (!options.silent) { - console.log(`Would apply changes to openspec/specs/${capability}/spec.md:`); + console.log(`Would apply changes to ${displayPath}:`); if (p.counts.added) console.log(` + ${p.counts.added} added`); if (p.counts.modified) console.log(` ~ ${p.counts.modified} modified`); if (p.counts.removed) console.log(` - ${p.counts.removed} removed`); diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index 20f69c2bff..417b0d87bb 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -7,6 +7,22 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +const DOMAIN_PRESERVING_ARCHIVE_GUIDANCE = `Derive the destination from the full slash-delimited change ID. The final segment is \`\`; every preceding segment is \`\`. + + - Domain change target: \`/openspec/archive//YYYY-MM-DD-\` + - Root change target: \`/openspec/archive/YYYY-MM-DD-\` + + Set \`\` to the applicable path and \`\` to its parent directory. This keeps the archive tree beside \`openspec/changes\` and preserves every domain segment. + + **Check if \`\` already exists:** + - If yes: Fail with error, suggest renaming the existing archive or using a different date + - If no: Create the parent and move \`changeRoot\` + + \`\`\`bash + mkdir -p "" + mv "" "" + \`\`\``; + export function getArchiveChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-archive-change', @@ -15,7 +31,7 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { ${STORE_SELECTION_GUIDANCE} -**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. +**Input**: Optionally specify a full slash-delimited change ID. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -28,9 +44,11 @@ ${STORE_SELECTION_GUIDANCE} **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Treat the full selected or supplied value as \`\`; do not reduce it to its final name segment. + 2. **Check artifact completion status** - Run \`openspec status --change "" --json\` to check artifact completion. + Run \`openspec status --change "" --json\` to check artifact completion. Parse the JSON to understand: - \`schemaName\`: The workflow being used @@ -60,7 +78,8 @@ ${STORE_SELECTION_GUIDANCE} Use \`artifactPaths.specs.existingOutputPaths\` from status JSON to check for delta specs. If none exist, proceed without sync prompt. **If delta specs exist:** - - Compare each delta spec with its corresponding main spec at \`openspec/specs//spec.md\` + - To derive each full \`\`, derive \`\` only from every segment before the final \`\` in the full \`\`. The repo-local delta path \`specs//spec.md\` does not contain the change domain. + - Compare it with \`openspec/specs///spec.md\` or \`openspec/specs//spec.md\`, as applicable - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting @@ -68,24 +87,11 @@ ${STORE_SELECTION_GUIDANCE} - If changes needed: "Sync now (recommended)", "Archive without syncing" - If already synced: "Archive now", "Sync anyway", "Cancel" - If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. Delta spec analysis: "). Proceed to archive regardless of choice. + If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. Delta spec analysis: "). Proceed to archive regardless of choice. 5. **Perform the archive** - Create an \`archive\` directory under \`planningHome.changesDir\` if it doesn't exist: - \`\`\`bash - mkdir -p "/archive" - \`\`\` - - Generate target name using current date: \`YYYY-MM-DD-\` - - **Check if target already exists:** - - If yes: Fail with error, suggest renaming existing archive or using different date - - If no: Move \`changeRoot\` to the archive directory - - \`\`\`bash - mv "" "/archive/YYYY-MM-DD-" - \`\`\` + ${DOMAIN_PRESERVING_ARCHIVE_GUIDANCE} 6. **Display summary** @@ -103,7 +109,7 @@ ${STORE_SELECTION_GUIDANCE} **Change:** **Schema:** -**Archived to:** the archive path derived from \`planningHome.changesDir\`/YYYY-MM-DD-/ +**Archived to:** \`\` **Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped") All artifacts complete. All tasks complete. @@ -133,7 +139,7 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { ${STORE_SELECTION_GUIDANCE} -**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. +**Input**: Optionally specify a full slash-delimited change ID after \`/opsx:archive\` (e.g., \`/opsx:archive auth/add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -146,9 +152,11 @@ ${STORE_SELECTION_GUIDANCE} **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Treat the full selected or supplied value as \`\`; do not reduce it to its final name segment. + 2. **Check artifact completion status** - Run \`openspec status --change "" --json\` to check artifact completion. + Run \`openspec status --change "" --json\` to check artifact completion. Parse the JSON to understand: - \`schemaName\`: The workflow being used @@ -178,7 +186,8 @@ ${STORE_SELECTION_GUIDANCE} Use \`artifactPaths.specs.existingOutputPaths\` from status JSON to check for delta specs. If none exist, proceed without sync prompt. **If delta specs exist:** - - Compare each delta spec with its corresponding main spec at \`openspec/specs//spec.md\` + - To derive each full \`\`, derive \`\` only from every segment before the final \`\` in the full \`\`. The repo-local delta path \`specs//spec.md\` does not contain the change domain. + - Compare it with \`openspec/specs///spec.md\` or \`openspec/specs//spec.md\`, as applicable - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting @@ -186,24 +195,11 @@ ${STORE_SELECTION_GUIDANCE} - If changes needed: "Sync now (recommended)", "Archive without syncing" - If already synced: "Archive now", "Sync anyway", "Cancel" - If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. Delta spec analysis: "). Proceed to archive regardless of choice. + If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. Delta spec analysis: "). Proceed to archive regardless of choice. 5. **Perform the archive** - Create an \`archive\` directory under \`planningHome.changesDir\` if it doesn't exist: - \`\`\`bash - mkdir -p "/archive" - \`\`\` - - Generate target name using current date: \`YYYY-MM-DD-\` - - **Check if target already exists:** - - If yes: Fail with error, suggest renaming existing archive or using different date - - If no: Move \`changeRoot\` to the archive directory - - \`\`\`bash - mv "" "/archive/YYYY-MM-DD-" - \`\`\` + ${DOMAIN_PRESERVING_ARCHIVE_GUIDANCE} 6. **Display summary** @@ -221,7 +217,7 @@ ${STORE_SELECTION_GUIDANCE} **Change:** **Schema:** -**Archived to:** the archive path derived from \`planningHome.changesDir\`/YYYY-MM-DD-/ +**Archived to:** \`\` **Specs:** ✓ Synced to main specs All artifacts complete. All tasks complete. @@ -234,7 +230,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** the archive path derived from \`planningHome.changesDir\`/YYYY-MM-DD-/ +**Archived to:** \`\` **Specs:** No delta specs All artifacts complete. All tasks complete. @@ -247,7 +243,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** the archive path derived from \`planningHome.changesDir\`/YYYY-MM-DD-/ +**Archived to:** \`\` **Specs:** Sync skipped (user chose to skip) **Warnings:** @@ -264,7 +260,7 @@ Review the archive if this was not intentional. ## Archive Failed **Change:** -**Target:** the archive path derived from \`planningHome.changesDir\`/YYYY-MM-DD-/ +**Target:** \`\` Target archive directory already exists. diff --git a/src/core/templates/workflows/bulk-archive-change.ts b/src/core/templates/workflows/bulk-archive-change.ts index e5478c15b1..e9eed221ff 100644 --- a/src/core/templates/workflows/bulk-archive-change.ts +++ b/src/core/templates/workflows/bulk-archive-change.ts @@ -7,6 +7,18 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +const DOMAIN_PRESERVING_ARCHIVE_GUIDANCE = `For each selected change, derive the destination from its full slash-delimited change ID. The final segment is \`\`; every preceding segment is \`\`. + + - Domain change target: \`/openspec/archive//YYYY-MM-DD-\` + - Root change target: \`/openspec/archive/YYYY-MM-DD-\` + + Set \`\` to the applicable path and \`\` to its parent directory. This preserves every domain segment in the sibling \`openspec/archive\` tree. + + \`\`\`bash + mkdir -p "" + mv "" "" + \`\`\``; + export function getBulkArchiveChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-bulk-archive-change', @@ -36,11 +48,13 @@ ${STORE_SELECTION_GUIDANCE} **IMPORTANT**: Do NOT auto-select. Always let the user choose. + Treat every full selected value as a \`\`; do not reduce it to its final name segment. + 3. **Batch validation - gather status for all selected changes** For each selected change, collect: - a. **Artifact status** - Run \`openspec status --change "" --json\` + a. **Artifact status** - Run \`openspec status --change "" --json\` - Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\` - Note which artifacts are \`done\` vs other states @@ -49,19 +63,21 @@ ${STORE_SELECTION_GUIDANCE} - If no tasks file exists, note as "No tasks" c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON - - List which capability specs exist + - To derive each canonical spec ID, derive \`\` only from every segment before the final \`\` in the full \`\`. The repo-local delta path \`specs//spec.md\` does not contain the change domain. + - List each canonical \`\`: \`/\` for a domain-qualified spec, or \`\` for a root spec + - Resolve its main target as \`openspec/specs///spec.md\` or \`openspec/specs//spec.md\` - For each, extract requirement names (lines matching \`### Requirement: \`) 4. **Detect spec conflicts** - Build a map of \`capability -> [changes that touch it]\`: + Build a map of \` -> [...]\`: \`\`\` - auth -> [change-a, change-b] <- CONFLICT (2+ changes) - api -> [change-c] <- OK (only 1 change) + auth/login -> [auth/change-a, auth/change-b] <- CONFLICT (2+ changes) + api -> [change-c] <- OK (only 1 change) \`\`\` - A conflict exists when 2+ selected changes have delta specs for the same capability. + A conflict exists when 2+ selected changes have delta specs for the same canonical spec ID. Do not merge identical capability leaves in different domains into one conflict key. 5. **Resolve conflicts agentically** @@ -130,10 +146,7 @@ ${STORE_SELECTION_GUIDANCE} - Track if sync was done b. **Perform the archive**: - \`\`\`bash - mkdir -p "/archive" - mv "" "/archive/YYYY-MM-DD-" - \`\`\` + ${DOMAIN_PRESERVING_ARCHIVE_GUIDANCE} c. **Track outcome** for each change: - Success: archived successfully @@ -148,9 +161,9 @@ ${STORE_SELECTION_GUIDANCE} ## Bulk Archive Complete Archived 3 changes: - - schema-management-cli -> archive/2026-01-19-schema-management-cli/ - - project-config -> archive/2026-01-19-project-config/ - - add-oauth -> archive/2026-01-19-add-oauth/ + - platform/schema-management-cli -> /openspec/archive/platform/2026-01-19-schema-management-cli/ + - project-config -> /openspec/archive/2026-01-19-project-config/ + - auth/add-oauth -> /openspec/archive/auth/2026-01-19-add-oauth/ Skipped 1 change: - add-verify-skill (user chose not to archive incomplete) @@ -205,8 +218,8 @@ then add-graphql specs (chronological order, newer takes precedence). ## Bulk Archive Complete Archived N changes: -- -> archive/YYYY-MM-DD-/ -- -> archive/YYYY-MM-DD-/ +- / -> /openspec/archive//YYYY-MM-DD-/ +- -> /openspec/archive/YYYY-MM-DD-/ Spec sync summary: - N delta specs synced to main specs @@ -219,7 +232,7 @@ Spec sync summary: ## Bulk Archive Complete (partial) Archived N changes: -- -> archive/YYYY-MM-DD-/ +- / -> /openspec/archive//YYYY-MM-DD-/ Skipped M changes: - (user chose not to archive incomplete) @@ -285,11 +298,13 @@ ${STORE_SELECTION_GUIDANCE} **IMPORTANT**: Do NOT auto-select. Always let the user choose. + Treat every full selected value as a \`\`; do not reduce it to its final name segment. + 3. **Batch validation - gather status for all selected changes** For each selected change, collect: - a. **Artifact status** - Run \`openspec status --change "" --json\` + a. **Artifact status** - Run \`openspec status --change "" --json\` - Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\` - Note which artifacts are \`done\` vs other states @@ -298,19 +313,21 @@ ${STORE_SELECTION_GUIDANCE} - If no tasks file exists, note as "No tasks" c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON - - List which capability specs exist + - To derive each canonical spec ID, derive \`\` only from every segment before the final \`\` in the full \`\`. The repo-local delta path \`specs//spec.md\` does not contain the change domain. + - List each canonical \`\`: \`/\` for a domain-qualified spec, or \`\` for a root spec + - Resolve its main target as \`openspec/specs///spec.md\` or \`openspec/specs//spec.md\` - For each, extract requirement names (lines matching \`### Requirement: \`) 4. **Detect spec conflicts** - Build a map of \`capability -> [changes that touch it]\`: + Build a map of \` -> [...]\`: \`\`\` - auth -> [change-a, change-b] <- CONFLICT (2+ changes) - api -> [change-c] <- OK (only 1 change) + auth/login -> [auth/change-a, auth/change-b] <- CONFLICT (2+ changes) + api -> [change-c] <- OK (only 1 change) \`\`\` - A conflict exists when 2+ selected changes have delta specs for the same capability. + A conflict exists when 2+ selected changes have delta specs for the same canonical spec ID. Do not merge identical capability leaves in different domains into one conflict key. 5. **Resolve conflicts agentically** @@ -379,10 +396,7 @@ ${STORE_SELECTION_GUIDANCE} - Track if sync was done b. **Perform the archive**: - \`\`\`bash - mkdir -p "/archive" - mv "" "/archive/YYYY-MM-DD-" - \`\`\` + ${DOMAIN_PRESERVING_ARCHIVE_GUIDANCE} c. **Track outcome** for each change: - Success: archived successfully @@ -397,9 +411,9 @@ ${STORE_SELECTION_GUIDANCE} ## Bulk Archive Complete Archived 3 changes: - - schema-management-cli -> archive/2026-01-19-schema-management-cli/ - - project-config -> archive/2026-01-19-project-config/ - - add-oauth -> archive/2026-01-19-add-oauth/ + - platform/schema-management-cli -> /openspec/archive/platform/2026-01-19-schema-management-cli/ + - project-config -> /openspec/archive/2026-01-19-project-config/ + - auth/add-oauth -> /openspec/archive/auth/2026-01-19-add-oauth/ Skipped 1 change: - add-verify-skill (user chose not to archive incomplete) @@ -454,8 +468,8 @@ then add-graphql specs (chronological order, newer takes precedence). ## Bulk Archive Complete Archived N changes: -- -> archive/YYYY-MM-DD-/ -- -> archive/YYYY-MM-DD-/ +- / -> /openspec/archive//YYYY-MM-DD-/ +- -> /openspec/archive/YYYY-MM-DD-/ Spec sync summary: - N delta specs synced to main specs @@ -468,7 +482,7 @@ Spec sync summary: ## Bulk Archive Complete (partial) Archived N changes: -- -> archive/YYYY-MM-DD-/ +- / -> /openspec/archive//YYYY-MM-DD-/ Skipped M changes: - (user chose not to archive incomplete) diff --git a/src/core/templates/workflows/ff-change.ts b/src/core/templates/workflows/ff-change.ts index fafcd85eba..97cb37e878 100644 --- a/src/core/templates/workflows/ff-change.ts +++ b/src/core/templates/workflows/ff-change.ts @@ -7,6 +7,15 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +const DOMAIN_SELECTION_GUIDANCE = `2. **Determine the domain (required)** + + Make an explicit domain decision with the user. Recommend lowercase kebab-case domain segments, set the confirmed value as \`\`, and use an empty string only when the user chooses root placement. + + If the user provides a CLI-valid literal that does not follow the recommendation, **Do not silently transform it**. Present these choices: + 1. Convert to the suggested lowercase kebab-case value + 2. Keep the exact literal + 3. Choose another domain`; + export function getFfChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-ff-change', @@ -28,22 +37,26 @@ ${STORE_SELECTION_GUIDANCE} **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. -2. **Create the change directory** +${DOMAIN_SELECTION_GUIDANCE} + +3. **Create the change directory** \`\`\`bash - openspec new change "" + openspec new change "" --domain "" \`\`\` + For root placement, invoke \`openspec new change "" --domain ""\`. If a store was selected, append \`--store \` to either invocation. + Set \`\` to \`/\` for a non-root domain, or \`\` for root placement. Keep any selected \`--store \` on every supported follow-up command. This creates a scaffolded change in the planning home resolved by the CLI. -3. **Get the artifact build order** +4. **Get the artifact build order** \`\`\`bash - openspec status --change "" --json + openspec status --change "" --json \`\`\` Parse the JSON to get: - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - \`artifacts\`: list of all artifacts with their status and dependencies - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. -4. **Create artifacts in sequence until apply-ready** +5. **Create artifacts in sequence until apply-ready** Use the **TodoWrite tool** to track progress through the artifacts. @@ -52,7 +65,7 @@ ${STORE_SELECTION_GUIDANCE} a. **For each artifact that is \`ready\` (dependencies satisfied)**: - Get instructions: \`\`\`bash - openspec instructions --change "" --json + openspec instructions --change "" --json \`\`\` - The instructions JSON includes: - \`context\`: Project background (constraints for you - do NOT include in output) @@ -67,7 +80,7 @@ ${STORE_SELECTION_GUIDANCE} - Show brief progress: "✓ Created " b. **Continue until all \`applyRequires\` artifacts are complete** - - After creating each artifact, re-run \`openspec status --change "" --json\` + - After creating each artifact, re-run \`openspec status --change "" --json\` - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - Stop when all \`applyRequires\` artifacts are done @@ -75,9 +88,9 @@ ${STORE_SELECTION_GUIDANCE} - Use **AskUserQuestion tool** to clarify - Then continue with creation -5. **Show final status** +6. **Show final status** \`\`\`bash - openspec status --change "" + openspec status --change "" \`\`\` **Output** @@ -86,7 +99,7 @@ After completing all artifacts, summarize: - Change name and location - List of artifacts created with brief descriptions - What's ready: "All artifacts created! Ready for implementation." -- Prompt: "Run \`/opsx:apply\` or ask me to implement to start working on the tasks." +- Prompt: "Run \`/opsx:apply \` or ask me to implement to start working on the tasks." **Artifact Creation Guidelines** @@ -133,22 +146,26 @@ ${STORE_SELECTION_GUIDANCE} **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. -2. **Create the change directory** +${DOMAIN_SELECTION_GUIDANCE} + +3. **Create the change directory** \`\`\`bash - openspec new change "" + openspec new change "" --domain "" \`\`\` + For root placement, invoke \`openspec new change "" --domain ""\`. If a store was selected, append \`--store \` to either invocation. + Set \`\` to \`/\` for a non-root domain, or \`\` for root placement. Keep any selected \`--store \` on every supported follow-up command. This creates a scaffolded change in the planning home resolved by the CLI. -3. **Get the artifact build order** +4. **Get the artifact build order** \`\`\`bash - openspec status --change "" --json + openspec status --change "" --json \`\`\` Parse the JSON to get: - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - \`artifacts\`: list of all artifacts with their status and dependencies - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. -4. **Create artifacts in sequence until apply-ready** +5. **Create artifacts in sequence until apply-ready** Use the **TodoWrite tool** to track progress through the artifacts. @@ -157,7 +174,7 @@ ${STORE_SELECTION_GUIDANCE} a. **For each artifact that is \`ready\` (dependencies satisfied)**: - Get instructions: \`\`\`bash - openspec instructions --change "" --json + openspec instructions --change "" --json \`\`\` - The instructions JSON includes: - \`context\`: Project background (constraints for you - do NOT include in output) @@ -172,7 +189,7 @@ ${STORE_SELECTION_GUIDANCE} - Show brief progress: "✓ Created " b. **Continue until all \`applyRequires\` artifacts are complete** - - After creating each artifact, re-run \`openspec status --change "" --json\` + - After creating each artifact, re-run \`openspec status --change "" --json\` - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - Stop when all \`applyRequires\` artifacts are done @@ -180,9 +197,9 @@ ${STORE_SELECTION_GUIDANCE} - Use **AskUserQuestion tool** to clarify - Then continue with creation -5. **Show final status** +6. **Show final status** \`\`\`bash - openspec status --change "" + openspec status --change "" \`\`\` **Output** @@ -191,7 +208,7 @@ After completing all artifacts, summarize: - Change name and location - List of artifacts created with brief descriptions - What's ready: "All artifacts created! Ready for implementation." -- Prompt: "Run \`/opsx:apply\` to start implementing." +- Prompt: "Run \`/opsx:apply \` to start implementing." **Artifact Creation Guidelines** diff --git a/src/core/templates/workflows/new-change.ts b/src/core/templates/workflows/new-change.ts index d301fec42d..161b7f974e 100644 --- a/src/core/templates/workflows/new-change.ts +++ b/src/core/templates/workflows/new-change.ts @@ -7,6 +7,17 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +const MANDATORY_DOMAIN_SELECTION_GUIDANCE = `3. **Select the domain (required)** + + **Domain selection is mandatory.** Ask the user which domain should contain the change, and recommend lowercase kebab-case domain segments (for example, \`payments/api\`). Root placement is an explicit choice represented by an empty domain. + + If the user provides a CLI-valid literal that does not follow the recommendation, **Do not silently transform it**. Present these choices: + 1. Convert to the suggested lowercase kebab-case value + 2. Keep the exact literal + 3. Choose another domain + + Build the conversion suggestion by replacing acronym/CamelCase boundaries sensibly (\`XMLParser\` -> \`xml-parser\`, \`IOError\` -> \`io-error\`), replace underscores with hyphens, and lowercase only after the user chooses conversion. Use the user's confirmed value as \`\`; use an empty string for root placement.`; + export function getNewChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-new-change', @@ -38,28 +49,32 @@ ${STORE_SELECTION_GUIDANCE} **Otherwise**: Omit \`--schema\` to use the default. -3. **Create the change directory** +${MANDATORY_DOMAIN_SELECTION_GUIDANCE} + +4. **Create the change directory** \`\`\`bash - openspec new change "" + openspec new change "" --domain "" \`\`\` + For root placement, invoke \`openspec new change "" --domain ""\`. If a store was selected, append \`--store \` to either invocation. + Set \`\` to \`/\` for a non-root domain, or \`\` for root placement. Keep any selected \`--store \` on every supported follow-up command. Add \`--schema \` only if the user requested a specific workflow. This creates a scaffolded change in the planning home resolved by the CLI. -4. **Show the artifact status** +5. **Show the artifact status** \`\`\`bash - openspec status --change "" --json + openspec status --change "" --json \`\`\` Use the returned \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`nextSteps\` instead of assuming repo-local paths. -5. **Get instructions for the first artifact** +6. **Get instructions for the first artifact** The first artifact depends on the schema (e.g., \`proposal\` for spec-driven). Check the status output to find the first artifact with status "ready". \`\`\`bash - openspec instructions --change "" + openspec instructions --change "" \`\`\` This outputs the template and context for creating the first artifact. -6. **STOP and wait for user direction** +7. **STOP and wait for user direction** **Output** @@ -115,27 +130,31 @@ ${STORE_SELECTION_GUIDANCE} **Otherwise**: Omit \`--schema\` to use the default. -3. **Create the change directory** +${MANDATORY_DOMAIN_SELECTION_GUIDANCE} + +4. **Create the change directory** \`\`\`bash - openspec new change "" + openspec new change "" --domain "" \`\`\` + For root placement, invoke \`openspec new change "" --domain ""\`. If a store was selected, append \`--store \` to either invocation. + Set \`\` to \`/\` for a non-root domain, or \`\` for root placement. Keep any selected \`--store \` on every supported follow-up command. Add \`--schema \` only if the user requested a specific workflow. This creates a scaffolded change in the planning home resolved by the CLI. -4. **Show the artifact status** +5. **Show the artifact status** \`\`\`bash - openspec status --change "" --json + openspec status --change "" --json \`\`\` Use the returned \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`nextSteps\` instead of assuming repo-local paths. -5. **Get instructions for the first artifact** +6. **Get instructions for the first artifact** The first artifact depends on the schema. Check the status output to find the first artifact with status "ready". \`\`\`bash - openspec instructions --change "" + openspec instructions --change "" \`\`\` This outputs the template and context for creating the first artifact. -6. **STOP and wait for user direction** +7. **STOP and wait for user direction** **Output** @@ -144,13 +163,13 @@ After completing the steps, summarize: - Schema/workflow being used and its artifact sequence - Current status (0/N artifacts complete) - The template for the first artifact -- Prompt: "Ready to create the first artifact? Run \`/opsx:continue\` or just describe what this change is about and I'll draft it." +- Prompt: "Ready to create the first artifact? Run \`/opsx:continue \` or just describe what this change is about and I'll draft it." **Guardrails** - Do NOT create any artifacts yet - just show the instructions - Do NOT advance beyond showing the first artifact template - If the name is invalid (not kebab-case), ask for a valid name -- If a change with that name already exists, suggest using \`/opsx:continue\` instead +- If a change with that ID already exists, suggest using \`/opsx:continue \` instead - Pass --schema if using a non-default workflow` }; } diff --git a/src/core/templates/workflows/onboard.ts b/src/core/templates/workflows/onboard.ts index 96f1b943bc..611aa6364f 100644 --- a/src/core/templates/workflows/onboard.ts +++ b/src/core/templates/workflows/onboard.ts @@ -179,15 +179,19 @@ Now let's create a change to hold our work. \`\`\` ## Creating a Change -A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives at the \`changeRoot\` reported by \`openspec status --change "" --json\` and holds your artifacts—proposal, specs, design, tasks. +A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives at the \`changeRoot\` reported by \`openspec status --change "" --json\` and holds your artifacts—proposal, specs, design, tasks. Let me create one for our task. \`\`\` -**DO:** Create the change with a derived kebab-case name: +**DOMAIN DECISION:** Ask the user which domain should contain this change. Recommend lowercase kebab-case domain segments, set the confirmed choice as \`\`, and use an empty string only when the user explicitly chooses root placement. If the user provides a CLI-valid literal that does not follow the recommendation, **Do not silently transform it**. Offer exactly these choices: Convert to the suggested lowercase kebab-case value, Keep the exact literal, or Choose another domain. + +**DO:** Create the change with a derived kebab-case \`\`: \`\`\`bash -openspec new change "" +openspec new change "" --domain "" \`\`\` +For root placement, invoke \`openspec new change "" --domain ""\`. If a store was selected, append \`--store \` to either invocation. +Set \`\` to \`/\` for a non-root domain, or \`\` for root placement. Keep any selected \`--store \` on every supported follow-up command. **SHOW:** \`\`\` @@ -255,9 +259,9 @@ Does this capture the intent? I can adjust before we save it. After approval, save the proposal: \`\`\`bash -openspec instructions proposal --change "" --json +openspec instructions proposal --change "" --json \`\`\` -Then write the content to the \`resolvedOutputPath\` from \`openspec instructions proposal --change "" --json\`. +Then write the content to the \`resolvedOutputPath\` from \`openspec instructions proposal --change "" --json\`. \`\`\` Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves. @@ -280,7 +284,7 @@ For a small task like this, we might only need one spec file. **DO:** Resolve where the spec file should be created: \`\`\`bash -openspec instructions specs --change "" --json +openspec instructions specs --change "" --json # Use resolvedOutputPath from the JSON. If it is a glob, choose the concrete file path using the schema instruction and the change's context. \`\`\` @@ -353,7 +357,7 @@ Here's the design: For a small task, this captures the key decisions without over-engineering. \`\`\` -Save to the \`resolvedOutputPath\` from \`openspec instructions design --change "" --json\`. +Save to the \`resolvedOutputPath\` from \`openspec instructions design --change "" --json\`. --- @@ -391,7 +395,7 @@ Each checkbox becomes a unit of work in the apply phase. Ready to implement? **PAUSE** - Wait for user to confirm they're ready to implement. -Save to the \`resolvedOutputPath\` from \`openspec instructions tasks --change "" --json\`. +Save to the \`resolvedOutputPath\` from \`openspec instructions tasks --change "" --json\`. --- @@ -435,19 +439,23 @@ The change is implemented! One more step—let's archive it. \`\`\` ## Archiving -When a change is complete, we archive it. The archive path is derived from \`planningHome.changesDir\` and the date. +When a change is complete, we archive it. Derive the archive destination from the full slash-delimited change ID: the last segment is \`\`, and all preceding segments form \`\`. + +A domain change targets \`/openspec/archive//YYYY-MM-DD-\`. A root change targets \`/openspec/archive/YYYY-MM-DD-\`. Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way. \`\`\` **DO:** \`\`\`bash -openspec archive "" +openspec archive "" \`\`\` +If a store was selected, append \`--store \`. **SHOW:** \`\`\` -Archived to: \`/archive/YYYY-MM-DD-/\` +Archived to: \`/openspec/archive//YYYY-MM-DD-/\` +(For a root change: \`/openspec/archive/YYYY-MM-DD-/\`.) The change is now part of your project's history. The code is in your codebase, the decision record is preserved. \`\`\` @@ -482,17 +490,17 @@ This same rhythm works for any size change—a small fix or a major feature. |-------------------|--------------------------------------------| | \`/opsx:propose\` | Create a change and generate all artifacts | | \`/opsx:explore\` | Think through problems before/during work | - | \`/opsx:apply\` | Implement tasks from a change | - | \`/opsx:archive\` | Archive a completed change | + | \`/opsx:apply \` | Implement tasks from a change | + | \`/opsx:archive \` | Archive a completed change | **Additional commands:** | Command | What it does | |--------------------|----------------------------------------------------------| | \`/opsx:new\` | Start a new change, step through artifacts one at a time | - | \`/opsx:continue\` | Continue working on an existing change | + | \`/opsx:continue \` | Continue working on an existing change | | \`/opsx:ff\` | Fast-forward: create all artifacts at once | - | \`/opsx:verify\` | Verify implementation matches artifacts | + | \`/opsx:verify \` | Verify implementation matches artifacts | --- @@ -510,11 +518,11 @@ Try \`/opsx:propose\` on something you actually want to build. You've got the rh If the user says they need to stop, want to pause, or seem disengaged: \`\`\` -No problem! Your change is saved at the \`changeRoot\` reported by \`openspec status --change "" --json\`. +No problem! Your change is saved at the \`changeRoot\` reported by \`openspec status --change "" --json\`. To pick up where we left off later: -- \`/opsx:continue \` - Resume artifact creation -- \`/opsx:apply \` - Jump to implementation (if tasks exist) +- \`/opsx:continue \` - Resume artifact creation +- \`/opsx:apply \` - Jump to implementation (if tasks exist) The work won't be lost. Come back whenever you're ready. \`\`\` @@ -534,17 +542,17 @@ If the user says they just want to see the commands or skip the tutorial: |--------------------------|--------------------------------------------| | \`/opsx:propose \` | Create a change and generate all artifacts | | \`/opsx:explore\` | Think through problems (no code changes) | - | \`/opsx:apply \` | Implement tasks | - | \`/opsx:archive \` | Archive when done | + | \`/opsx:apply \` | Implement tasks | + | \`/opsx:archive \` | Archive when done | **Additional commands:** | Command | What it does | |---------------------------|-------------------------------------| | \`/opsx:new \` | Start a new change, step by step | - | \`/opsx:continue \` | Continue an existing change | + | \`/opsx:continue \` | Continue an existing change | | \`/opsx:ff \` | Fast-forward: all artifacts at once | - | \`/opsx:verify \` | Verify implementation | + | \`/opsx:verify \` | Verify implementation | Try \`/opsx:propose\` to start your first change. \`\`\` diff --git a/src/core/templates/workflows/propose.ts b/src/core/templates/workflows/propose.ts index d84dab5a85..3af9c5c517 100644 --- a/src/core/templates/workflows/propose.ts +++ b/src/core/templates/workflows/propose.ts @@ -7,6 +7,17 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +const MANDATORY_DOMAIN_SELECTION_GUIDANCE = `2. **Select the domain (required)** + + **Domain selection is mandatory.** Ask the user which domain should contain the change, and recommend lowercase kebab-case domain segments (for example, \`payments/api\`). Root placement is an explicit choice represented by an empty domain. + + If the user provides a CLI-valid literal that does not follow the recommendation, **Do not silently transform it**. Present these choices: + 1. Convert to the suggested lowercase kebab-case value + 2. Keep the exact literal + 3. Choose another domain + + Build the conversion suggestion by replacing acronym/CamelCase boundaries sensibly (\`XMLParser\` -> \`xml-parser\`, \`IOError\` -> \`io-error\`), replace underscores with hyphens, and lowercase only after the user chooses conversion. Use the user's confirmed value as \`\`; use an empty string for root placement.`; + export function getOpsxProposeSkillTemplate(): SkillTemplate { return { name: 'openspec-propose', @@ -18,7 +29,7 @@ I'll create a change with artifacts: - design.md (how) - tasks.md (implementation steps) -When ready to implement, run /opsx:apply +When ready to implement, run /opsx:apply --- @@ -37,22 +48,26 @@ ${STORE_SELECTION_GUIDANCE} **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. -2. **Create the change directory** +${MANDATORY_DOMAIN_SELECTION_GUIDANCE} + +3. **Create the change directory** \`\`\`bash - openspec new change "" + openspec new change "" --domain "" \`\`\` + For root placement, invoke \`openspec new change "" --domain ""\`. If a store was selected, append \`--store \` to either invocation. + Set \`\` to \`/\` for a non-root domain, or \`\` for root placement. Keep any selected \`--store \` on every supported follow-up command. This creates a scaffolded change in the planning home resolved by the CLI with \`.openspec.yaml\`. -3. **Get the artifact build order** +4. **Get the artifact build order** \`\`\`bash - openspec status --change "" --json + openspec status --change "" --json \`\`\` Parse the JSON to get: - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - \`artifacts\`: list of all artifacts with their status and dependencies - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. -4. **Create artifacts in sequence until apply-ready** +5. **Create artifacts in sequence until apply-ready** Use the **TodoWrite tool** to track progress through the artifacts. @@ -61,7 +76,7 @@ ${STORE_SELECTION_GUIDANCE} a. **For each artifact that is \`ready\` (dependencies satisfied)**: - Get instructions: \`\`\`bash - openspec instructions --change "" --json + openspec instructions --change "" --json \`\`\` - The instructions JSON includes: - \`context\`: Project background (constraints for you - do NOT include in output) @@ -76,7 +91,7 @@ ${STORE_SELECTION_GUIDANCE} - Show brief progress: "Created " b. **Continue until all \`applyRequires\` artifacts are complete** - - After creating each artifact, re-run \`openspec status --change "" --json\` + - After creating each artifact, re-run \`openspec status --change "" --json\` - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - Stop when all \`applyRequires\` artifacts are done @@ -84,9 +99,9 @@ ${STORE_SELECTION_GUIDANCE} - Use **AskUserQuestion tool** to clarify - Then continue with creation -5. **Show final status** +6. **Show final status** \`\`\`bash - openspec status --change "" + openspec status --change "" \`\`\` **Output** @@ -95,7 +110,7 @@ After completing all artifacts, summarize: - Change name and location - List of artifacts created with brief descriptions - What's ready: "All artifacts created! Ready for implementation." -- Prompt: "Run \`/opsx:apply\` or ask me to implement to start working on the tasks." +- Prompt: "Run \`/opsx:apply \` or ask me to implement to start working on the tasks." **Artifact Creation Guidelines** @@ -132,7 +147,7 @@ I'll create a change with artifacts: - design.md (how) - tasks.md (implementation steps) -When ready to implement, run /opsx:apply +When ready to implement, run /opsx:apply --- @@ -151,22 +166,26 @@ ${STORE_SELECTION_GUIDANCE} **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. -2. **Create the change directory** +${MANDATORY_DOMAIN_SELECTION_GUIDANCE} + +3. **Create the change directory** \`\`\`bash - openspec new change "" + openspec new change "" --domain "" \`\`\` + For root placement, invoke \`openspec new change "" --domain ""\`. If a store was selected, append \`--store \` to either invocation. + Set \`\` to \`/\` for a non-root domain, or \`\` for root placement. Keep any selected \`--store \` on every supported follow-up command. This creates a scaffolded change in the planning home resolved by the CLI with \`.openspec.yaml\`. -3. **Get the artifact build order** +4. **Get the artifact build order** \`\`\`bash - openspec status --change "" --json + openspec status --change "" --json \`\`\` Parse the JSON to get: - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - \`artifacts\`: list of all artifacts with their status and dependencies - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. -4. **Create artifacts in sequence until apply-ready** +5. **Create artifacts in sequence until apply-ready** Use the **TodoWrite tool** to track progress through the artifacts. @@ -175,7 +194,7 @@ ${STORE_SELECTION_GUIDANCE} a. **For each artifact that is \`ready\` (dependencies satisfied)**: - Get instructions: \`\`\`bash - openspec instructions --change "" --json + openspec instructions --change "" --json \`\`\` - The instructions JSON includes: - \`context\`: Project background (constraints for you - do NOT include in output) @@ -190,7 +209,7 @@ ${STORE_SELECTION_GUIDANCE} - Show brief progress: "Created " b. **Continue until all \`applyRequires\` artifacts are complete** - - After creating each artifact, re-run \`openspec status --change "" --json\` + - After creating each artifact, re-run \`openspec status --change "" --json\` - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - Stop when all \`applyRequires\` artifacts are done @@ -198,9 +217,9 @@ ${STORE_SELECTION_GUIDANCE} - Use **AskUserQuestion tool** to clarify - Then continue with creation -5. **Show final status** +6. **Show final status** \`\`\`bash - openspec status --change "" + openspec status --change "" \`\`\` **Output** @@ -209,7 +228,7 @@ After completing all artifacts, summarize: - Change name and location - List of artifacts created with brief descriptions - What's ready: "All artifacts created! Ready for implementation." -- Prompt: "Run \`/opsx:apply\` to start implementing." +- Prompt: "Run \`/opsx:apply \` to start implementing." **Artifact Creation Guidelines** diff --git a/src/core/templates/workflows/sync-specs.ts b/src/core/templates/workflows/sync-specs.ts index 8e25534c30..6fbb7d46c0 100644 --- a/src/core/templates/workflows/sync-specs.ts +++ b/src/core/templates/workflows/sync-specs.ts @@ -17,7 +17,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e ${STORE_SELECTION_GUIDANCE} -**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. +**Input**: Optionally specify a full slash-delimited change ID. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -29,11 +29,13 @@ ${STORE_SELECTION_GUIDANCE} **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Treat the full selected or supplied value as \`\`; do not reduce it to its final name segment. + 2. **Resolve change context** Run: \`\`\`bash - openspec status --change "" --json + openspec status --change "" --json \`\`\` 3. **Find delta specs** @@ -54,7 +56,7 @@ ${STORE_SELECTION_GUIDANCE} a. **Read the delta spec** to understand the intended changes - b. **Read the main spec** at \`openspec/specs//spec.md\` (may not exist yet) + b. **Derive the canonical spec ID and read the main spec**: derive \`\` only from every segment before the final \`\` in the full \`\`. The repo-local delta path \`specs//spec.md\` does not contain the change domain. Read \`openspec/specs///spec.md\` for a domain-qualified change, or \`openspec/specs//spec.md\` for a root change (it may not exist yet). c. **Apply changes intelligently**: @@ -77,7 +79,7 @@ ${STORE_SELECTION_GUIDANCE} - Find the FROM requirement, rename to TO d. **Create new main spec** if capability doesn't exist yet: - - Create \`openspec/specs//spec.md\` + - Create \`openspec/specs///spec.md\` or \`openspec/specs//spec.md\`, matching the delta's full spec ID - Add Purpose section (can be brief, mark as TBD) - Add Requirements section with the ADDED requirements @@ -165,7 +167,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e ${STORE_SELECTION_GUIDANCE} -**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. +**Input**: Optionally specify a full slash-delimited change ID after \`/opsx:sync\` (e.g., \`/opsx:sync auth/add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -177,11 +179,13 @@ ${STORE_SELECTION_GUIDANCE} **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Treat the full selected or supplied value as \`\`; do not reduce it to its final name segment. + 2. **Resolve change context** Run: \`\`\`bash - openspec status --change "" --json + openspec status --change "" --json \`\`\` 3. **Find delta specs** @@ -202,7 +206,7 @@ ${STORE_SELECTION_GUIDANCE} a. **Read the delta spec** to understand the intended changes - b. **Read the main spec** at \`openspec/specs//spec.md\` (may not exist yet) + b. **Derive the canonical spec ID and read the main spec**: derive \`\` only from every segment before the final \`\` in the full \`\`. The repo-local delta path \`specs//spec.md\` does not contain the change domain. Read \`openspec/specs///spec.md\` for a domain-qualified change, or \`openspec/specs//spec.md\` for a root change (it may not exist yet). c. **Apply changes intelligently**: @@ -225,7 +229,7 @@ ${STORE_SELECTION_GUIDANCE} - Find the FROM requirement, rename to TO d. **Create new main spec** if capability doesn't exist yet: - - Create \`openspec/specs//spec.md\` + - Create \`openspec/specs///spec.md\` or \`openspec/specs//spec.md\`, matching the delta's full spec ID - Add Purpose section (can be brief, mark as TBD) - Add Requirements section with the ADDED requirements diff --git a/src/core/view.ts b/src/core/view.ts index 343775bb5d..4f96df7e15 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import chalk from 'chalk'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { MarkdownParser } from './parsers/markdown-parser.js'; +import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js'; export class ViewCommand { async execute(targetPath: string = '.'): Promise { @@ -93,22 +94,21 @@ export class ViewCommand { const active: Array<{ name: string; progress: { total: number; completed: number } }> = []; const completed: Array<{ name: string }> = []; - const entries = fs.readdirSync(changesDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'archive') { - const progress = await getTaskProgressForChange(changesDir, entry.name, path.dirname(openspecDir)); - - if (progress.total === 0) { - // No tasks defined yet - still in planning/draft phase - draft.push({ name: entry.name }); - } else if (progress.completed === progress.total) { - // All tasks complete - completed.push({ name: entry.name }); - } else { - // Has tasks but not all complete - active.push({ name: entry.name, progress }); - } + const projectRoot = path.dirname(openspecDir); + const changeIds = await getActiveChangeIds(projectRoot); + + for (const changeId of changeIds) { + const progress = await getTaskProgressForChange(changesDir, changeId, projectRoot); + + if (progress.total === 0) { + // No tasks defined yet - still in planning/draft phase + draft.push({ name: changeId }); + } else if (progress.completed === progress.total) { + // All tasks complete + completed.push({ name: changeId }); + } else { + // Has tasks but not all complete + active.push({ name: changeId, progress }); } } @@ -137,24 +137,19 @@ export class ViewCommand { } const specs: Array<{ name: string; requirementCount: number }> = []; - const entries = fs.readdirSync(specsDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - const specFile = path.join(specsDir, entry.name, 'spec.md'); - - if (fs.existsSync(specFile)) { - try { - const content = fs.readFileSync(specFile, 'utf-8'); - const parser = new MarkdownParser(content); - const spec = parser.parseSpec(entry.name); - const requirementCount = spec.requirements.length; - specs.push({ name: entry.name, requirementCount }); - } catch (error) { - // If spec cannot be parsed, include with 0 count - specs.push({ name: entry.name, requirementCount: 0 }); - } - } + const specIds = await getSpecIds(path.dirname(openspecDir)); + + for (const specId of specIds) { + const specFile = path.join(specsDir, ...specId.split('/'), 'spec.md'); + try { + const content = fs.readFileSync(specFile, 'utf-8'); + const parser = new MarkdownParser(content); + const spec = parser.parseSpec(specId); + const requirementCount = spec.requirements.length; + specs.push({ name: specId, requirementCount }); + } catch (error) { + // If spec cannot be parsed, include with 0 count + specs.push({ name: specId, requirementCount: 0 }); } } @@ -216,4 +211,4 @@ export class ViewCommand { return `[${filledBar}${emptyBar}]`; } -} \ No newline at end of file +} diff --git a/src/utils/change-path.ts b/src/utils/change-path.ts new file mode 100644 index 0000000000..bbbe0498b4 --- /dev/null +++ b/src/utils/change-path.ts @@ -0,0 +1,362 @@ +import { promises as fs } from 'fs'; +import path from 'path'; + +const DOMAIN_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/; + +export interface ValidationResult { + valid: boolean; + error?: string; +} + +export type InvalidChangeIdCode = + | 'invalid_domain' + | 'invalid_name' + | 'reserved_root' + | 'outside_changes'; + +export class InvalidChangeIdError extends Error { + constructor( + readonly changeId: string, + readonly code: InvalidChangeIdCode, + message: string + ) { + super(`Invalid change name '${changeId}': ${message}`); + this.name = 'InvalidChangeIdError'; + } +} + +export class ChangeNotFoundError extends Error { + constructor(readonly changeId: string) { + super(`Change '${changeId}' not found.`); + this.name = 'ChangeNotFoundError'; + } +} + +export interface ResolvedChangeId { + id: string; + domain: string[]; + name: string; + path: string; +} + +export function splitChangeId(id: string): { domain: string[]; name: string } { + const segments = id.split('/'); + return { + domain: segments.slice(0, -1), + name: segments[segments.length - 1] ?? '', + }; +} + +export function validateDomainPath(domainPath: string): ValidationResult { + if (domainPath === '') { + return { valid: true }; + } + + if (domainPath.includes('\\')) { + return { valid: false, error: 'Domain path must use forward slashes' }; + } + + for (const segment of domainPath.split('/')) { + if (segment.length === 0) { + return { valid: false, error: 'Domain path cannot contain empty segments' }; + } + + if (/\s/.test(segment)) { + return { valid: false, error: 'Domain path cannot contain whitespace' }; + } + + if (segment === '.' || segment === '..') { + return { valid: false, error: 'Domain path cannot contain "." or ".." segments' }; + } + + if (segment.startsWith('.')) { + return { valid: false, error: 'Domain path segments cannot start with a dot' }; + } + + if (!DOMAIN_SEGMENT_PATTERN.test(segment)) { + return { + valid: false, + error: 'Domain path can only contain letters, numbers, hyphens, underscores, and dots', + }; + } + } + + return { valid: true }; +} + +export function validateChangeName(name: string): ValidationResult { + const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; + + if (!name) { + return { valid: false, error: 'Change name cannot be empty' }; + } + + if (!kebabCasePattern.test(name)) { + if (/[A-Z]/.test(name)) { + return { valid: false, error: 'Change name must be lowercase (use kebab-case)' }; + } + if (/\s/.test(name)) { + return { valid: false, error: 'Change name cannot contain spaces (use hyphens instead)' }; + } + if (/_/.test(name)) { + return { valid: false, error: 'Change name cannot contain underscores (use hyphens instead)' }; + } + if (name.startsWith('-')) { + return { valid: false, error: 'Change name cannot start with a hyphen' }; + } + if (name.endsWith('-')) { + return { valid: false, error: 'Change name cannot end with a hyphen' }; + } + if (/--/.test(name)) { + return { valid: false, error: 'Change name cannot contain consecutive hyphens' }; + } + if (/[^a-z0-9-]/.test(name)) { + return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' }; + } + if (/^[0-9]/.test(name)) { + return { valid: false, error: 'Change name must start with a letter' }; + } + + return { + valid: false, + error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)', + }; + } + + return { valid: true }; +} + +export async function resolveExistingChangeId( + changeId: string, + changesDir: string +): Promise { + const { domain, name } = splitChangeId(changeId); + const domainValidation = validateDomainPath(domain.join('/')); + if (!domainValidation.valid) { + throw new InvalidChangeIdError( + changeId, + 'invalid_domain', + domainValidation.error ?? 'Invalid domain path' + ); + } + + const nameValidation = validateChangeName(name); + if (!nameValidation.valid) { + throw new InvalidChangeIdError( + changeId, + 'invalid_name', + nameValidation.error ?? 'Invalid change name' + ); + } + + const rootSegment = domain[0] ?? name; + if (rootSegment.toLowerCase() === 'archive') { + throw new InvalidChangeIdError( + changeId, + 'reserved_root', + "Change ID root segment 'archive' is reserved" + ); + } + + const changesRoot = path.resolve(changesDir); + const changePath = path.resolve(changesRoot, ...domain, name); + assertContainedChangePath(changeId, changesRoot, changePath); + + let stat; + try { + stat = await fs.stat(changePath); + } catch (error) { + if (isChangeLookupNotFoundError(error)) { + throw new ChangeNotFoundError(changeId); + } + throw error; + } + if (!stat.isDirectory()) { + throw new ChangeNotFoundError(changeId); + } + + const [canonicalRoot, canonicalChange] = await Promise.all([ + fs.realpath(changesRoot), + fs.realpath(changePath), + ]); + assertContainedChangePath(changeId, canonicalRoot, canonicalChange); + + if (!(await hasOwnChangeMarker(changePath))) { + const nestedChanges = await findChangeIds(changePath, false); + if (nestedChanges.length > 0 || !(await hasLegacyChangeContent(changePath))) { + throw new ChangeNotFoundError(changeId); + } + } + + return { id: changeId, domain, name, path: changePath }; +} + +export function buildArchivePath(archiveDir: string, changeId: string, date: string): string { + const { domain, name } = splitChangeId(changeId); + return path.join(archiveDir, ...domain, `${date}-${name}`); +} + +export async function findAllChangeIds(changesDir: string): Promise { + return findChangeIds(changesDir, true); +} + +export async function findAllArchivedChangeIds(archiveDir: string): Promise { + return findChangeIds(archiveDir, false); +} + +export async function pathExistsWithoutFollowingLinks(targetPath: string): Promise { + try { + await fs.lstat(targetPath); + return true; + } catch (error) { + if (isChangeLookupNotFoundError(error)) { + return false; + } + throw error; + } +} + +export async function assertProspectivePathContained( + root: string, + target: string, + label: string +): Promise { + const resolvedRoot = path.resolve(root); + const resolvedTarget = path.resolve(target); + const containmentError = () => new Error( + `${label} destination must stay within the selected root.` + ); + + if (!isContainedPath(resolvedRoot, resolvedTarget)) { + throw containmentError(); + } + + const missingSegments: string[] = []; + let existingAncestor = resolvedTarget; + while (!(await pathExistsWithoutFollowingLinks(existingAncestor))) { + const parent = path.dirname(existingAncestor); + if (parent === existingAncestor) { + throw containmentError(); + } + missingSegments.unshift(path.basename(existingAncestor)); + existingAncestor = parent; + } + + let canonicalRoot: string; + let canonicalAncestor: string; + try { + [canonicalRoot, canonicalAncestor] = await Promise.all([ + fs.realpath(resolvedRoot), + fs.realpath(existingAncestor), + ]); + } catch (error) { + if (isChangeLookupNotFoundError(error)) { + throw containmentError(); + } + throw error; + } + + const canonicalTarget = path.resolve(canonicalAncestor, ...missingSegments); + if (!isContainedPath(canonicalRoot, canonicalTarget)) { + throw containmentError(); + } + + if (missingSegments.length > 0) { + const ancestorStats = await fs.stat(existingAncestor); + if (!ancestorStats.isDirectory()) { + throw containmentError(); + } + } +} + +async function findChangeIds(rootDir: string, ignoreRootArchive: boolean): Promise { + const ids: string[] = []; + + async function walk(currentDir: string, segments: string[]): Promise { + let entries; + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch (error) { + if (isMissingPathError(error)) { + return; + } + + throw error; + } + + const hasMarker = segments.length > 0 && entries.some( + (entry) => entry.isFile() && (entry.name === '.openspec.yaml' || entry.name === 'proposal.md') + ); + + if (hasMarker) { + ids.push(segments.join('/')); + return; + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) { + continue; + } + + if (ignoreRootArchive && segments.length === 0 && entry.name === 'archive') { + continue; + } + + await walk(path.join(currentDir, entry.name), [...segments, entry.name]); + } + } + + await walk(rootDir, []); + return ids.sort(); +} + +function isMissingPathError(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error && + error.code === 'ENOENT'; +} + +function isChangeLookupNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return isMissingPathError(error) || + (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOTDIR'); +} + +function assertContainedChangePath(changeId: string, root: string, target: string): void { + if (!isContainedPath(root, target)) { + throw new InvalidChangeIdError( + changeId, + 'outside_changes', + 'Change path must stay within changesDir' + ); + } +} + +function isContainedPath(root: string, target: string): boolean { + const relativePath = path.relative(root, target); + return !path.isAbsolute(relativePath) && + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`); +} + +async function hasOwnChangeMarker(changePath: string): Promise { + for (const marker of ['.openspec.yaml', 'proposal.md']) { + try { + const stats = await fs.stat(path.join(changePath, marker)); + if (stats.isFile()) { + return true; + } + } catch (error) { + if (!isMissingPathError(error)) { + throw error; + } + } + } + return false; +} + +async function hasLegacyChangeContent(changePath: string): Promise { + const entries = await fs.readdir(changePath, { withFileTypes: true }); + return entries.some((entry) => + (entry.isFile() && ['design.md', 'tasks.md', 'README.md'].includes(entry.name)) || + (entry.isDirectory() && entry.name === 'specs') + ); +} diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index c47a4a3efe..654bccc8a9 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -1,10 +1,77 @@ import path from 'path'; +import { promises as fs } from 'fs'; import { FileSystemUtils } from './file-system.js'; import { writeChangeMetadata, validateSchemaName } from './change-metadata.js'; +import { + splitChangeId, + validateChangeName, + validateDomainPath, +} from './change-path.js'; import { readProjectConfig } from '../core/project-config.js'; import type { ChangeMetadata } from '../core/change-metadata/index.js'; const DEFAULT_SCHEMA = 'spec-driven'; +const CHANGE_MARKERS = ['.openspec.yaml', 'proposal.md'] as const; + +async function canonicalizeProspectivePath(targetPath: string): Promise { + const missingSegments: string[] = []; + let currentPath = path.resolve(targetPath); + + while (true) { + try { + const existingPath = await fs.realpath(currentPath); + return path.resolve(existingPath, ...missingSegments.reverse()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + throw error; + } + missingSegments.push(path.basename(currentPath)); + currentPath = parentPath; + } + } +} + +function pathStaysWithin(basePath: string, targetPath: string): boolean { + const relativePath = path.relative(basePath, targetPath); + return ( + relativePath === '' || + (!path.isAbsolute(relativePath) && + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`)) + ); +} + +async function pathIsFile(targetPath: string): Promise { + try { + return (await fs.stat(targetPath)).isFile(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +} + +async function assertDomainPrefixesAreNotChanges( + changesDir: string, + domain: string[] +): Promise { + const prefix: string[] = []; + for (const segment of domain) { + prefix.push(segment); + const prefixDir = path.join(changesDir, ...prefix); + for (const marker of CHANGE_MARKERS) { + if (await pathIsFile(path.join(prefixDir, marker))) { + throw new Error(`Domain prefix '${prefix.join('/')}' is already a change`); + } + } + } +} /** * Options for creating a change. @@ -30,79 +97,16 @@ export interface CreateChangeResult { changeDir: string; } -/** - * Result of validating a change name. - */ -export interface ValidationResult { - valid: boolean; - error?: string; -} - -/** - * Validates that a change name follows kebab-case conventions. - * - * Valid names: - * - Start with a lowercase letter - * - Contain only lowercase letters, numbers, and hyphens - * - Do not start or end with a hyphen - * - Do not contain consecutive hyphens - * - * @param name - The change name to validate - * @returns Validation result with `valid: true` or `valid: false` with an error message - * - * @example - * validateChangeName('add-auth') // { valid: true } - * validateChangeName('Add-Auth') // { valid: false, error: '...' } - */ -export function validateChangeName(name: string): ValidationResult { - // Pattern: starts with lowercase letter, followed by lowercase letters/numbers, - // optionally followed by hyphen + lowercase letters/numbers (repeatable) - const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; - - if (!name) { - return { valid: false, error: 'Change name cannot be empty' }; - } - - if (!kebabCasePattern.test(name)) { - // Provide specific error messages for common mistakes - if (/[A-Z]/.test(name)) { - return { valid: false, error: 'Change name must be lowercase (use kebab-case)' }; - } - if (/\s/.test(name)) { - return { valid: false, error: 'Change name cannot contain spaces (use hyphens instead)' }; - } - if (/_/.test(name)) { - return { valid: false, error: 'Change name cannot contain underscores (use hyphens instead)' }; - } - if (name.startsWith('-')) { - return { valid: false, error: 'Change name cannot start with a hyphen' }; - } - if (name.endsWith('-')) { - return { valid: false, error: 'Change name cannot end with a hyphen' }; - } - if (/--/.test(name)) { - return { valid: false, error: 'Change name cannot contain consecutive hyphens' }; - } - if (/[^a-z0-9-]/.test(name)) { - return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' }; - } - if (/^[0-9]/.test(name)) { - return { valid: false, error: 'Change name must start with a letter' }; - } - - return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' }; - } - - return { valid: true }; -} +export { validateChangeName } from './change-path.js'; +export type { ValidationResult } from './change-path.js'; /** * Creates a new change directory with metadata file. * * @param projectRoot - The root directory of the project (where `openspec/` lives) - * @param name - The change name (must be valid kebab-case) + * @param changeId - Full change ID; domain segments are optional and the final name must be kebab-case * @param options - Optional settings for the change - * @throws Error if the change name is invalid + * @throws Error if the domain path or final change name is invalid * @throws Error if the schema name is invalid * @throws Error if the change directory already exists * @@ -114,21 +118,31 @@ export function validateChangeName(name: string): ValidationResult { * console.log(result.schema) // 'spec-driven' or value from config * * @example - * // Creates openspec/changes/add-auth/ with custom schema - * const result = await createChange('/path/to/project', 'add-auth', { schema: 'my-workflow' }) + * // Creates openspec/changes/Platform/API/add-auth/ with custom schema + * const result = await createChange('/path/to/project', 'Platform/API/add-auth', { schema: 'my-workflow' }) * console.log(result.schema) // 'my-workflow' */ export async function createChange( projectRoot: string, - name: string, + changeId: string, options: CreateChangeOptions = {} ): Promise { - // Validate the name first + const { domain, name } = splitChangeId(changeId); + const domainValidation = validateDomainPath(domain.join('/')); + if (!domainValidation.valid) { + throw new Error(domainValidation.error); + } + const validation = validateChangeName(name); if (!validation.valid) { throw new Error(validation.error); } + const rootSegment = domain[0] ?? name; + if (rootSegment.toLowerCase() === 'archive') { + throw new Error("Change ID root segment 'archive' is reserved"); + } + const defaultSchema = options.defaultSchema ?? DEFAULT_SCHEMA; // Determine schema: explicit option → project config → supplied default @@ -149,18 +163,31 @@ export async function createChange( // Validate the resolved schema validateSchemaName(schemaName, projectRoot); - // Build the change directory path - const changeDir = path.join(options.changesDir ?? path.join(projectRoot, 'openspec', 'changes'), name); + const changesDir = path.resolve( + options.changesDir ?? path.join(projectRoot, 'openspec', 'changes') + ); + const changeDir = path.resolve(changesDir, ...domain, name); + if (!pathStaysWithin(changesDir, changeDir)) { + throw new Error('Change path must stay within changesDir'); + } + + const canonicalChangesDir = await canonicalizeProspectivePath(changesDir); + const canonicalChangeDir = await canonicalizeProspectivePath(changeDir); + if (!pathStaysWithin(canonicalChangesDir, canonicalChangeDir)) { + throw new Error('Change path must stay within changesDir'); + } + + await assertDomainPrefixesAreNotChanges(changesDir, domain); // Check if change already exists if (await FileSystemUtils.directoryExists(changeDir)) { - throw new Error(`Change '${name}' already exists at ${changeDir}`); + throw new Error(`Change '${changeId}' already exists at ${changeDir}`); } // Creating a change may scaffold or complete the root itself (an // implicit root, or a config-only/incomplete clone). Never leave a // half-root behind that doctor immediately calls unhealthy: ensure - // specs/ and changes/archive/ exist, and write a config only when + // specs/ and archive/ exist, and write a config only when // none exists. The config records the PROJECT default schema, never // a one-change --schema override. const openspecDir = path.join(projectRoot, 'openspec'); @@ -168,7 +195,7 @@ export async function createChange( // Create the directory (including parent directories if needed) await FileSystemUtils.createDirectory(changeDir); await FileSystemUtils.createDirectory(path.join(openspecDir, 'specs')); - await FileSystemUtils.createDirectory(path.join(openspecDir, 'changes', 'archive')); + await FileSystemUtils.createDirectory(path.join(openspecDir, 'archive')); const configPath = path.join(openspecDir, 'config.yaml'); const configYmlPath = path.join(openspecDir, 'config.yml'); if ( diff --git a/src/utils/item-discovery.ts b/src/utils/item-discovery.ts index 1a86c3aed9..64507d4b68 100644 --- a/src/utils/item-discovery.ts +++ b/src/utils/item-discovery.ts @@ -1,66 +1,41 @@ -import { promises as fs } from 'fs'; import path from 'path'; +import { findAllArchivedChangeIds, findAllChangeIds } from './change-path.js'; export async function getActiveChangeIds(root: string = process.cwd()): Promise { - const changesPath = path.join(root, 'openspec', 'changes'); - try { - const entries = await fs.readdir(changesPath, { withFileTypes: true }); - const result: string[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive') continue; - const proposalPath = path.join(changesPath, entry.name, 'proposal.md'); - try { - await fs.access(proposalPath); - result.push(entry.name); - } catch { - // skip directories without proposal.md - } - } - return result.sort(); - } catch { - return []; - } + return findAllChangeIds(path.join(root, 'openspec', 'changes')); } export async function getSpecIds(root: string = process.cwd()): Promise { + const { promises: fs } = await import('fs'); const specsPath = path.join(root, 'openspec', 'specs'); const result: string[] = []; - try { - const entries = await fs.readdir(specsPath, { withFileTypes: true }); + + async function walk(currentDir: string, segments: string[]): Promise { + let entries; + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch { + return; + } + + if (segments.length > 0 && entries.some( + (entry) => entry.isFile() && entry.name === 'spec.md' + )) { + result.push(segments.join('/')); + } + for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const specFile = path.join(specsPath, entry.name, 'spec.md'); - try { - await fs.access(specFile); - result.push(entry.name); - } catch { - // ignore + if (!entry.isDirectory() || entry.name.startsWith('.')) { + continue; } + await walk(path.join(currentDir, entry.name), [...segments, entry.name]); } - } catch { - // ignore } + + await walk(specsPath, []); return result.sort(); } export async function getArchivedChangeIds(root: string = process.cwd()): Promise { - const archivePath = path.join(root, 'openspec', 'changes', 'archive'); - try { - const entries = await fs.readdir(archivePath, { withFileTypes: true }); - const result: string[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const proposalPath = path.join(archivePath, entry.name, 'proposal.md'); - try { - await fs.access(proposalPath); - result.push(entry.name); - } catch { - // skip directories without proposal.md - } - } - return result.sort(); - } catch { - return []; - } + return findAllArchivedChangeIds(path.join(root, 'openspec', 'archive')); } - diff --git a/test/cli-e2e/capstone-journeys.test.ts b/test/cli-e2e/capstone-journeys.test.ts index adc4271a73..3f3ec7696f 100644 --- a/test/cli-e2e/capstone-journeys.test.ts +++ b/test/cli-e2e/capstone-journeys.test.ts @@ -82,7 +82,7 @@ describe('capstone persona journeys (6.1)', () => { // Low-level design lands in the app repo's own root, not the store. const created = await runCLI( - ['new', 'change', 'implement-invoice-immutability', '--json'], + ['new', 'change', 'implement-invoice-immutability', '--domain', '', '--json'], { cwd: appRepo, env } ); expect(created.exitCode).toBe(0); @@ -98,7 +98,9 @@ describe('capstone persona journeys (6.1)', () => { ).toBe(false); // The store stayed read-only context throughout. - const storeChanges = fs.readdirSync(path.join(storeRoot, 'openspec', 'changes')); + const storeChanges = fs.existsSync(path.join(storeRoot, 'openspec', 'changes')) + ? fs.readdirSync(path.join(storeRoot, 'openspec', 'changes')) + : []; expect(storeChanges.filter((name) => name !== 'archive' && name !== '.gitkeep')).toEqual([]); }, JOURNEY_TIMEOUT_MS); @@ -117,7 +119,7 @@ describe('capstone persona journeys (6.1)', () => { // The whole lifecycle from the code repo, zero --store flags. const created = await runCLI( - ['new', 'change', 'add-rate-limits', '--schema', 'spec-driven', '--json'], + ['new', 'change', 'add-rate-limits', '--domain', '', '--schema', 'spec-driven', '--json'], { cwd: codeRepo, env } ); expect(created.exitCode).toBe(0); @@ -171,7 +173,7 @@ describe('capstone persona journeys (6.1)', () => { ); expect(archived.exitCode).toBe(0); expect(fs.existsSync(changeDir)).toBe(false); - const archiveDir = path.join(storeRoot, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(storeRoot, 'openspec', 'archive'); const archivedNames = fs.readdirSync(archiveDir); expect(archivedNames.some((name) => name.endsWith('add-rate-limits'))).toBe(true); diff --git a/test/cli-e2e/store-lifecycle.test.ts b/test/cli-e2e/store-lifecycle.test.ts index 4f0acd99c4..68f37946aa 100644 --- a/test/cli-e2e/store-lifecycle.test.ts +++ b/test/cli-e2e/store-lifecycle.test.ts @@ -212,7 +212,7 @@ describe('standalone store lifecycle journey', () => { expect.arrayContaining([ 'openspec/config.yaml', 'openspec/specs/.gitkeep', - 'openspec/changes/archive/.gitkeep', + 'openspec/archive/.gitkeep', '.openspec-store/store.yaml', ]) ); @@ -229,7 +229,7 @@ describe('standalone store lifecycle journey', () => { ]); expect(committedFiles).toContain('.openspec-store/store.yaml'); expect(committedFiles).toContain('openspec/specs/.gitkeep'); - expect(committedFiles).toContain('openspec/changes/archive/.gitkeep'); + expect(committedFiles).toContain('openspec/archive/.gitkeep'); const status = await git(storeRoot, machineA, ['status', '--porcelain']); expect(status.trim()).toBe(''); @@ -267,7 +267,7 @@ describe('standalone store lifecycle journey', () => { const changeId = 'add-billing'; const created = await runCLI( - ['new', 'change', changeId, '--store', STORE_ID, '--json'], + ['new', 'change', changeId, '--domain', '', '--store', STORE_ID, '--json'], { env: machineA, cwd: projectDir } ); expect(created.exitCode).toBe(0); @@ -336,7 +336,7 @@ describe('standalone store lifecycle journey', () => { await expect(fs.readFile(specPath, 'utf-8')).resolves.toContain('billing SHALL work'); const archiveEntries = await fs.readdir( - path.join(storeRoot, 'openspec', 'changes', 'archive') + path.join(storeRoot, 'openspec', 'archive') ); expect(archiveEntries.some((entry) => entry.endsWith(`-${changeId}`))).toBe(true); }, JOURNEY_TIMEOUT_MS); @@ -399,7 +399,7 @@ describe('standalone store lifecycle journey', () => { const changeId = 'add-invoicing'; const created = await runCLI( - ['new', 'change', changeId, '--store', STORE_ID], + ['new', 'change', changeId, '--domain', '', '--store', STORE_ID], { env: machineB, cwd: base } ); expect(created.exitCode).toBe(0); @@ -451,7 +451,12 @@ describe('standalone store lifecycle journey', () => { ); expect(failedApply.exitCode).not.toBe(0); expect(failedApply.stderr).toContain(`Using OpenSpec root: ${STORE_ID}`); - expect(failedApply.stderr).toContain(`openspec new change --store ${STORE_ID}`); + expect(failedApply.stderr).toContain( + `openspec new change --domain --store ${STORE_ID}` + ); + expect(failedApply.stderr).toContain( + `openspec new change --domain "" --store ${STORE_ID}` + ); }, JOURNEY_TIMEOUT_MS); it('end state is just normal OpenSpec files in both checkouts', async () => { diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index a286422a82..19fbdb7726 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -79,6 +79,7 @@ describe('artifact-workflow CLI commands', () => { // Create empty change directory (no proposal.md) const changeDir = path.join(changesDir, 'scaffolded-change'); await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); const result = await runCLI(['status', '--change', 'scaffolded-change'], { cwd: tempDir }); expect(result.exitCode).toBe(0); @@ -140,6 +141,8 @@ describe('artifact-workflow CLI commands', () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain('No active changes'); expect(result.stdout).toContain('openspec new change'); + expect(result.stdout).toContain('--domain '); + expect(result.stdout).toContain('--domain ""'); }); it('exits gracefully with JSON when no changes exist', async () => { @@ -206,11 +209,17 @@ describe('artifact-workflow CLI commands', () => { expect(output).toContain('Invalid change name'); }); - it('rejects slashes in change name', async () => { - const result = await runCLI(['status', '--change', 'foo/bar'], { cwd: tempDir }); - expect(result.exitCode).toBe(1); - const output = getOutput(result); - expect(output).toContain('Invalid change name'); + it('accepts slash ids for scaffolded nested changes', async () => { + const nestedChangeDir = path.join(changesDir, 'Platform', 'API', 'add-auth'); + await fs.mkdir(nestedChangeDir, { recursive: true }); + await fs.writeFile(path.join(nestedChangeDir, '.openspec.yaml'), 'schema: spec-driven\n'); + + const result = await runCLI(['status', '--change', 'Platform/API/add-auth'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Platform/API/add-auth'); + expect(result.stdout).toContain('0/4 artifacts complete'); }); }); @@ -219,6 +228,7 @@ describe('artifact-workflow CLI commands', () => { // Create empty change directory (no proposal.md) const changeDir = path.join(changesDir, 'scaffolded-change'); await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); const result = await runCLI(['instructions', 'proposal', '--change', 'scaffolded-change'], { cwd: tempDir, @@ -332,7 +342,7 @@ describe('artifact-workflow CLI commands', () => { describe('new change command', () => { it('creates a new change directory', async () => { - const result = await runCLI(['new', 'change', 'my-new-feature'], { cwd: tempDir }); + const result = await runCLI(['new', 'change', 'my-new-feature', '--domain', ''], { cwd: tempDir }); expect(result.exitCode).toBe(0); const output = getOutput(result); expect(output).toContain("Created change 'my-new-feature'"); @@ -344,7 +354,7 @@ describe('artifact-workflow CLI commands', () => { it('rejects --initiative and writes no change', async () => { const result = await runCLI( - ['new', 'change', 'linked-change', '--initiative', 'billing-launch'], + ['new', 'change', 'linked-change', '--domain', '', '--initiative', 'billing-launch'], { cwd: tempDir } ); expect(result.exitCode).toBe(1); @@ -356,7 +366,7 @@ describe('artifact-workflow CLI commands', () => { }); it('rejects --areas and writes no affected-area metadata', async () => { - const result = await runCLI(['new', 'change', 'area-change', '--areas', 'api'], { + const result = await runCLI(['new', 'change', 'area-change', '--domain', '', '--areas', 'api'], { cwd: tempDir, }); expect(result.exitCode).toBe(1); @@ -369,7 +379,7 @@ describe('artifact-workflow CLI commands', () => { it('keeps --goal as ordinary metadata without switching schema', async () => { const result = await runCLI( - ['new', 'change', 'goal-change', '--goal', 'Improve billing'], + ['new', 'change', 'goal-change', '--domain', '', '--goal', 'Improve billing'], { cwd: tempDir } ); expect(result.exitCode).toBe(0); @@ -386,7 +396,7 @@ describe('artifact-workflow CLI commands', () => { it('creates README.md when --description is provided', async () => { const result = await runCLI( - ['new', 'change', 'described-feature', '--description', 'This is a test feature'], + ['new', 'change', 'described-feature', '--domain', '', '--description', 'This is a test feature'], { cwd: tempDir } ); expect(result.exitCode).toBe(0); @@ -398,7 +408,7 @@ describe('artifact-workflow CLI commands', () => { }); it('errors for invalid change name with spaces', async () => { - const result = await runCLI(['new', 'change', 'invalid name'], { cwd: tempDir }); + const result = await runCLI(['new', 'change', 'invalid name', '--domain', ''], { cwd: tempDir }); expect(result.exitCode).toBe(1); const output = getOutput(result); expect(output).toContain('Error'); @@ -407,14 +417,14 @@ describe('artifact-workflow CLI commands', () => { it('errors for duplicate change name', async () => { await createTestChange('existing-change'); - const result = await runCLI(['new', 'change', 'existing-change'], { cwd: tempDir }); + const result = await runCLI(['new', 'change', 'existing-change', '--domain', ''], { cwd: tempDir }); expect(result.exitCode).toBe(1); const output = getOutput(result); expect(output).toContain('exists'); }); it('errors when name argument is missing', async () => { - const result = await runCLI(['new', 'change'], { cwd: tempDir }); + const result = await runCLI(['new', 'change', '--domain', ''], { cwd: tempDir }); expect(result.exitCode).toBe(1); }); }); @@ -614,6 +624,7 @@ artifacts: // Create a change with only the first artifact (missing second) const changeDir = path.join(changesDir, 'no-apply-test'); await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: no-apply\n'); await fs.writeFile(path.join(changeDir, 'first.md'), '# First artifact content'); // Run with XDG_DATA_HOME pointing to our temp user data dir @@ -657,6 +668,7 @@ artifacts: // Create a change with the artifact present const changeDir = path.join(changesDir, 'no-apply-full-test'); await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: no-apply-full\n'); await fs.writeFile(path.join(changeDir, 'only.md'), '# Content'); const result = await runCLI( @@ -791,7 +803,7 @@ artifacts: ); // Create a new change without specifying schema - const result = await runCLI(['new', 'change', 'test-change'], { cwd: tempDir, timeoutMs: 30000 }); + const result = await runCLI(['new', 'change', 'test-change', '--domain', ''], { cwd: tempDir, timeoutMs: 30000 }); expect(result.exitCode).toBe(0); // Verify the change was created with spec-driven schema @@ -810,7 +822,7 @@ artifacts: // Create change with explicit schema const result = await runCLI( - ['new', 'change', 'override-test', '--schema', 'spec-driven'], + ['new', 'change', 'override-test', '--domain', '', '--schema', 'spec-driven'], { cwd: tempDir, timeoutMs: 30000 } ); expect(result.exitCode).toBe(0); diff --git a/test/commands/change-initiative-link.test.ts b/test/commands/change-initiative-link.test.ts index c1a7797b57..3c47856066 100644 --- a/test/commands/change-initiative-link.test.ts +++ b/test/commands/change-initiative-link.test.ts @@ -89,7 +89,7 @@ describe('legacy repo-local change initiative metadata', () => { }); it('creates no initiative metadata for new changes', async () => { - const result = await runCLI(['new', 'change', 'fresh-change', '--json'], { + const result = await runCLI(['new', 'change', 'fresh-change', '--domain', '', '--json'], { cwd: tempDir, env, }); @@ -103,7 +103,7 @@ describe('legacy repo-local change initiative metadata', () => { it('rejects new change --initiative without writing files', async () => { const result = await runCLI( - ['new', 'change', 'linked-change', '--initiative', 'billing-launch', '--json'], + ['new', 'change', 'linked-change', '--domain', '', '--initiative', 'billing-launch', '--json'], { cwd: tempDir, env } ); expect(result.exitCode).toBe(1); diff --git a/test/commands/declared-store-fallback.test.ts b/test/commands/declared-store-fallback.test.ts index 75fca1a8af..9d95955892 100644 --- a/test/commands/declared-store-fallback.test.ts +++ b/test/commands/declared-store-fallback.test.ts @@ -53,7 +53,7 @@ describe('declared store fallback (3.2)', () => { it('runs the externalized-planning journey without --store anywhere', async () => { const pointerBefore = snapshot(pointerRepo); - const created = await runCLI(['new', 'change', 'billing-rework', '--json'], { + const created = await runCLI(['new', 'change', 'billing-rework', '--domain', '', '--json'], { cwd: pointerRepo, env, }); @@ -117,7 +117,7 @@ describe('declared store fallback (3.2)', () => { env, }); expect(archive.exitCode).toBe(0); - const archived = fs.readdirSync(path.join(storeRoot, 'openspec', 'changes', 'archive')); + const archived = fs.readdirSync(path.join(storeRoot, 'openspec', 'archive')); expect(archived.some((name) => name.endsWith('billing-rework'))).toBe(true); // The pointer repo is byte-identical: no specs/, no changes/, nothing. @@ -136,7 +136,7 @@ describe('declared store fallback (3.2)', () => { 'schema: spec-driven\nreferences:\n - upstream-context\n' ); - const created = await runCLI(['new', 'change', 'ref-check', '--json'], { + const created = await runCLI(['new', 'change', 'ref-check', '--domain', '', '--json'], { cwd: pointerRepo, env, }); diff --git a/test/commands/legacy-groups-removed.test.ts b/test/commands/legacy-groups-removed.test.ts index 99c9700abc..f3cbeb17bf 100644 --- a/test/commands/legacy-groups-removed.test.ts +++ b/test/commands/legacy-groups-removed.test.ts @@ -128,7 +128,7 @@ describe('legacy command groups are removed', () => { // behavior cannot silently start writing into this fixture. expect((await runCLI(['update'], { cwd: projectDir, env })).exitCode).toBe(1); expect( - (await runCLI(['new', 'change', 'survival-check', '--store', 'team-context', '--json'], { + (await runCLI(['new', 'change', 'survival-check', '--domain', '', '--store', 'team-context', '--json'], { cwd: projectDir, env, })).exitCode diff --git a/test/commands/show.test.ts b/test/commands/show.test.ts index 67de310c2d..c3fa2ca5eb 100644 --- a/test/commands/show.test.ts +++ b/test/commands/show.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import { execSync } from 'child_process'; +import { runCLI } from '../helpers/run-cli.js'; describe('top-level show command', () => { const projectRoot = process.cwd(); @@ -77,6 +78,32 @@ describe('top-level show command', () => { } }); + it('auto-detects a recursively discovered spec ID', async () => { + const nestedSpecDir = path.join(specsDir, 'auth', 'oauth', 'login'); + await fs.mkdir(nestedSpecDir, { recursive: true }); + await fs.copyFile( + path.join(specsDir, 'auth', 'spec.md'), + path.join(nestedSpecDir, 'spec.md') + ); + + const result = await runCLI(['show', 'auth/oauth/login', '--json'], { cwd: testDir }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).id).toBe('auth/oauth/login'); + }); + + it.each(['change', 'spec'])('rejects traversal IDs with --type %s', async (type) => { + const outsideDir = path.join(testDir, 'outside'); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(path.join(outsideDir, 'proposal.md'), '# OUTSIDE_CHANGE_SENTINEL', 'utf-8'); + await fs.writeFile(path.join(outsideDir, 'spec.md'), 'OUTSIDE_SPEC_SENTINEL', 'utf-8'); + + const result = await runCLI(['show', '../../outside', '--type', type], { cwd: testDir }); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).not.toContain('OUTSIDE_'); + expect(result.stderr).toContain("Unknown item '../../outside'"); + }); + it('handles ambiguity and suggests --type', async () => { // create matching spec and change named 'foo' await fs.mkdir(path.join(changesDir, 'foo'), { recursive: true }); diff --git a/test/commands/spec.test.ts b/test/commands/spec.test.ts index b8f90fabed..f1881c1c4c 100644 --- a/test/commands/spec.test.ts +++ b/test/commands/spec.test.ts @@ -191,6 +191,26 @@ The system SHALL process credit card payments securely`; process.chdir(originalCwd); } }); + + it('should list recursively discovered spec IDs', async () => { + const nestedSpecDir = path.join(specsDir, 'auth', 'oauth', 'login'); + await fs.mkdir(nestedSpecDir, { recursive: true }); + await fs.copyFile( + path.join(specsDir, 'auth', 'spec.md'), + path.join(nestedSpecDir, 'spec.md') + ); + + const originalCwd = process.cwd(); + try { + process.chdir(testDir); + const output = execSync(`node ${openspecBin} spec list --json`, { encoding: 'utf-8' }); + expect(JSON.parse(output).map((spec: { id: string }) => spec.id)).toContain( + 'auth/oauth/login' + ); + } finally { + process.chdir(originalCwd); + } + }); }); describe('spec validate', () => { @@ -321,4 +341,4 @@ This section has no actual requirements`; } }); }); -}); \ No newline at end of file +}); diff --git a/test/commands/store-git.test.ts b/test/commands/store-git.test.ts index 2a9ea8a03c..cc0af1154a 100644 --- a/test/commands/store-git.test.ts +++ b/test/commands/store-git.test.ts @@ -174,7 +174,7 @@ describe('store git lifecycle', () => { .sort(); expect(committedFiles).toEqual([ '.openspec-store/store.yaml', - 'openspec/changes/archive/.gitkeep', + 'openspec/archive/.gitkeep', 'openspec/config.yaml', 'openspec/specs/keep-me.md', ]); @@ -188,7 +188,7 @@ describe('store git lifecycle', () => { for (const required of [ 'openspec/config.yaml', 'openspec/specs/keep-me.md', - 'openspec/changes/archive/.gitkeep', + 'openspec/archive/.gitkeep', '.openspec-store/store.yaml', ]) { expect(fs.existsSync(path.join(cloneRoot, required))).toBe(true); @@ -275,14 +275,14 @@ describe('store git lifecycle', () => { 'openspec/config.yaml', ]); expect(committedFiles).not.toContain('openspec/specs/.gitkeep'); - expect(committedFiles).not.toContain('openspec/changes/archive/.gitkeep'); + expect(committedFiles).not.toContain('openspec/archive/.gitkeep'); execFileSync('git', ['clone', storeRoot, cloneRoot], { env: gitExecEnv, stdio: 'ignore', }); expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'specs'))).toBe(false); - expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'changes', 'archive'))).toBe(false); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'archive'))).toBe(false); const registered = await runCLI(['store', 'register', cloneRoot, '--json'], { cwd: tempDir, @@ -320,7 +320,7 @@ describe('store git lifecycle', () => { .sort(); expect(committedFiles).toEqual([ '.openspec-store/store.yaml', - 'openspec/changes/archive/.gitkeep', + 'openspec/archive/.gitkeep', 'openspec/specs/.gitkeep', ]); diff --git a/test/commands/store-references.test.ts b/test/commands/store-references.test.ts index a93a103b0d..ec76489c9a 100644 --- a/test/commands/store-references.test.ts +++ b/test/commands/store-references.test.ts @@ -47,7 +47,10 @@ describe('store references in instructions (3.1)', () => { } async function createChange(cwd: string, name: string, extraArgs: string[] = []) { - const result = await runCLI(['new', 'change', name, '--json', ...extraArgs], { cwd, env }); + const result = await runCLI( + ['new', 'change', name, '--domain', '', '--json', ...extraArgs], + { cwd, env } + ); expect(result.exitCode).toBe(0); } diff --git a/test/commands/store-remote.test.ts b/test/commands/store-remote.test.ts index b51282c5a4..09c43bbbc2 100644 --- a/test/commands/store-remote.test.ts +++ b/test/commands/store-remote.test.ts @@ -355,7 +355,7 @@ describe('store canonical remote (3.3)', () => { // Anchor every directory a healthy clone needs (the same job // store setup's anchor files do). fs.writeFileSync(path.join(originWorktree, 'openspec', 'specs', '.gitkeep'), ''); - fs.writeFileSync(path.join(originWorktree, 'openspec', 'changes', 'archive', '.gitkeep'), ''); + fs.writeFileSync(path.join(originWorktree, 'openspec', 'archive', '.gitkeep'), ''); fs.mkdirSync(path.join(originWorktree, '.openspec-store'), { recursive: true }); fs.writeFileSync( path.join(originWorktree, '.openspec-store', 'store.yaml'), @@ -377,9 +377,9 @@ describe('store canonical remote (3.3)', () => { ` - { id: team-context, remote: ${originRemote} }\n` ); fs.mkdirSync(path.join(appRepo, 'openspec', 'specs'), { recursive: true }); - fs.mkdirSync(path.join(appRepo, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.mkdirSync(path.join(appRepo, 'openspec', 'archive'), { recursive: true }); - const created = await runCLI(['new', 'change', 'onboard-check', '--json'], { + const created = await runCLI(['new', 'change', 'onboard-check', '--domain', '', '--json'], { cwd: appRepo, env: e2eEnv, }); @@ -432,7 +432,7 @@ describe('store canonical remote (3.3)', () => { // Keep specs/ and archive/ tracked so the pre-existing // fragile-directories warning stays out of this assertion. fs.writeFileSync(path.join(storeRoot, 'openspec', 'specs', '.gitkeep'), ''); - fs.writeFileSync(path.join(storeRoot, 'openspec', 'changes', 'archive', '.gitkeep'), ''); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'archive', '.gitkeep'), ''); fs.mkdirSync(path.join(storeRoot, '.openspec-store'), { recursive: true }); fs.writeFileSync( path.join(storeRoot, '.openspec-store', 'store.yaml'), diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index e50147e0d9..2a3bc71a6a 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -46,6 +46,11 @@ The system SHALL create bills differently. - **THEN** a bill is created `; +function createDirectoryLink(target: string, linkPath: string): void { + fs.mkdirSync(path.dirname(linkPath), { recursive: true }); + fs.symlinkSync(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir'); +} + describe('store root selection for normal commands', () => { let tempDir: string; let appRepo: string; @@ -75,7 +80,7 @@ describe('store root selection for normal commands', () => { function createOpenSpecRoot(rootDir: string): void { fs.mkdirSync(path.join(rootDir, 'openspec', 'specs'), { recursive: true }); - fs.mkdirSync(path.join(rootDir, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'openspec', 'archive'), { recursive: true }); fs.writeFileSync(path.join(rootDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); } @@ -125,7 +130,7 @@ describe('store root selection for normal commands', () => { describe('selecting a registered store by id', () => { it('creates a change only in the store and names the root on stderr', async () => { - const result = await runCLI(['new', 'change', 'add-billing', '--store', 'team-context'], { + const result = await runCLI(['new', 'change', 'add-billing', '--domain', '', '--store', 'team-context'], { cwd: appRepo, env, }); @@ -144,7 +149,7 @@ describe('store root selection for normal commands', () => { it('includes the shared root block and absolute paths in new change JSON', async () => { const result = await runCLI( - ['new', 'change', 'add-billing', '--store', 'team-context', '--json'], + ['new', 'change', 'add-billing', '--domain', '', '--store', 'team-context', '--json'], { cwd: appRepo, env } ); expect(result.exitCode).toBe(0); @@ -261,6 +266,22 @@ describe('store root selection for normal commands', () => { expectNoLocalOpenSpec(); }); + it.each(['show', 'validate'])('%s rejects traversal outside the selected store', async (command) => { + const outsideDir = path.join(storeRoot, 'outside'); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.writeFileSync(path.join(outsideDir, 'proposal.md'), '# STORE_OUTSIDE_SENTINEL'); + + const result = await runCLI( + [command, '../../outside', '--type', 'change', '--store', 'team-context'], + { cwd: appRepo, env } + ); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).not.toContain('STORE_OUTSIDE_SENTINEL'); + expect(result.stderr).toContain("Unknown item '../../outside'"); + expectNoLocalOpenSpec(); + }); + it('lists specs from the store with minimal JSON support', async () => { const specDir = path.join(storeRoot, 'openspec', 'specs', 'billing'); fs.mkdirSync(specDir, { recursive: true }); @@ -306,7 +327,7 @@ describe('store root selection for normal commands', () => { expect(json.archive.change).toBe('store-change'); expect(json.archive.archivedAs).toMatch(/^\d{4}-\d{2}-\d{2}-store-change$/); expect(json.archive.path).toBe( - path.join(storeRoot, 'openspec', 'changes', 'archive', json.archive.archivedAs) + path.join(storeRoot, 'openspec', 'archive', json.archive.archivedAs) ); expect(json.archive.specsUpdated).toBe(true); expect(json.root.store_id).toBe('team-context'); @@ -320,6 +341,51 @@ describe('store root selection for normal commands', () => { ).toBe(true); expectNoLocalOpenSpec(); }); + + it('preserves nested change domains in the selected store archive and specs', async () => { + const changeId = 'auth/oauth/store-change'; + createChange(storeRoot, changeId); + + const result = await runCLI( + ['archive', changeId, '--store', 'team-context', '--json', '--yes'], + { cwd: appRepo, env } + ); + + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.archive.change).toBe(changeId); + expect(json.archive.archivedAs).toMatch(/^auth\/oauth\/\d{4}-\d{2}-\d{2}-store-change$/); + expect(json.archive.path).toBe( + path.join(storeRoot, 'openspec', 'archive', ...json.archive.archivedAs.split('/')) + ); + expect(fs.existsSync(json.archive.path)).toBe(true); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'specs', 'auth', 'oauth', 'billing', 'spec.md')) + ).toBe(true); + + const specs = await runCLI( + ['list', '--specs', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(parseJson(specs).specs.map((spec: { id: string }) => spec.id)).toContain( + 'auth/oauth/billing' + ); + + const shown = await runCLI( + ['show', 'auth/oauth/billing', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(shown.exitCode).toBe(0); + expect(parseJson(shown).id).toBe('auth/oauth/billing'); + + const validated = await runCLI( + ['validate', 'auth/oauth/billing', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(validated.exitCode).toBe(0); + expect(parseJson(validated).items[0].id).toBe('auth/oauth/billing'); + expectNoLocalOpenSpec(); + }); }); describe('human output and stdout purity', () => { @@ -363,7 +429,7 @@ describe('store root selection for normal commands', () => { describe('selector errors', () => { it('rejects --store-path with register guidance', async () => { - const result = await runCLI(['new', 'change', 'nope', '--store-path', '/x'], { + const result = await runCLI(['new', 'change', 'nope', '--domain', '', '--store-path', '/x'], { cwd: appRepo, env, }); @@ -453,7 +519,7 @@ describe('store root selection for normal commands', () => { describe('default resolution without --store', () => { it('fails with a store hint instead of scaffolding when no root exists', async () => { - const result = await runCLI(['new', 'change', 'foo'], { cwd: appRepo, env }); + const result = await runCLI(['new', 'change', 'foo', '--domain', ''], { cwd: appRepo, env }); expect(result.exitCode).toBe(1); const output = result.stdout + result.stderr; expect(output).toContain('team-context'); @@ -523,6 +589,57 @@ describe('store root selection for normal commands', () => { }); describe('archive --json is non-interactive', () => { + it('rejects an archive destination junction outside the selected Store root', async () => { + const changeId = 'auth/store-change'; + const changeDir = createChange(storeRoot, changeId, { deltaSpec: null }); + const outsideArchive = path.join(tempDir, 'outside-store-archive'); + fs.mkdirSync(outsideArchive, { recursive: true }); + createDirectoryLink( + outsideArchive, + path.join(storeRoot, 'openspec', 'archive', 'auth') + ); + + const result = await runCLI( + ['archive', changeId, '--store', 'team-context', '--json', '--yes', '--skip-specs'], + { cwd: appRepo, env } + ); + + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0]).toEqual(expect.objectContaining({ + code: 'archive_error', + message: expect.stringMatching(/archive destination.*selected root/i), + })); + expect(fs.existsSync(path.join(changeDir, 'proposal.md'))).toBe(true); + expect(fs.readdirSync(outsideArchive)).toEqual([]); + }); + + it('rejects a spec destination junction outside the selected Store root', async () => { + const changeDir = createChange(storeRoot, 'store-change'); + const outsideSpecs = path.join(tempDir, 'outside-store-specs'); + fs.mkdirSync(outsideSpecs, { recursive: true }); + createDirectoryLink( + outsideSpecs, + path.join(storeRoot, 'openspec', 'specs', 'billing') + ); + + const result = await runCLI( + ['archive', 'store-change', '--store', 'team-context', '--json', '--yes', '--no-validate'], + { cwd: appRepo, env } + ); + + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0]).toEqual(expect.objectContaining({ + code: 'archive_error', + message: expect.stringMatching(/spec destination.*selected root/i), + })); + expect(fs.existsSync(path.join(changeDir, 'proposal.md'))).toBe(true); + expect(fs.readdirSync(outsideSpecs)).toEqual([]); + }); + it('fails without a change name instead of opening a picker', async () => { createChange(storeRoot, 'store-change'); @@ -680,7 +797,7 @@ describe('store root selection for normal commands', () => { createOpenSpecRoot(localRepo); const result = await runCLI( - ['new', 'change', 'linked-change', '--initiative', 'billing-launch'], + ['new', 'change', 'linked-change', '--domain', '', '--initiative', 'billing-launch'], { cwd: localRepo, env } ); expect(result.exitCode).toBe(1); @@ -724,7 +841,8 @@ describe('store root selection for normal commands', () => { { cwd: appRepo, env } ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('openspec new change --store fresh-context'); + expect(result.stdout).toContain('openspec new change --domain --store fresh-context'); + expect(result.stdout).toContain('openspec new change --domain "" --store fresh-context'); }); it('shows --store usage after register', async () => { @@ -740,7 +858,8 @@ describe('store root selection for normal commands', () => { env, }); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('openspec new change --store register-context'); + expect(result.stdout).toContain('openspec new change --domain --store register-context'); + expect(result.stdout).toContain('openspec new change --domain "" --store register-context'); }); }); }); diff --git a/test/commands/store.test.ts b/test/commands/store.test.ts index 41a17a6377..58abd240e9 100644 --- a/test/commands/store.test.ts +++ b/test/commands/store.test.ts @@ -100,7 +100,7 @@ describe('store command', () => { expect(fs.existsSync(path.join(root, 'openspec', 'config.yaml')) || fs.existsSync(path.join(root, 'openspec', 'config.yml'))).toBe(true); expect(fs.existsSync(path.join(root, 'openspec', 'specs'))).toBe(true); expect(fs.existsSync(path.join(root, 'openspec', 'changes'))).toBe(true); - expect(fs.existsSync(path.join(root, 'openspec', 'changes', 'archive'))).toBe(true); + expect(fs.existsSync(path.join(root, 'openspec', 'archive'))).toBe(true); } function expectNoGeneratedAgentOrBetaArtifacts(root: string): void { @@ -156,10 +156,10 @@ describe('store command', () => { 'openspec/', 'openspec/specs/', 'openspec/changes/', - 'openspec/changes/archive/', + 'openspec/archive/', 'openspec/config.yaml', 'openspec/specs/.gitkeep', - 'openspec/changes/archive/.gitkeep', + 'openspec/archive/.gitkeep', '.openspec-store/store.yaml', ]); expect(payload.status).toEqual([]); @@ -290,10 +290,10 @@ describe('store command', () => { 'openspec/', 'openspec/specs/', 'openspec/changes/', - 'openspec/changes/archive/', + 'openspec/archive/', 'openspec/config.yaml', 'openspec/specs/.gitkeep', - 'openspec/changes/archive/.gitkeep', + 'openspec/archive/.gitkeep', '.openspec-store/store.yaml', ]); expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(true); @@ -315,7 +315,8 @@ describe('store command', () => { // First-time accept of an existing root anchors its empty directories // (specs/ has user content here, so only archive/ gets an anchor). expect(payload.created_files).toEqual([ - 'openspec/changes/archive/.gitkeep', + 'openspec/changes/', + 'openspec/archive/.gitkeep', '.openspec-store/store.yaml', ]); expect(fs.existsSync(path.join(storeRoot, 'openspec', 'config.yaml'))).toBe(false); @@ -579,7 +580,7 @@ describe('store command', () => { expect(payload.created_files).toEqual([]); expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes'))).toBe(false); expect(fs.existsSync(path.join(storeRoot, 'openspec', 'specs'))).toBe(false); - expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive'))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'archive'))).toBe(false); }); it('registers a store with active changes before specs or archive exist', async () => { @@ -605,7 +606,7 @@ describe('store command', () => { expect(payload.store.id).toBe('team-context'); expect(payload.created_files).toEqual([]); expect(fs.existsSync(path.join(storeRoot, 'openspec', 'specs'))).toBe(false); - expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive'))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'archive'))).toBe(false); }); it('requires confirmation before registering a healthy root without identity', async () => { @@ -1072,7 +1073,7 @@ describe('store command', () => { const storeRoot = mkdir('team-context'); fs.mkdirSync(path.join(storeRoot, 'openspec', 'specs'), { recursive: true }); fs.mkdirSync(path.join(storeRoot, 'openspec', 'changes'), { recursive: true }); - fs.writeFileSync(path.join(storeRoot, 'openspec', 'changes', 'archive'), 'not a dir\n'); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'archive'), 'not a dir\n'); fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); await writeStoreRegistryState( @@ -1103,7 +1104,7 @@ describe('store command', () => { code: 'openspec_archive_not_directory', }) ); - expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'changes', 'archive'), 'utf-8')).toBe('not a dir\n'); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'archive'), 'utf-8')).toBe('not a dir\n'); }); it('register errors are terminal: one-checkout rule, no circular fix texts', async () => { @@ -1243,7 +1244,12 @@ describe('store command', () => { expect(result.stderr).toContain( 'setup, register, unregister, remove, list (ls), doctor' ); - expect(result.stderr).toContain('openspec new change billing-rework --store '); + expect(result.stderr).toContain( + 'openspec new change billing-rework --domain --store ' + ); + expect(result.stderr).toContain( + 'openspec new change billing-rework --domain "" --store ' + ); }); it('never suggests an invalid command for partial new invocations', async () => { @@ -1251,7 +1257,12 @@ describe('store command', () => { expect(result.exitCode).toBe(1); // 'new my-change' would be invalid; the hint falls back to the full form. - expect(result.stderr).toContain('openspec new change --store '); + expect(result.stderr).toContain( + 'openspec new change --domain --store ' + ); + expect(result.stderr).toContain( + 'openspec new change --domain "" --store ' + ); expect(result.stderr).not.toContain('openspec new my-change'); }); @@ -1262,7 +1273,12 @@ describe('store command', () => { ); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('openspec new change --store '); + expect(result.stderr).toContain( + 'openspec new change --domain --store ' + ); + expect(result.stderr).toContain( + 'openspec new change --domain "" --store ' + ); expect(result.stderr).not.toContain('core'); }); diff --git a/test/commands/validate.test.ts b/test/commands/validate.test.ts index 65e9ce80e2..73ef7e553e 100644 --- a/test/commands/validate.test.ts +++ b/test/commands/validate.test.ts @@ -89,12 +89,42 @@ describe('top-level validate command', () => { expect(json.items.every((i: any) => i.type === 'spec')).toBe(true); }); + it('auto-detects and bulk-validates recursively discovered spec IDs', async () => { + const nestedSpecDir = path.join(specsDir, 'auth', 'oauth', 'login'); + await fs.mkdir(nestedSpecDir, { recursive: true }); + await fs.copyFile( + path.join(specsDir, 'alpha', 'spec.md'), + path.join(nestedSpecDir, 'spec.md') + ); + + const direct = await runCLI(['validate', 'auth/oauth/login', '--json'], { cwd: testDir }); + expect(direct.exitCode).toBe(0); + expect(JSON.parse(direct.stdout).items[0].id).toBe('auth/oauth/login'); + + const bulk = await runCLI(['validate', '--specs', '--json'], { cwd: testDir }); + expect(JSON.parse(bulk.stdout).items.map((item: { id: string }) => item.id)).toContain( + 'auth/oauth/login' + ); + }); + it('errors on ambiguous item names and suggests type override', async () => { const result = await runCLI(['validate', 'dup'], { cwd: testDir }); expect(result.exitCode).toBe(1); expect(result.stderr).toContain('Ambiguous item'); }); + it.each(['change', 'spec'])('rejects traversal IDs with --type %s', async (type) => { + const outsideDir = path.join(testDir, 'outside'); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(path.join(outsideDir, 'proposal.md'), '# Outside', 'utf-8'); + await fs.writeFile(path.join(outsideDir, 'spec.md'), '## Purpose\nOutside', 'utf-8'); + + const result = await runCLI(['validate', '../../outside', '--type', type], { cwd: testDir }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Unknown item '../../outside'"); + }); + it('accepts change proposals saved with CRLF line endings', async () => { const changeId = 'crlf-change'; const toCrlf = (segments: string[]) => segments.join('\n').replace(/\n/g, '\r\n'); diff --git a/test/commands/workflow/new-change-domain.test.ts b/test/commands/workflow/new-change-domain.test.ts new file mode 100644 index 0000000000..aeaaac55ab --- /dev/null +++ b/test/commands/workflow/new-change-domain.test.ts @@ -0,0 +1,404 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir, registerStore } from '../../../src/core/index.js'; +import { newChangeCommand } from '../../../src/commands/workflow/new-change.js'; +import { createOpenSpecRoot } from '../../helpers/openspec-fixtures.js'; +import { runCLI, type RunCLIResult } from '../../helpers/run-cli.js'; + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + select: vi.fn(), +})); + +function parseJson(result: RunCLIResult): any { + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error( + `Could not parse JSON.\nCommand: ${result.command}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\n${String(error)}` + ); + } +} + +function writeChange(root: string, changeId: string): void { + const changeDir = path.join(root, 'openspec', 'changes', ...changeId.split('/')); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); +} + +describe.sequential('domain-aware new change', () => { + let tempDir: string; + let localRoot: string; + let env: NodeJS.ProcessEnv; + let originalCwd: string; + let originalEnv: NodeJS.ProcessEnv; + let originalStdinTTY: boolean | undefined; + let originalExitCode: string | number | undefined; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-new-change-domain-')) + ); + localRoot = path.join(tempDir, 'local'); + createOpenSpecRoot(localRoot); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + + originalCwd = process.cwd(); + originalEnv = { ...process.env }; + originalStdinTTY = (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY; + originalExitCode = process.exitCode; + process.exitCode = undefined; + + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.chdir(originalCwd); + process.env = originalEnv; + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = originalStdinTTY; + process.exitCode = originalExitCode; + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + vi.clearAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('creates a root-level local change only with an explicit empty domain', async () => { + const result = await runCLI( + ['new', 'change', 'root-change', '--domain', '', '--json'], + { cwd: localRoot, env } + ); + + expect(result.exitCode).toBe(0); + expect(parseJson(result)).toEqual({ + change: { + id: 'root-change', + path: path.join(localRoot, 'openspec', 'changes', 'root-change'), + metadataPath: path.join( + localRoot, + 'openspec', + 'changes', + 'root-change', + '.openspec.yaml' + ), + schema: 'spec-driven', + }, + root: { + path: localRoot, + source: 'nearest', + }, + }); + }); + + it('preserves lenient casing in a single domain', async () => { + const result = await runCLI( + ['new', 'change', 'add-auth', '--domain', 'Platform', '--json'], + { cwd: localRoot, env } + ); + + expect(result.exitCode).toBe(0); + expect(parseJson(result).change.id).toBe('Platform/add-auth'); + expect( + fs.existsSync(path.join(localRoot, 'openspec', 'changes', 'Platform', 'add-auth')) + ).toBe(true); + }); + + it('creates a change in a multi-level local domain', async () => { + const result = await runCLI( + ['new', 'change', 'add-auth', '--domain', 'Platform/API.v2', '--json'], + { cwd: localRoot, env } + ); + + expect(result.exitCode).toBe(0); + expect(parseJson(result).change.id).toBe('Platform/API.v2/add-auth'); + expect( + fs.existsSync( + path.join(localRoot, 'openspec', 'changes', 'Platform', 'API.v2', 'add-auth') + ) + ).toBe(true); + }); + + it('preserves the Store root JSON contract and writes the nested path there', async () => { + const storeRoot = path.join(tempDir, 'team-store'); + createOpenSpecRoot(storeRoot); + await registerStore({ + id: 'team-context', + localPath: storeRoot, + globalDataDir: getGlobalDataDir({ env }), + }); + const canonicalStoreRoot = fs.realpathSync.native(storeRoot); + + const result = await runCLI( + [ + 'new', + 'change', + 'add-billing', + '--domain', + 'Finance/Billing', + '--store', + 'team-context', + '--json', + ], + { cwd: localRoot, env } + ); + + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.root).toEqual({ + path: canonicalStoreRoot, + source: 'store', + store_id: 'team-context', + }); + expect(json.change).toMatchObject({ + id: 'Finance/Billing/add-billing', + path: path.join( + canonicalStoreRoot, + 'openspec', + 'changes', + 'Finance', + 'Billing', + 'add-billing' + ), + }); + expect(fs.existsSync(path.join(localRoot, 'openspec', 'changes'))).toBe(false); + }); + + it('rejects invalid domain paths and strict-name violations independently', async () => { + const invalidDomain = await runCLI( + ['new', 'change', 'add-auth', '--domain', 'Platform/../API', '--json'], + { cwd: localRoot, env } + ); + const invalidName = await runCLI( + ['new', 'change', 'Add-Auth', '--domain', '', '--json'], + { cwd: localRoot, env } + ); + + expect(invalidDomain.exitCode).toBe(1); + expect(parseJson(invalidDomain)).toMatchObject({ + change: null, + status: [{ severity: 'error', message: expect.stringContaining('Domain path') }], + }); + expect(invalidName.exitCode).toBe(1); + expect(parseJson(invalidName)).toMatchObject({ + change: null, + status: [{ severity: 'error', message: expect.stringContaining('lowercase') }], + }); + }); + + it('rejects the reserved archive domain without creating an undiscoverable change', async () => { + const result = await runCLI( + ['new', 'change', 'add-auth', '--domain', 'archive', '--json'], + { cwd: localRoot, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result)).toMatchObject({ + change: null, + status: [ + { + severity: 'error', + message: expect.stringMatching(/root segment 'archive' is reserved/i), + }, + ], + }); + expect(fs.existsSync(path.join(localRoot, 'openspec', 'changes', 'archive'))).toBe(false); + }); + + it('rejects a domain prefix that is already a logical change leaf', async () => { + writeChange(localRoot, 'Platform/existing-change'); + + const result = await runCLI( + [ + 'new', + 'change', + 'add-auth', + '--domain', + 'Platform/existing-change', + '--json', + ], + { cwd: localRoot, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result)).toMatchObject({ + change: null, + status: [ + { + severity: 'error', + message: "Domain prefix 'Platform/existing-change' is already a change", + }, + ], + }); + expect( + fs.existsSync( + path.join( + localRoot, + 'openspec', + 'changes', + 'Platform', + 'existing-change', + 'add-auth' + ) + ) + ).toBe(false); + }); + + it('requires a domain decision in JSON mode and lists domains from the selected root', async () => { + writeChange(localRoot, 'Platform/API/existing-change'); + writeChange(localRoot, 'Finance/existing-change'); + + const result = await runCLI(['new', 'change', 'add-auth', '--json'], { + cwd: localRoot, + env, + }); + + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.change).toBeNull(); + expect(json.status).toHaveLength(1); + expect(json.status[0]).toMatchObject({ + severity: 'error', + code: 'domain_required', + target: 'change.domain', + }); + expect(json.status[0].message).toContain('--domain '); + expect(json.status[0].message).toContain('--domain ""'); + expect(json.status[0].message).toMatch(/agents? must ask the user/i); + expect(json.status[0].message).toContain('Platform'); + expect(json.status[0].message).toContain('Platform/API'); + expect(json.status[0].message).toContain('Finance'); + expect(json.status[0].message).toMatch(/case-insensitive file systems/i); + }); + + it('requires a domain decision without a TTY and prints actionable guidance', async () => { + const result = await runCLI(['new', 'change', 'add-auth'], { + cwd: localRoot, + env, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--domain '); + expect(result.stderr).toContain('--domain ""'); + expect(result.stderr).toMatch(/agents? must ask the user/i); + expect(fs.existsSync(path.join(localRoot, 'openspec', 'changes', 'add-auth'))).toBe(false); + }); + + it('uses existing domains from a selected Store in mandatory JSON guidance', async () => { + writeChange(localRoot, 'Local/existing-change'); + const storeRoot = path.join(tempDir, 'selected-store'); + createOpenSpecRoot(storeRoot); + writeChange(storeRoot, 'StoreDomain/API/existing-change'); + await registerStore({ + id: 'selected-context', + localPath: storeRoot, + globalDataDir: getGlobalDataDir({ env }), + }); + + const result = await runCLI( + ['new', 'change', 'add-auth', '--store', 'selected-context', '--json'], + { cwd: localRoot, env } + ); + + expect(result.exitCode).toBe(1); + const message = parseJson(result).status[0].message; + expect(message).toContain('StoreDomain'); + expect(message).toContain('StoreDomain/API'); + expect(message).not.toContain('Local'); + }); + + it('prompts through existing domains level by level and supports create-here', async () => { + writeChange(localRoot, 'Platform/API/existing-change'); + process.chdir(localRoot); + process.env.OPEN_SPEC_INTERACTIVE = '1'; + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + const prompts = await import('@inquirer/prompts'); + const select = vi.mocked(prompts.select); + const selections = ['Platform', 'API', 'Create here']; + select.mockImplementation(async (config: any) => { + const expectedName = selections.shift(); + const choice = config.choices.find((candidate: any) => + candidate.name.startsWith(expectedName) + ); + return choice.value; + }); + + await newChangeCommand('add-auth', {}); + + expect(select).toHaveBeenCalledTimes(3); + expect( + fs.existsSync(path.join(localRoot, 'openspec', 'changes', 'Platform', 'API', 'add-auth')) + ).toBe(true); + }); + + it('supports create-here at the root in an interactive TTY', async () => { + process.chdir(localRoot); + process.env.OPEN_SPEC_INTERACTIVE = '1'; + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + const prompts = await import('@inquirer/prompts'); + vi.mocked(prompts.select).mockImplementation(async (config: any) => + config.choices.find((candidate: any) => candidate.name.startsWith('Create here')).value + ); + + await newChangeCommand('root-change', {}); + + expect(prompts.select).toHaveBeenCalledTimes(1); + expect( + fs.existsSync(path.join(localRoot, 'openspec', 'changes', 'root-change')) + ).toBe(true); + }); + + it('re-prompts an invalid new subdomain name before creating it', async () => { + process.chdir(localRoot); + process.env.OPEN_SPEC_INTERACTIVE = '1'; + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + const prompts = await import('@inquirer/prompts'); + vi.mocked(prompts.select).mockImplementation(async (config: any) => + config.choices.find((candidate: any) => candidate.name.startsWith('New subdomain')).value + ); + vi.mocked(prompts.input) + .mockResolvedValueOnce('bad/name') + .mockResolvedValueOnce('New.Team'); + + await newChangeCommand('add-auth', {}); + + expect(prompts.input).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('single path segment') + ); + expect( + fs.existsSync(path.join(localRoot, 'openspec', 'changes', 'New.Team', 'add-auth')) + ).toBe(true); + }); + + it('re-prompts domain literals with leading or trailing whitespace', async () => { + process.chdir(localRoot); + process.env.OPEN_SPEC_INTERACTIVE = '1'; + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + const prompts = await import('@inquirer/prompts'); + vi.mocked(prompts.select).mockImplementation(async (config: any) => + config.choices.find((candidate: any) => candidate.name.startsWith('New subdomain')).value + ); + vi.mocked(prompts.input) + .mockResolvedValueOnce(' New.Team ') + .mockResolvedValueOnce('New.Team'); + + await newChangeCommand('add-auth', {}); + + expect(prompts.input).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('leading or trailing whitespace')); + expect( + fs.existsSync(path.join(localRoot, 'openspec', 'changes', 'New.Team', 'add-auth')) + ).toBe(true); + }); +}); diff --git a/test/commands/workflow/shared.test.ts b/test/commands/workflow/shared.test.ts new file mode 100644 index 0000000000..17a8a85141 --- /dev/null +++ b/test/commands/workflow/shared.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import { getAvailableChanges, validateChangeExists } from '../../../src/commands/workflow/shared.js'; + +async function writeMarker( + root: string, + relativeDir: string, + marker: '.openspec.yaml' | 'proposal.md' +): Promise { + const fullDir = path.join(root, ...relativeDir.split('/')); + await fs.mkdir(fullDir, { recursive: true }); + await fs.writeFile( + path.join(fullDir, marker), + marker === 'proposal.md' ? '# Proposal\n' : 'schema: spec-driven\n', + 'utf-8' + ); +} + +describe('workflow shared change discovery', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-workflow-shared-${randomUUID()}`); + await fs.mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('returns scaffolded and proposal-only slash ids from the supplied changesDir', async () => { + const changesDir = path.join(tempDir, 'custom-changes'); + + await writeMarker(changesDir, 'Platform/API/add-auth', '.openspec.yaml'); + await writeMarker(changesDir, 'legacy/add-logs', 'proposal.md'); + await writeMarker(changesDir, 'archive/legacy-only', 'proposal.md'); + + await expect(getAvailableChanges(tempDir, changesDir)).resolves.toEqual([ + 'Platform/API/add-auth', + 'legacy/add-logs', + ]); + }); + + it('accepts slash ids whose domain passes domain-path validation and whose leaf is kebab-case', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + await writeMarker(changesDir, 'Platform/API/add-auth', '.openspec.yaml'); + await writeMarker(changesDir, 'legacy/add-logs', 'proposal.md'); + + await expect(validateChangeExists('Platform/API/add-auth', tempDir, changesDir)).resolves.toBe( + 'Platform/API/add-auth' + ); + await expect(validateChangeExists('legacy/add-logs', tempDir, changesDir)).resolves.toBe( + 'legacy/add-logs' + ); + }); + + it('rejects invalid domain traversal segments', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + + await expect( + validateChangeExists('Platform/../add-auth', tempDir, changesDir) + ).rejects.toThrow( + "Invalid change name 'Platform/../add-auth': Domain path cannot contain \".\" or \"..\" segments" + ); + }); + + it('rejects non-kebab-case leaf segments even when the domain is valid', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + + await expect( + validateChangeExists('Platform/API/Add-Auth', tempDir, changesDir) + ).rejects.toThrow( + "Invalid change name 'Platform/API/Add-Auth': Change name must be lowercase (use kebab-case)" + ); + }); +}); diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 0d039f2026..be56b3e378 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -11,6 +11,10 @@ vi.mock('@inquirer/prompts', () => ({ confirm: vi.fn() })); +async function createDirectoryLink(target: string, linkPath: string): Promise { + await fs.symlink(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir'); +} + describe('ArchiveCommand', () => { let tempDir: string; let archiveCommand: ArchiveCommand; @@ -34,7 +38,7 @@ describe('ArchiveCommand', () => { const openspecDir = path.join(tempDir, 'openspec'); await fs.mkdir(path.join(openspecDir, 'changes'), { recursive: true }); await fs.mkdir(path.join(openspecDir, 'specs'), { recursive: true }); - await fs.mkdir(path.join(openspecDir, 'changes', 'archive'), { recursive: true }); + await fs.mkdir(path.join(openspecDir, 'archive'), { recursive: true }); // Suppress console.log during tests console.log = vi.fn(); @@ -85,7 +89,7 @@ describe('ArchiveCommand', () => { await archiveCommand.execute(changeName, { yes: true }); // Check that change was moved to archive - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.length).toBe(1); @@ -95,6 +99,131 @@ describe('ArchiveCommand', () => { await expect(fs.access(changeDir)).rejects.toThrow(); }); + it('preserves a one-level domain when archiving', async () => { + const changeId = 'auth/add-login'; + const changeDir = path.join(tempDir, 'openspec', 'changes', 'auth', 'add-login'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Add login'); + + await archiveCommand.execute(changeId, { yes: true, noValidate: true }); + + const date = new Date().toISOString().split('T')[0]; + const archivePath = path.join(tempDir, 'openspec', 'archive', 'auth', `${date}-add-login`); + await expect(fs.access(path.join(archivePath, 'proposal.md'))).resolves.not.toThrow(); + await expect(fs.access(changeDir)).rejects.toThrow(); + }); + + it('preserves a multi-level domain and reports an unambiguous archive ID in JSON', async () => { + const changeId = 'auth/oauth/add-login'; + const changeDir = path.join(tempDir, 'openspec', 'changes', 'auth', 'oauth', 'add-login'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Add OAuth login'); + + await archiveCommand.execute(changeId, { yes: true, noValidate: true, json: true }); + + const date = new Date().toISOString().split('T')[0]; + const archivedAs = `auth/oauth/${date}-add-login`; + const archivePath = path.join(process.cwd(), 'openspec', 'archive', 'auth', 'oauth', `${date}-add-login`); + const output = JSON.parse((console.log as ReturnType).mock.calls[0][0]); + expect(output.archive).toMatchObject({ change: changeId, archivedAs }); + expect(path.isAbsolute(output.archive.path)).toBe(true); + expect(await fs.realpath(output.archive.path)).toBe(await fs.realpath(archivePath)); + await expect(fs.access(path.join(output.archive.path, 'proposal.md'))).resolves.not.toThrow(); + }); + + it('rejects an archive destination that escapes through a symlink or junction prefix', async () => { + const changeId = 'auth/add-login'; + const changeDir = path.join(tempDir, 'openspec', 'changes', 'auth', 'add-login'); + const outsideArchive = path.join(tempDir, 'outside-archive'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Add login'); + await fs.mkdir(outsideArchive, { recursive: true }); + await createDirectoryLink( + outsideArchive, + path.join(tempDir, 'openspec', 'archive', 'auth') + ); + + await expect( + archiveCommand.execute(changeId, { yes: true, noValidate: true, skipSpecs: true }) + ).rejects.toThrow(/archive destination.*selected root/i); + + await expect(fs.access(path.join(changeDir, 'proposal.md'))).resolves.not.toThrow(); + await expect(fs.readdir(outsideArchive)).resolves.toEqual([]); + }); + + it('rejects a non-directory archive parent before writing specs', async () => { + const changeId = 'auth/add-login'; + const changeDir = path.join(tempDir, 'openspec', 'changes', 'auth', 'add-login'); + const deltaDir = path.join(changeDir, 'specs', 'login'); + const targetSpec = path.join(tempDir, 'openspec', 'specs', 'auth', 'login', 'spec.md'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Add login'); + await fs.writeFile(path.join(deltaDir, 'spec.md'), `## ADDED Requirements + +### Requirement: Login +The system SHALL support login. + +#### Scenario: Login works +- **WHEN** login runs +- **THEN** access is granted +`); + await fs.writeFile(path.join(tempDir, 'openspec', 'archive', 'auth'), 'not a directory'); + + await expect( + archiveCommand.execute(changeId, { yes: true, noValidate: true }) + ).rejects.toThrow(/archive destination.*selected root/i); + + await expect(fs.access(targetSpec)).rejects.toThrow(); + await expect(fs.access(path.join(changeDir, 'proposal.md'))).resolves.not.toThrow(); + }); + + it('rejects a spec destination that escapes through a symlink or junction prefix', async () => { + const changeId = 'auth/add-login'; + const changeDir = path.join(tempDir, 'openspec', 'changes', 'auth', 'add-login'); + const deltaDir = path.join(changeDir, 'specs', 'billing'); + const outsideSpecs = path.join(tempDir, 'outside-specs'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Add login'); + await fs.writeFile( + path.join(deltaDir, 'spec.md'), + [ + '## ADDED Requirements', + '', + '### Requirement: New billing', + 'The system SHALL add billing.', + '', + '#### Scenario: Billing is added', + '- **WHEN** billing runs', + '- **THEN** the new result is produced', + '', + ].join('\n') + ); + await fs.mkdir(outsideSpecs, { recursive: true }); + await createDirectoryLink( + outsideSpecs, + path.join(tempDir, 'openspec', 'specs', 'auth') + ); + + await expect( + archiveCommand.execute(changeId, { yes: true, noValidate: true }) + ).rejects.toThrow(/spec destination.*selected root/i); + + await expect(fs.access(path.join(changeDir, 'proposal.md'))).resolves.not.toThrow(); + await expect(fs.readdir(outsideSpecs)).resolves.toEqual([]); + }); + + it('rejects a domain container instead of archiving its nested changes', async () => { + const nestedChangeDir = path.join(tempDir, 'openspec', 'changes', 'auth', 'add-login'); + await fs.mkdir(nestedChangeDir, { recursive: true }); + await fs.writeFile(path.join(nestedChangeDir, 'proposal.md'), '# Add login'); + + await expect( + archiveCommand.execute('auth', { yes: true, noValidate: true, skipSpecs: true }) + ).rejects.toThrow("Change 'auth' not found."); + + await expect(fs.access(path.join(nestedChangeDir, 'proposal.md'))).resolves.not.toThrow(); + }); + it('should warn about incomplete tasks', async () => { const changeName = 'incomplete-feature'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); @@ -234,7 +363,7 @@ The system SHALL support logo and backgroundColor fields for gift cards. expect(updatedContent).not.toContain('### Requirement: Thumbnail Field'); // Verify change was archived successfully - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.length).toBeGreaterThan(0); expect(archives.some(a => a.includes(changeName))).toBe(true); @@ -274,7 +403,7 @@ Modified content.`; await expect(fs.access(mainSpecPath)).rejects.toThrow(); // Verify change was NOT archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.some(a => a.includes(changeName))).toBe(false); }); @@ -312,7 +441,7 @@ New feature description. await expect(fs.access(mainSpecPath)).rejects.toThrow(); // Verify change was NOT archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.some(a => a.includes(changeName))).toBe(false); }); @@ -323,14 +452,39 @@ New feature description. ).rejects.toThrow("Change 'non-existent-change' not found."); }); + it('rejects traversal IDs without moving directories outside changes', async () => { + const specsDir = path.join(tempDir, 'openspec', 'specs'); + const sentinelPath = path.join(specsDir, 'sentinel.txt'); + await fs.writeFile(sentinelPath, 'keep me'); + + await expect( + archiveCommand.execute('../specs', { yes: true, noValidate: true, skipSpecs: true }) + ).rejects.toThrow(/Invalid change name/); + + await expect(fs.readFile(sentinelPath, 'utf8')).resolves.toBe('keep me'); + await expect(fs.access(specsDir)).resolves.not.toThrow(); + }); + + it.each([ + 'auth//add-login', + 'auth/add-login/', + 'auth/oauth/Add-Login', + 'archive/legacy-change', + ])('rejects malformed or reserved change ID %s', async (changeId) => { + await expect( + archiveCommand.execute(changeId, { yes: true, noValidate: true, skipSpecs: true }) + ).rejects.toThrow(/Invalid change name/); + }); + it('should throw error if archive already exists', async () => { const changeName = 'duplicate-feature'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); // Create existing archive with same date const date = new Date().toISOString().split('T')[0]; - const archivePath = path.join(tempDir, 'openspec', 'changes', 'archive', `${date}-${changeName}`); + const archivePath = path.join(tempDir, 'openspec', 'archive', `${date}-${changeName}`); await fs.mkdir(archivePath, { recursive: true }); // Try to archive @@ -339,10 +493,131 @@ New feature description. ).rejects.toThrow(`Archive '${date}-${changeName}' already exists.`); }); + it('checks collisions at the full domain-preserving archive path', async () => { + const changeId = 'auth/oauth/add-login'; + const changeDir = path.join(tempDir, 'openspec', 'changes', 'auth', 'oauth', 'add-login'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); + + const date = new Date().toISOString().split('T')[0]; + const archivedAs = `auth/oauth/${date}-add-login`; + await fs.mkdir( + path.join(tempDir, 'openspec', 'archive', 'auth', 'oauth', `${date}-add-login`), + { recursive: true } + ); + + await expect( + archiveCommand.execute(changeId, { yes: true }) + ).rejects.toThrow(`Archive '${archivedAs}' already exists.`); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('checks archive collisions before writing prepared spec updates', async () => { + const changeName = 'colliding-spec-change'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const deltaDir = path.join(changeDir, 'specs', 'billing'); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'billing'); + const mainSpecPath = path.join(mainSpecDir, 'spec.md'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.mkdir(mainSpecDir, { recursive: true }); + + const originalSpec = `# Billing + +## Purpose +Track billing. + +## Requirements + +### Requirement: Existing billing +The system SHALL preserve existing billing. + +#### Scenario: Existing billing works +- **WHEN** billing runs +- **THEN** the existing result is preserved +`; + const deltaSpec = `## ADDED Requirements + +### Requirement: New billing +The system SHALL add new billing. + +#### Scenario: New billing works +- **WHEN** new billing runs +- **THEN** the new result is produced +`; + await fs.writeFile(mainSpecPath, originalSpec); + await fs.writeFile(path.join(deltaDir, 'spec.md'), deltaSpec); + + const date = new Date().toISOString().split('T')[0]; + await fs.mkdir(path.join(tempDir, 'openspec', 'archive', `${date}-${changeName}`)); + + await expect( + archiveCommand.execute(changeName, { yes: true, noValidate: true }) + ).rejects.toThrow(`Archive '${date}-${changeName}' already exists.`); + + await expect(fs.readFile(mainSpecPath, 'utf8')).resolves.toBe(originalSpec); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('treats a dangling archive symlink or junction as a collision before spec writes', async () => { + const changeName = 'dangling-collision'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const deltaDir = path.join(changeDir, 'specs', 'billing'); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'billing'); + const mainSpecPath = path.join(mainSpecDir, 'spec.md'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Dangling collision'); + + const originalSpec = `# Billing + +## Purpose +Track billing. + +## Requirements + +### Requirement: Existing billing +The system SHALL preserve existing billing. + +#### Scenario: Existing billing works +- **WHEN** billing runs +- **THEN** the existing result is preserved +`; + await fs.writeFile(mainSpecPath, originalSpec); + await fs.writeFile( + path.join(deltaDir, 'spec.md'), + `## ADDED Requirements + +### Requirement: New billing +The system SHALL add new billing. + +#### Scenario: New billing works +- **WHEN** new billing runs +- **THEN** the new result is produced +` + ); + + const date = new Date().toISOString().split('T')[0]; + const archivePath = path.join(tempDir, 'openspec', 'archive', `${date}-${changeName}`); + const vanishedTarget = path.join(tempDir, 'vanished-archive-target'); + await fs.mkdir(vanishedTarget); + await createDirectoryLink(vanishedTarget, archivePath); + await fs.rmdir(vanishedTarget); + const lstatSpy = vi.spyOn(fs, 'lstat'); + + await expect( + archiveCommand.execute(changeName, { yes: true, noValidate: true }) + ).rejects.toThrow(`Archive '${date}-${changeName}' already exists.`); + + expect(lstatSpy).toHaveBeenCalledTimes(1); + await expect(fs.readFile(mainSpecPath, 'utf8')).resolves.toBe(originalSpec); + await expect(fs.access(path.join(changeDir, 'proposal.md'))).resolves.not.toThrow(); + }); + it('should handle changes without tasks.md', async () => { const changeName = 'no-tasks-feature'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); // Execute archive without tasks.md await archiveCommand.execute(changeName, { yes: true }); @@ -353,7 +628,7 @@ New feature description. ); // Verify change was archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.length).toBe(1); }); @@ -362,6 +637,7 @@ New feature description. const changeName = 'no-specs-feature'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); // Execute archive without specs await archiveCommand.execute(changeName, { yes: true }); @@ -372,7 +648,7 @@ New feature description. ); // Verify change was archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.length).toBe(1); }); @@ -400,7 +676,7 @@ New feature description. await expect(fs.access(mainSpecPath)).rejects.toThrow(); // Verify change was still archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.length).toBe(1); expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`)); @@ -436,7 +712,7 @@ The system will log all events. expect(deltaSpy).not.toHaveBeenCalled(); expect(specContentSpy).not.toHaveBeenCalled(); - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.length).toBe(1); expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`)); @@ -493,7 +769,7 @@ Then expected result happens`; await expect(fs.access(mainSpecPath)).rejects.toThrow(); // Verify change was still archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.length).toBe(1); expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`)); @@ -696,7 +972,7 @@ The system SHALL support the shared rule. expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); await expect(fs.access(changeBDir)).resolves.not.toThrow(); - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.some(a => a.includes(changeA))).toBe(true); expect(archives.some(a => a.includes(changeB))).toBe(false); @@ -759,7 +1035,7 @@ The system SHALL do B differently. const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); expect(still).toBe(malformedMain); - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.some(a => a.includes(changeName))).toBe(false); }); @@ -945,7 +1221,7 @@ The system will log all events. ); // Change must NOT have been archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.some(a => a.includes(changeName))).toBe(false); }); @@ -978,7 +1254,7 @@ Modified content.`; const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md'); await expect(fs.access(mainSpecPath)).rejects.toThrow(); - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.some(a => a.includes(changeName))).toBe(false); }); @@ -1054,7 +1330,7 @@ The system SHALL do the thing differently. expect(still).toBe(mainContent); // Change must NOT have been archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.some(a => a.includes(changeName))).toBe(false); } finally { @@ -1066,12 +1342,13 @@ The system SHALL do the thing differently. const changeName = 'exit-ok'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); await archiveCommand.execute(changeName, { yes: true }); expect(process.exitCode).toBeUndefined(); - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives.some(a => a.includes(changeName))).toBe(true); }); @@ -1098,6 +1375,8 @@ The system SHALL do the thing differently. const change2 = 'feature-b'; await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change1), { recursive: true }); await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change2), { recursive: true }); + await fs.writeFile(path.join(tempDir, 'openspec', 'changes', change1, 'proposal.md'), '# A'); + await fs.writeFile(path.join(tempDir, 'openspec', 'changes', change2, 'proposal.md'), '# B'); // Mock select to return first change mockSelect.mockResolvedValueOnce(change1); @@ -1115,11 +1394,33 @@ The system SHALL do the thing differently. })); // Verify the selected change was archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archiveDir = path.join(tempDir, 'openspec', 'archive'); const archives = await fs.readdir(archiveDir); expect(archives[0]).toContain(change1); }); + it('displays and archives full slash IDs from recursive discovery', async () => { + const { select } = await import('@inquirer/prompts'); + const mockSelect = select as unknown as ReturnType; + const nestedId = 'auth/oauth/add-login'; + const nestedDir = path.join(tempDir, 'openspec', 'changes', 'auth', 'oauth', 'add-login'); + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile(path.join(nestedDir, 'proposal.md'), '# Add login'); + mockSelect.mockResolvedValueOnce(nestedId); + + await archiveCommand.execute(undefined, { yes: true }); + + expect(mockSelect).toHaveBeenCalledWith(expect.objectContaining({ + choices: expect.arrayContaining([ + expect.objectContaining({ value: nestedId, name: expect.stringContaining(nestedId) }), + ]), + })); + const date = new Date().toISOString().split('T')[0]; + await expect( + fs.access(path.join(tempDir, 'openspec', 'archive', 'auth', 'oauth', `${date}-add-login`)) + ).resolves.not.toThrow(); + }); + it('should use confirm prompt for task warnings', async () => { const { confirm } = await import('@inquirer/prompts'); const mockConfirm = confirm as unknown as ReturnType; diff --git a/test/core/commands/change-command.list.test.ts b/test/core/commands/change-command.list.test.ts index 6bf24420e1..9be024dcbc 100644 --- a/test/core/commands/change-command.list.test.ts +++ b/test/core/commands/change-command.list.test.ts @@ -18,6 +18,9 @@ describe('ChangeCommand.list', () => { const proposal = `# Change: Demo\n\n## Why\nTest list.\n\n## What Changes\n- **auth:** Add requirement`; await fs.writeFile(path.join(changeDir, 'proposal.md'), proposal, 'utf-8'); await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n- [ ] Task 2\n', 'utf-8'); + const nestedChangeDir = path.join(tempRoot, 'openspec', 'changes', 'auth', 'oauth', 'add-login'); + await fs.mkdir(nestedChangeDir, { recursive: true }); + await fs.writeFile(path.join(nestedChangeDir, 'proposal.md'), proposal, 'utf-8'); process.chdir(tempRoot); }); @@ -73,4 +76,29 @@ describe('ChangeCommand.list', () => { console.log = origLog; } }); + + it('includes nested domain IDs in every output mode', async () => { + const logs: string[] = []; + const origLog = console.log; + try { + console.log = (msg?: any, ...args: any[]) => { + logs.push([msg, ...args].filter(Boolean).join(' ')); + }; + + await cmd.list({ json: true }); + expect(JSON.parse(logs.join('\n')).map((item: { id: string }) => item.id)).toContain( + 'auth/oauth/add-login' + ); + + logs.length = 0; + await cmd.list({}); + expect(logs).toContain('auth/oauth/add-login'); + + logs.length = 0; + await cmd.list({ long: true }); + expect(logs.some((line) => line.startsWith('auth/oauth/add-login:'))).toBe(true); + } finally { + console.log = origLog; + } + }); }); diff --git a/test/core/commands/change-command.show-validate.test.ts b/test/core/commands/change-command.show-validate.test.ts index fcaa00ad53..a880085bf0 100644 --- a/test/core/commands/change-command.show-validate.test.ts +++ b/test/core/commands/change-command.show-validate.test.ts @@ -18,6 +18,17 @@ describe('ChangeCommand.show/validate', () => { await fs.mkdir(changesDir, { recursive: true }); const proposal = `# Change: Sample Change\n\n## Why\nConsistency in tests.\n\n## What Changes\n- **auth:** Add requirement`; await fs.writeFile(path.join(changesDir, 'proposal.md'), proposal, 'utf-8'); + const outsideDir = path.join(tempRoot, 'outside'); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(path.join(outsideDir, 'proposal.md'), '# OUTSIDE_SENTINEL', 'utf-8'); + const linkedChangeDir = path.join(outsideDir, 'add-login'); + await fs.mkdir(linkedChangeDir, { recursive: true }); + await fs.writeFile(path.join(linkedChangeDir, 'proposal.md'), '# LINKED_OUTSIDE_SENTINEL', 'utf-8'); + await fs.symlink( + outsideDir, + path.join(tempRoot, 'openspec', 'changes', 'linked'), + process.platform === 'win32' ? 'junction' : 'dir' + ); process.chdir(tempRoot); changeName = 'sample-change'; }); @@ -108,4 +119,22 @@ describe('ChangeCommand.show/validate', () => { console.log = origLog; } }); + + it('show rejects a traversal change ID', async () => { + await expect(cmd.show('../../outside')).rejects.toThrow(/Invalid change name/); + }); + + it('validate rejects a traversal change ID', async () => { + try { + await expect(cmd.validate('../../outside')).rejects.toThrow(/Invalid change name/); + } finally { + process.exitCode = 0; + } + }); + + it.each(['show', 'validate'] as const)('%s rejects a change that escapes through a directory link', async (operation) => { + await expect(cmd[operation]('linked/add-login')).rejects.toThrow( + /Change path must stay within changesDir/ + ); + }); }); diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index 8ac1e0775b..59608616b8 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -204,6 +204,7 @@ describe('command completion registry', () => { const newChange = command('new')?.subcommands?.find((entry) => entry.name === 'change'); expect(newChange?.flags.map((flag) => flag.name)).toEqual([ 'description', + 'domain', 'goal', 'schema', 'json', @@ -217,6 +218,17 @@ describe('command completion registry', () => { expect(newChange?.flags.map((flag) => flag.name)).not.toContain('store-path'); }); + it('advertises mandatory domain selection on new change', () => { + const newChange = command('new')?.subcommands?.find((entry) => entry.name === 'change'); + const domain = newChange?.flags.find((flag) => flag.name === 'domain'); + + expect(domain).toEqual({ + name: 'domain', + description: 'Domain path, or an empty string for the root domain', + takesValue: true, + }); + }); + it('advertises --store on the supported root-selection commands', () => { for (const name of ['list', 'show', 'validate', 'archive', 'status', 'instructions']) { const entry = command(name); diff --git a/test/core/completions/completion-provider.test.ts b/test/core/completions/completion-provider.test.ts index 2af6dc2437..065ec51ac1 100644 --- a/test/core/completions/completion-provider.test.ts +++ b/test/core/completions/completion-provider.test.ts @@ -125,6 +125,14 @@ describe('CompletionProvider', () => { expect(specIds).toEqual(['spec-1', 'spec-2']); }); + it('should return recursively discovered spec IDs with forward slashes', async () => { + const specDir = path.join(testDir, 'openspec', 'specs', 'auth', 'oauth', 'login'); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile(path.join(specDir, 'spec.md'), '# Login'); + + await expect(provider.getSpecIds()).resolves.toEqual(['auth/oauth/login']); + }); + it('should cache results for the TTL duration', async () => { const specsDir = path.join(testDir, 'openspec', 'specs'); await fs.mkdir(specsDir, { recursive: true }); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index a0ab03e384..3b2d96eafc 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -62,7 +62,7 @@ describe('InitCommand', () => { expect(await directoryExists(openspecPath)).toBe(true); expect(await directoryExists(path.join(openspecPath, 'specs'))).toBe(true); expect(await directoryExists(path.join(openspecPath, 'changes'))).toBe(true); - expect(await directoryExists(path.join(openspecPath, 'changes', 'archive'))).toBe(true); + expect(await directoryExists(path.join(openspecPath, 'archive'))).toBe(true); }); it('should create config.yaml with default schema', async () => { diff --git a/test/core/list.test.ts b/test/core/list.test.ts index 096e46a1d8..38825832ab 100644 --- a/test/core/list.test.ts +++ b/test/core/list.test.ts @@ -4,6 +4,19 @@ import path from 'path'; import os from 'os'; import { ListCommand } from '../../src/core/list.js'; +async function scaffoldChange( + changesDir: string, + changeId: string, + tasksContent?: string +): Promise { + const changeDir = path.join(changesDir, ...changeId.split('/')); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); + if (tasksContent !== undefined) { + await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent); + } +} + describe('ListCommand', () => { let tempDir: string; let originalLog: typeof console.log; @@ -61,14 +74,12 @@ describe('ListCommand', () => { it('should exclude archive directory', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); - await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true }); - await fs.mkdir(path.join(changesDir, 'my-change'), { recursive: true }); - - // Create tasks.md with some tasks + await fs.mkdir(path.join(changesDir, 'archive', 'legacy-change'), { recursive: true }); await fs.writeFile( - path.join(changesDir, 'my-change', 'tasks.md'), - '- [x] Task 1\n- [ ] Task 2\n' + path.join(changesDir, 'archive', 'legacy-change', 'proposal.md'), + '# Proposal\n' ); + await scaffoldChange(changesDir, 'my-change', '- [x] Task 1\n- [ ] Task 2\n'); const listCommand = new ListCommand(); await listCommand.execute(tempDir, 'changes'); @@ -80,10 +91,9 @@ describe('ListCommand', () => { it('should count tasks correctly', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); - await fs.mkdir(path.join(changesDir, 'test-change'), { recursive: true }); - - await fs.writeFile( - path.join(changesDir, 'test-change', 'tasks.md'), + await scaffoldChange( + changesDir, + 'test-change', `# Tasks - [x] Completed task 1 - [x] Completed task 2 @@ -102,22 +112,21 @@ Regular text that should be ignored it('should show complete status for fully completed changes', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); - await fs.mkdir(path.join(changesDir, 'completed-change'), { recursive: true }); - - await fs.writeFile( - path.join(changesDir, 'completed-change', 'tasks.md'), + await scaffoldChange( + changesDir, + 'completed-change', '- [x] Task 1\n- [x] Task 2\n- [x] Task 3\n' ); const listCommand = new ListCommand(); await listCommand.execute(tempDir, 'changes'); - expect(logOutput.some(line => line.includes('✓ Complete'))).toBe(true); + expect(logOutput.some(line => line.includes('Complete'))).toBe(true); }); it('should handle changes without tasks.md', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); - await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true }); + await scaffoldChange(changesDir, 'no-tasks'); const listCommand = new ListCommand(); await listCommand.execute(tempDir, 'changes'); @@ -127,9 +136,9 @@ Regular text that should be ignored it('should sort changes alphabetically when sort=name', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); - await fs.mkdir(path.join(changesDir, 'zebra'), { recursive: true }); - await fs.mkdir(path.join(changesDir, 'alpha'), { recursive: true }); - await fs.mkdir(path.join(changesDir, 'middle'), { recursive: true }); + await scaffoldChange(changesDir, 'zebra'); + await scaffoldChange(changesDir, 'alpha'); + await scaffoldChange(changesDir, 'middle'); const listCommand = new ListCommand(); await listCommand.execute(tempDir, 'changes', { sort: 'name' }); @@ -147,29 +156,52 @@ Regular text that should be ignored const changesDir = path.join(tempDir, 'openspec', 'changes'); // Complete change - await fs.mkdir(path.join(changesDir, 'completed'), { recursive: true }); - await fs.writeFile( - path.join(changesDir, 'completed', 'tasks.md'), - '- [x] Task 1\n- [x] Task 2\n' - ); + await scaffoldChange(changesDir, 'completed', '- [x] Task 1\n- [x] Task 2\n'); // Partial change - await fs.mkdir(path.join(changesDir, 'partial'), { recursive: true }); - await fs.writeFile( - path.join(changesDir, 'partial', 'tasks.md'), - '- [x] Done\n- [ ] Not done\n- [ ] Also not done\n' - ); + await scaffoldChange(changesDir, 'partial', '- [x] Done\n- [ ] Not done\n- [ ] Also not done\n'); // No tasks - await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true }); + await scaffoldChange(changesDir, 'no-tasks'); const listCommand = new ListCommand(); await listCommand.execute(tempDir); expect(logOutput).toContain('Changes:'); - expect(logOutput.some(line => line.includes('completed') && line.includes('✓ Complete'))).toBe(true); + expect(logOutput.some(line => line.includes('completed') && line.includes('Complete'))).toBe(true); expect(logOutput.some(line => line.includes('partial') && line.includes('1/3 tasks'))).toBe(true); expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true); }); + + it('should include nested change ids discovered recursively', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + await scaffoldChange(changesDir, 'platform/api/add-auth', '- [ ] Task 1\n'); + await scaffoldChange(changesDir, 'root-change', '- [ ] Task 1\n'); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'changes', { sort: 'name' }); + + const changeLines = logOutput.filter(line => + line.includes('platform/api/add-auth') || line.includes('root-change') + ); + + expect(changeLines[0]).toContain('platform/api/add-auth'); + expect(changeLines[1]).toContain('root-change'); + }); + + it('lists recursively discovered spec IDs with forward slashes', async () => { + const specDir = path.join(tempDir, 'openspec', 'specs', 'auth', 'oauth', 'login'); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile( + path.join(specDir, 'spec.md'), + '## Purpose\nLogin.\n\n## Requirements\n\n### Requirement: Login\nLogin SHALL work.\n' + ); + + await new ListCommand().execute(tempDir, 'specs', { json: true }); + + expect(JSON.parse(logOutput.join('\n')).specs).toEqual([ + { id: 'auth/oauth/login', requirementCount: 1 }, + ]); + }); }); }); diff --git a/test/core/openspec-root.test.ts b/test/core/openspec-root.test.ts index d2059f31e0..5f566f5acb 100644 --- a/test/core/openspec-root.test.ts +++ b/test/core/openspec-root.test.ts @@ -23,7 +23,7 @@ describe('OpenSpec root helper', () => { function createHealthyRoot(root: string, configName = 'config.yaml'): void { fs.mkdirSync(path.join(root, 'openspec', 'specs'), { recursive: true }); - fs.mkdirSync(path.join(root, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.mkdirSync(path.join(root, 'openspec', 'archive'), { recursive: true }); fs.writeFileSync(path.join(root, 'openspec', configName), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); } @@ -65,7 +65,7 @@ describe('OpenSpec root helper', () => { expect(inspection.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ 'openspec_config_missing', ]); - expect(fs.existsSync(path.join(root, 'openspec', 'changes', 'archive'))).toBe(false); + expect(fs.existsSync(path.join(root, 'openspec', 'archive'))).toBe(false); }); it('accepts roots before changes, applied specs, or archives exist', async () => { @@ -100,6 +100,22 @@ describe('OpenSpec root helper', () => { ]); }); + it('reports a sibling archive file even when changes is missing', async () => { + const root = path.join(tempDir, 'store'); + fs.mkdirSync(path.join(root, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', 'archive'), 'not a directory\n'); + fs.writeFileSync(path.join(root, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + + const inspection = await inspectOpenSpecRoot(root); + + expect(inspection.healthy).toBe(false); + expect(inspection.changes).toEqual({ present: false }); + expect(inspection.archive).toEqual({ present: false }); + expect(inspection.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'openspec_archive_not_directory', + ]); + }); + it('ensures the default root shape and records created paths', async () => { const root = path.join(tempDir, 'store'); @@ -109,7 +125,7 @@ describe('OpenSpec root helper', () => { 'openspec/', 'openspec/specs/', 'openspec/changes/', - 'openspec/changes/archive/', + 'openspec/archive/', 'openspec/config.yaml', ]); expect(result.inspection.healthy).toBe(true); @@ -125,7 +141,7 @@ describe('OpenSpec root helper', () => { const result = await ensureOpenSpecRoot(root); - expect(result.createdArtifacts).toEqual([]); + expect(result.createdArtifacts).toEqual(['openspec/changes/']); expect(fs.existsSync(path.join(root, 'openspec', 'config.yaml'))).toBe(false); expect(fs.readFileSync(path.join(root, 'openspec', 'config.yml'), 'utf-8')).toBe( `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` diff --git a/test/core/relationship-health.test.ts b/test/core/relationship-health.test.ts index 2edfe6d0f4..3b22d9fd1c 100644 --- a/test/core/relationship-health.test.ts +++ b/test/core/relationship-health.test.ts @@ -9,7 +9,7 @@ const root = { storeId: 'team-context', changesDir: '/team/store/openspec/changes', specsDir: '/team/store/openspec/specs', - archiveDir: '/team/store/openspec/changes/archive', + archiveDir: '/team/store/openspec/archive', defaultSchema: 'spec-driven', } as ResolvedOpenSpecRoot; diff --git a/test/core/root-selection.test.ts b/test/core/root-selection.test.ts index f20a503810..a3bfd2f0aa 100644 --- a/test/core/root-selection.test.ts +++ b/test/core/root-selection.test.ts @@ -48,7 +48,7 @@ describe('resolveOpenSpecRoot', () => { function createOpenSpecRoot(rootDir: string): void { fs.mkdirSync(path.join(rootDir, 'openspec', 'specs'), { recursive: true }); - fs.mkdirSync(path.join(rootDir, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'openspec', 'archive'), { recursive: true }); fs.writeFileSync(path.join(rootDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); } @@ -114,7 +114,7 @@ describe('resolveOpenSpecRoot', () => { expect(root.path).toBe(storeRoot); expect(root.changesDir).toBe(path.join(storeRoot, 'openspec', 'changes')); expect(root.specsDir).toBe(path.join(storeRoot, 'openspec', 'specs')); - expect(root.archiveDir).toBe(path.join(storeRoot, 'openspec', 'changes', 'archive')); + expect(root.archiveDir).toBe(path.join(storeRoot, 'openspec', 'archive')); expect(root.defaultSchema).toBe('spec-driven'); }); @@ -199,6 +199,7 @@ describe('resolveOpenSpecRoot', () => { expect(root.source).toBe('nearest'); expect(root.path).toBe(repoRoot); + expect(root.archiveDir).toBe(path.join(repoRoot, 'openspec', 'archive')); }); it('ignores leftover workspace view state when a nearest root exists', async () => { @@ -255,6 +256,7 @@ describe('resolveOpenSpecRoot', () => { const implicitRoot = await resolveOpenSpecRoot({ startPath: appRepo, globalDataDir }); expect(implicitRoot.source).toBe('implicit'); expect(implicitRoot.path).toBe(appRepo); + expect(implicitRoot.archiveDir).toBe(path.join(appRepo, 'openspec', 'archive')); await expectRootSelectionError( resolveOpenSpecRoot({ startPath: appRepo, globalDataDir, allowImplicitRoot: false }), @@ -299,6 +301,7 @@ describe('resolveOpenSpecRoot', () => { expect(root.source).toBe('declared'); expect(root.storeId).toBe('team-context'); expect(root.path).toBe(storeRoot); + expect(root.archiveDir).toBe(path.join(storeRoot, 'openspec', 'archive')); // The pointer dir is untouched. expect(fs.existsSync(path.join(pointerDir, 'openspec', 'specs'))).toBe(false); expect(fs.existsSync(path.join(pointerDir, 'openspec', 'changes'))).toBe(false); diff --git a/test/core/specs-apply.test.ts b/test/core/specs-apply.test.ts new file mode 100644 index 0000000000..e4984460bb --- /dev/null +++ b/test/core/specs-apply.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { applySpecs, findSpecUpdates } from '../../src/core/specs-apply.js'; + +const ADDED_DELTA = `## ADDED Requirements + +### Requirement: OAuth login +The system SHALL support OAuth login. + +#### Scenario: Successful login +- **WHEN** a user signs in with OAuth +- **THEN** access is granted +`; + +async function createDirectoryLink(target: string, linkPath: string): Promise { + await fs.symlink(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir'); +} + +describe('domain-prefixed spec application', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-specs-apply-')); + await fs.mkdir(path.join(tempDir, 'openspec', 'specs'), { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function writeDelta(changeId: string): Promise { + const changeDir = path.join(tempDir, 'openspec', 'changes', ...changeId.split('/')); + const deltaDir = path.join(changeDir, 'specs', 'login'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(deltaDir, 'spec.md'), ADDED_DELTA); + return changeDir; + } + + it('prepends the change domain to discovered capability targets', async () => { + const changeDir = await writeDelta('auth/oauth/add-login'); + const specsDir = path.join(tempDir, 'openspec', 'specs'); + + const updates = await findSpecUpdates(changeDir, specsDir, ['auth', 'oauth']); + + expect(updates).toEqual([ + { + source: path.join(changeDir, 'specs', 'login', 'spec.md'), + target: path.join(specsDir, 'auth', 'oauth', 'login', 'spec.md'), + exists: false, + }, + ]); + }); + + it('applies nested change deltas under the matching spec domain', async () => { + await writeDelta('auth/oauth/add-login'); + + await applySpecs(tempDir, 'auth/oauth/add-login', { skipValidation: true, silent: true }); + + await expect( + fs.readFile(path.join(tempDir, 'openspec', 'specs', 'auth', 'oauth', 'login', 'spec.md'), 'utf8') + ).resolves.toContain('### Requirement: OAuth login'); + await expect( + fs.access(path.join(tempDir, 'openspec', 'specs', 'login', 'spec.md')) + ).rejects.toThrow(); + }); + + it('prints the domain-qualified target path in human output', async () => { + await writeDelta('auth/oauth/add-login'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await applySpecs(tempDir, 'auth/oauth/add-login', { skipValidation: true }); + expect(log).toHaveBeenCalledWith( + 'Applying changes to openspec/specs/auth/oauth/login/spec.md:' + ); + } finally { + log.mockRestore(); + } + }); + + it('leaves root change capability targets unchanged', async () => { + await writeDelta('add-login'); + + await applySpecs(tempDir, 'add-login', { skipValidation: true, silent: true }); + + await expect( + fs.readFile(path.join(tempDir, 'openspec', 'specs', 'login', 'spec.md'), 'utf8') + ).resolves.toContain('### Requirement: OAuth login'); + }); + + it('rejects a linked spec destination outside the selected root', async () => { + const changeDir = await writeDelta('auth/add-login'); + const outsideSpecs = path.join(tempDir, 'outside-specs'); + await fs.mkdir(outsideSpecs, { recursive: true }); + await createDirectoryLink( + outsideSpecs, + path.join(tempDir, 'openspec', 'specs', 'auth') + ); + + await expect( + applySpecs(tempDir, 'auth/add-login', { skipValidation: true, silent: true }) + ).rejects.toThrow(/spec destination.*selected root/i); + + await expect(fs.readdir(outsideSpecs)).resolves.toEqual([]); + await expect(fs.readFile(path.join(changeDir, 'specs', 'login', 'spec.md'), 'utf8')).resolves.toBe( + ADDED_DELTA + ); + }); + + it('rejects a domain container that contains nested logical changes', async () => { + const nestedChangeDir = await writeDelta('auth/add-login'); + await fs.writeFile(path.join(nestedChangeDir, 'proposal.md'), '# Add login\n'); + + await expect( + applySpecs(tempDir, 'auth', { skipValidation: true, silent: true }) + ).rejects.toThrow("Change 'auth' not found."); + + await expect(fs.readFile(path.join(nestedChangeDir, 'proposal.md'), 'utf8')).resolves.toBe( + '# Add login\n' + ); + }); + + it('rejects traversal IDs without reading changes or writing specs outside their roots', async () => { + const outsideChangeDir = path.join(tempDir, 'outside'); + const deltaDir = path.join(outsideChangeDir, 'specs', 'login'); + const escapedTarget = path.join(tempDir, 'login', 'spec.md'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(deltaDir, 'spec.md'), ADDED_DELTA); + + await expect( + applySpecs(tempDir, '../../outside', { skipValidation: true, silent: true }) + ).rejects.toThrow(/Invalid change name/); + + await expect(fs.access(escapedTarget)).rejects.toThrow(); + await expect(fs.readFile(path.join(deltaDir, 'spec.md'), 'utf8')).resolves.toBe(ADDED_DELTA); + }); + + it.each([ + 'auth//add-login', + 'auth/add-login/', + 'auth/oauth/Add-Login', + 'archive/legacy-change', + ])('rejects malformed or reserved change ID %s', async (changeId) => { + await expect( + applySpecs(tempDir, changeId, { skipValidation: true, silent: true }) + ).rejects.toThrow(/Invalid change name/); + }); +}); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 65202e9e6e..fcd6feecac 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -38,27 +38,27 @@ import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/ const EXPECTED_FUNCTION_HASHES: Record = { getExploreSkillTemplate: '7d2f54e74fffcb36aaaa4498a4a8b033142bb25945fb9b2de532354acbe76b9c', - getNewChangeSkillTemplate: '39663a6d2037e6697020393a66f6327506e3e3bc573b7a3556dcb7f9457dc51d', + getNewChangeSkillTemplate: '2994cb7688858fef63469415ad759cd60508dcd4d0746d7c304099d5a6e186f0', getContinueChangeSkillTemplate: '1bb28875d6e5946ea2ec5f12e90f55d9784c2fa1f6e4c4e2d0eda53d861d4c75', getApplyChangeSkillTemplate: '0f5a15fc7fb9ad6059a5643d0e01365d27642637a4aaebf182f9eabb45348197', - getFfChangeSkillTemplate: '9f4c12a1c58c723c9c45a139307eb90caf39cedd93c435bc960d0817328875e2', - getSyncSpecsSkillTemplate: '75abb20572256e2b8a647e77befae99f109ab5c4dc954a9c3c184829b5fcaa40', - getOnboardSkillTemplate: 'e871d8ce172bb805ae62a7611aee7a3154d89414f427ad5ef31721c903f13002', + getFfChangeSkillTemplate: 'eb202d18fcbbe3833cba0bf267f8e45f6e71fa0d60a33f80ab97fe5b405830da', + getSyncSpecsSkillTemplate: '9559d55a649f68ab0cee69017e0895eb92b7ce16896d353e3b0429ca4f474704', + getOnboardSkillTemplate: 'a3869b632379ac56ab09e59d1efa53283947b2d73c3fb74156913c7e5b84342f', getOpsxExploreCommandTemplate: '37e53590aae7ac6621d4393aa80a5b8af21881323887fa924ed329199fda27e0', - getOpsxNewCommandTemplate: '57c600cce318d16b9b4308a18d0d983ea3c0673034e606a7cceec07b4c705e87', + getOpsxNewCommandTemplate: 'c329c787ee8db8849d2f4dd7f07cbc5a8bb2290d93bc9623306a2770771aba0e', getOpsxContinueCommandTemplate: '418108b417107a87019d4020b26c105792d2ef0110fe6920445e255889216716', getOpsxApplyCommandTemplate: 'daeb507206707169de73c828e199648dde5732cbc17791ef2a027adffd028574', - getOpsxFfCommandTemplate: '36973ae0dd00ab169fbaaa42bf565f97e1bc97cf63ae7c07307734cc1ca8c1fd', - getArchiveChangeSkillTemplate: 'c511a1c943bcfc5f9f3833b8c0ff284b22d34864a08f5f553cec471ee485d38f', - getBulkArchiveChangeSkillTemplate: '0f635913757ae3d1609e111f4a8f699443ca47cbaaf8a1b21eb652f7b96a1d13', - getOpsxSyncCommandTemplate: '86cf706886d0f18069e2cfa16948b7357028fd348210efb58588c88c416d8622', + getOpsxFfCommandTemplate: 'f2a8f6f49866b3c7cd1c8e9ef46dcb5d68f7eff054e6a7ef00b3dfc6da33675c', + getArchiveChangeSkillTemplate: '0f1e8090ee2350f406e3605572619ce022cc1ac34b14dec5d36f97e73072ffb3', + getBulkArchiveChangeSkillTemplate: 'c47334cf94ef30184cd116fac7364853d4f8bd82487d1ba5adb7400a6479de6a', + getOpsxSyncCommandTemplate: '713969eae4bfda6d8046cdefe1b19f3dbb2cbd0920125755037561d6c26469b3', getVerifyChangeSkillTemplate: 'd718c79aad649223a73fdb11036c93fb3842ac5a780f4934d50bfa03c9692683', - getOpsxArchiveCommandTemplate: '6985bddb310cb45b6b50350bfcebe31bf67146135ca0084c94930920280970a4', - getOpsxOnboardCommandTemplate: '0673f34a0f81fd173bcfb8c3ac83e2b1c617f7b7564e24e5298d3bd5665a05a9', - getOpsxBulkArchiveCommandTemplate: '9f444fc7b27a5b788077b5e3aa4f61af45aa8c8004ac8d899d204fa362ff89b7', + getOpsxArchiveCommandTemplate: '8c443a55106ef4004c6f5e3d5a0ed1381e0d3d217c53d2aba05067860e976ef5', + getOpsxOnboardCommandTemplate: '79f214d7f189248fe7dafe5ba5f92b9d5c1d5f9b58bff0ed549e89c0d0cd0d55', + getOpsxBulkArchiveCommandTemplate: '38ad1d292f77044068eb6749d678cf7e69b610f4b1f4b1b667b55f9d2e2912b7', getOpsxVerifyCommandTemplate: '011509480a20a60342c993906f0f9280c0e9ba5d019d335bdc1ef4d53213a5a8', - getOpsxProposeSkillTemplate: '8dfb5e9c719d5ba547aff0d3953c076dca6b33d7223be98cbffc396b8f1e0048', - getOpsxProposeCommandTemplate: '7cd569beb32d99cdabd0b49615a8245160a8e152b6ea67a99fc4dd71e3f39f50', + getOpsxProposeSkillTemplate: '2515367c8db1fdaaf1a73388dd3f59668d6d41c5753d9a1566925c6851b265e4', + getOpsxProposeCommandTemplate: '33f580cf45baa81987e3652c9574b9a55a1ef3537e57d89fd4b50d52d0728b0d', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', getUpdateChangeSkillTemplate: 'fe2e8edaf973d42dc7fc7dfd846105c4c3cfec0437606e582ec644985cd4e81d', getOpsxUpdateCommandTemplate: 'e55ac5774203a7d9037d2d588889c97c53f3f930da49497cc79e865375920da7', @@ -66,16 +66,16 @@ const EXPECTED_FUNCTION_HASHES: Record = { const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-explore': 'ba099821631ce75ee70af370917bbddbc88d0882ad0e50e91ed687d2185102ef', - 'openspec-new-change': 'd5b8909bea70a33b7a312b38ce204a91f40b6bb2bff12c4c06b3e11641b6a689', + 'openspec-new-change': '17d245dfa5c30780eb405eb8fbd27cf58036a2636768261fc115ae5e6825154a', 'openspec-continue-change': '39b4467a4873cde7c97d52c80d53ac647b220bf7c9d96f4e6505f3188e1a1642', 'openspec-apply-change': '09c0e1cdf5ccc82416d0969d6bd715cc70616bdbc3531358a5c36057f78be55a', - 'openspec-ff-change': '8d5a8890eccbd97d714fbab1d73472f79ad9104b519e000264ae43d752cdf631', - 'openspec-sync-specs': 'f6a1581eb11a30061795c42582db6fa4f5e1f213b4b7cad9f3cbfbe3e9fb2d97', - 'openspec-archive-change': '1821aee5a06afd895d59d1e1d16495e484b6087ecf59ec93460d7d5e7851e772', - 'openspec-bulk-archive-change': '7b09b04a440809dd7dbf0b1d7b695cbb8c41184d8d104eb32e82d7cdfb476d18', + 'openspec-ff-change': 'b8567d9e727b1342126504685337719b1204839478660dac3e28a3f2250ab2c6', + 'openspec-sync-specs': '479068632b10d3bf060c65cd20c645a074f25d8d41bcce6199a48a95543fb1eb', + 'openspec-archive-change': '61965cf8db3b571d1a45ee6e526154f3f546798220a45ffb5e7619cbc899ac6d', + 'openspec-bulk-archive-change': 'c3f5fe47e04e6828a62988f548dee2b81982827d3e6490e95c36521fc07ff490', 'openspec-verify-change': '9a8735eaaa34c278d2193eb32fa736f4b111d1c47e675971c8df40f81d20c8c3', - 'openspec-onboard': 'b1b6fc9a1b3ff64dafe9b8c39a761ee1bd001b542d47b4e4deaf058e0aa21256', - 'openspec-propose': '0cfc9278123d973929cb4da3ea7ac8ae1b6c84b472eed4fb753657b8347eaeb9', + 'openspec-onboard': 'c9fb3577d8b9ba876b3d7b95e85781c989ca302ca95f3e8e9abe2df890bcd92b', + 'openspec-propose': '2edb42356aeb5b2c51b5750e888b0d987f64a4dd63f4b2008117acc9d4f4ca26', 'openspec-update-change': '77ff4d1f1cd08a57649cce1f25e0ebc4f55d6d032dfde5c301d1b479561b72fa', }; @@ -117,6 +117,168 @@ function hash(value: string): string { } describe('skill templates split parity', () => { + it('requires explicit domain selection without silently rewriting literals', () => { + const templates: Array<[string, string]> = [ + ['openspec-propose skill', getOpsxProposeSkillTemplate().instructions], + ['openspec-propose command', getOpsxProposeCommandTemplate().content], + ['openspec-new-change skill', getNewChangeSkillTemplate().instructions], + ['openspec-new-change command', getOpsxNewCommandTemplate().content], + ]; + + for (const [label, content] of templates) { + expect(content, label).toContain('Domain selection is mandatory'); + expect(content, label).toContain('lowercase kebab-case'); + expect(content, label).toContain('Do not silently transform'); + expect(content, label).toContain('Convert to the suggested'); + expect(content, label).toContain('Keep the exact literal'); + expect(content, label).toContain('Choose another domain'); + expect(content, label).toContain('replace underscores with hyphens'); + expect(content, label).toContain('`XMLParser` -> `xml-parser`'); + expect(content, label).toContain('`IOError` -> `io-error`'); + expect(content, label).toContain('lowercase only after the user chooses conversion'); + } + }); + + it('passes the mandatory domain and preserves Store selection in every creation workflow', () => { + const templates: Array<[string, string]> = [ + ['openspec-propose skill', getOpsxProposeSkillTemplate().instructions], + ['openspec-propose command', getOpsxProposeCommandTemplate().content], + ['openspec-new-change skill', getNewChangeSkillTemplate().instructions], + ['openspec-new-change command', getOpsxNewCommandTemplate().content], + ['openspec-ff-change skill', getFfChangeSkillTemplate().instructions], + ['openspec-ff-change command', getOpsxFfCommandTemplate().content], + ['openspec-onboard skill', getOnboardSkillTemplate().instructions], + ['openspec-onboard command', getOpsxOnboardCommandTemplate().content], + ]; + + for (const [label, content] of templates) { + expect(content, label).toContain('openspec new change "" --domain ""'); + expect(content, label).toContain('openspec new change "" --domain ""'); + expect(content, label).toContain('append `--store `'); + expect(content, label).toContain(STORE_SELECTION_GUIDANCE); + } + }); + + it('keeps exact domain literals as an explicit user choice in fast-forward and onboarding', () => { + const templates: Array<[string, string]> = [ + ['openspec-ff-change skill', getFfChangeSkillTemplate().instructions], + ['openspec-ff-change command', getOpsxFfCommandTemplate().content], + ['openspec-onboard skill', getOnboardSkillTemplate().instructions], + ['openspec-onboard command', getOpsxOnboardCommandTemplate().content], + ]; + + for (const [label, content] of templates) { + expect(content, label).toContain('Do not silently transform'); + expect(content, label).toContain('Convert to the suggested'); + expect(content, label).toContain('Keep the exact literal'); + expect(content, label).toContain('Choose another domain'); + } + }); + + it('uses canonical change and spec IDs throughout archive, bulk archive, and sync workflows', () => { + const archiveTemplates: Array<[string, string]> = [ + ['archive skill', getArchiveChangeSkillTemplate().instructions], + ['archive command', getOpsxArchiveCommandTemplate().content], + ]; + const bulkTemplates: Array<[string, string]> = [ + ['bulk archive skill', getBulkArchiveChangeSkillTemplate().instructions], + ['bulk archive command', getOpsxBulkArchiveCommandTemplate().content], + ]; + const syncTemplates: Array<[string, string]> = [ + ['sync skill', getSyncSpecsSkillTemplate().instructions], + ['sync command', getOpsxSyncCommandTemplate().content], + ]; + + for (const [label, content] of [...archiveTemplates, ...bulkTemplates, ...syncTemplates]) { + expect(content, label).toContain('openspec status --change "" --json'); + expect(content, label).not.toContain('--change ""'); + expect(content, label).toContain( + 'derive `` only from every segment before the final `` in the full ``' + ); + expect(content, label).toContain( + 'The repo-local delta path `specs//spec.md` does not contain the change domain.' + ); + expect(content, label).toContain('openspec/specs///spec.md'); + expect(content, label).toContain('openspec/specs//spec.md'); + } + + for (const [label, content] of archiveTemplates) { + expect(content, label).toContain("openspec-sync-specs for change ''"); + } + + for (const [label, content] of bulkTemplates) { + expect(content, label).toContain('` -> [...]`'); + expect(content, label).toContain('identical capability leaves in different domains'); + expect(content, label).not.toContain('`capability -> [changes that touch it]`'); + } + }); + + it('domain-qualifies the propose pre-creation apply teaser', () => { + for (const [label, content] of [ + ['propose skill', getOpsxProposeSkillTemplate().instructions], + ['propose command', getOpsxProposeCommandTemplate().content], + ] as Array<[string, string]>) { + expect(content, label).toContain('When ready to implement, run /opsx:apply '); + } + }); + + it('uses the canonical change ID for every lifecycle command after creation', () => { + const templates: Array<[string, string]> = [ + ['openspec-propose skill', getOpsxProposeSkillTemplate().instructions], + ['openspec-propose command', getOpsxProposeCommandTemplate().content], + ['openspec-new-change skill', getNewChangeSkillTemplate().instructions], + ['openspec-new-change command', getOpsxNewCommandTemplate().content], + ['openspec-ff-change skill', getFfChangeSkillTemplate().instructions], + ['openspec-ff-change command', getOpsxFfCommandTemplate().content], + ['openspec-onboard skill', getOnboardSkillTemplate().instructions], + ['openspec-onboard command', getOpsxOnboardCommandTemplate().content], + ]; + + for (const [label, content] of templates) { + const canonicalGuidance = + 'Set `` to `/` for a non-root domain, or `` for root placement.'; + expect(content, label).toContain(canonicalGuidance); + expect(content, label).toContain('openspec status --change ""'); + expect(content, label).toMatch(/openspec instructions .+ --change ""/u); + expect(content, label).not.toContain('--change ""'); + expect(content, label).not.toContain('openspec archive ""'); + + const downstreamContent = content.slice(content.indexOf(canonicalGuidance)); + for (const reference of downstreamContent + .split('\n') + .filter((line) => /\/opsx:(?:apply|continue|archive|verify)(?:\s|`)/u.test(line))) { + expect(reference, `${label}: ${reference.trim()}`).toContain(''); + } + } + + for (const [label, content] of templates.filter(([label]) => label.includes('onboard'))) { + expect(content, label).toContain('openspec archive ""'); + } + }); + + it('archives slash-qualified changes beside changes while preserving their domains', () => { + const templates: Array<[string, string]> = [ + ['openspec-archive-change skill', getArchiveChangeSkillTemplate().instructions], + ['openspec-archive-change command', getOpsxArchiveCommandTemplate().content], + ['openspec-bulk-archive-change skill', getBulkArchiveChangeSkillTemplate().instructions], + ['openspec-bulk-archive-change command', getOpsxBulkArchiveCommandTemplate().content], + ['openspec-onboard skill', getOnboardSkillTemplate().instructions], + ['openspec-onboard command', getOpsxOnboardCommandTemplate().content], + ]; + + for (const [label, content] of templates) { + expect(content, label).toContain('full slash-delimited change ID'); + expect(content, label).toContain( + '/openspec/archive//YYYY-MM-DD-' + ); + expect(content, label).toContain( + '/openspec/archive/YYYY-MM-DD-' + ); + expect(content, label).not.toContain('/archive'); + expect(content, label).not.toContain('changes/archive'); + } + }); + it('preserves all template function payloads exactly', () => { const functionFactories: Record unknown> = { getExploreSkillTemplate, diff --git a/test/core/view.test.ts b/test/core/view.test.ts index 653bb8624e..8937377dc9 100644 --- a/test/core/view.test.ts +++ b/test/core/view.test.ts @@ -6,6 +6,45 @@ import { ViewCommand } from '../../src/core/view.js'; const stripAnsi = (input: string): string => input.replace(/\u001b\[[0-9;]*m/g, ''); +function extractSection(output: string, heading: string, nextHeadings: string[]): string { + const lines = output.split('\n'); + const headingIndex = lines.findIndex((line) => line.trim() === heading); + if (headingIndex === -1) { + return ''; + } + + const nextHeadingIndex = lines.findIndex( + (line, index) => index > headingIndex && nextHeadings.includes(line.trim()) + ); + const sectionLines = lines.slice( + headingIndex + 1, + nextHeadingIndex === -1 ? lines.length : nextHeadingIndex + ); + + return sectionLines.join('\n').trim(); +} + +function extractSectionLines(output: string, heading: string, nextHeadings: string[]): string[] { + const section = extractSection(output, heading, nextHeadings); + return section + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +async function scaffoldChange( + changesDir: string, + changeId: string, + tasksContent?: string +): Promise { + const changeDir = path.join(changesDir, ...changeId.split('/')); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); + if (tasksContent !== undefined) { + await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent); + } +} + describe('ViewCommand', () => { let tempDir: string; let originalLog: typeof console.log; @@ -33,89 +72,62 @@ describe('ViewCommand', () => { await fs.mkdir(changesDir, { recursive: true }); // Empty change (no tasks.md) - should show in Draft - await fs.mkdir(path.join(changesDir, 'empty-change'), { recursive: true }); + await scaffoldChange(changesDir, 'empty-change'); // Change with tasks.md but no tasks - should show in Draft - await fs.mkdir(path.join(changesDir, 'no-tasks-change'), { recursive: true }); - await fs.writeFile(path.join(changesDir, 'no-tasks-change', 'tasks.md'), '# Tasks\n\nNo tasks yet.'); + await scaffoldChange(changesDir, 'no-tasks-change', '# Tasks\n\nNo tasks yet.'); // Change with all tasks complete - should show in Completed - await fs.mkdir(path.join(changesDir, 'completed-change'), { recursive: true }); - await fs.writeFile( - path.join(changesDir, 'completed-change', 'tasks.md'), - '- [x] Done task\n' - ); + await scaffoldChange(changesDir, 'completed-change', '- [x] Done task\n'); const viewCommand = new ViewCommand(); await viewCommand.execute(tempDir); const output = logOutput.map(stripAnsi).join('\n'); - // Draft section should contain empty and no-tasks changes - expect(output).toContain('Draft Changes'); - expect(output).toContain('empty-change'); - expect(output).toContain('no-tasks-change'); + const draftLines = extractSectionLines(output, 'Draft Changes', [ + 'Active Changes', + 'Completed Changes', + 'Specifications', + ]); + const completedLines = extractSectionLines(output, 'Completed Changes', ['Specifications']); - // Completed section should only contain changes with all tasks done - expect(output).toContain('Completed Changes'); - expect(output).toContain('completed-change'); + expect(draftLines.length).toBeGreaterThan(0); + expect(completedLines.length).toBeGreaterThan(0); - // Verify empty-change and no-tasks-change are in Draft section (marked with ○) - const draftLines = logOutput - .map(stripAnsi) - .filter((line) => line.includes('○')); - const draftNames = draftLines.map((line) => line.trim().replace('○ ', '')); - expect(draftNames).toContain('empty-change'); - expect(draftNames).toContain('no-tasks-change'); + expect(draftLines.some((line) => line.includes('empty-change'))).toBe(true); + expect(draftLines.some((line) => line.includes('no-tasks-change'))).toBe(true); + expect(draftLines.some((line) => line.includes('completed-change'))).toBe(false); - // Verify completed-change is in Completed section (marked with ✓) - const completedLines = logOutput - .map(stripAnsi) - .filter((line) => line.includes('✓')); - const completedNames = completedLines.map((line) => line.trim().replace('✓ ', '')); - expect(completedNames).toContain('completed-change'); - expect(completedNames).not.toContain('empty-change'); - expect(completedNames).not.toContain('no-tasks-change'); + expect(completedLines.some((line) => line.includes('completed-change'))).toBe(true); + expect(completedLines.some((line) => line.includes('empty-change'))).toBe(false); + expect(completedLines.some((line) => line.includes('no-tasks-change'))).toBe(false); }); it('sorts active changes by completion percentage ascending with deterministic tie-breakers', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); await fs.mkdir(changesDir, { recursive: true }); - await fs.mkdir(path.join(changesDir, 'gamma-change'), { recursive: true }); - await fs.writeFile( - path.join(changesDir, 'gamma-change', 'tasks.md'), - '- [x] Done\n- [x] Also done\n- [ ] Not done\n' - ); - - await fs.mkdir(path.join(changesDir, 'beta-change'), { recursive: true }); - await fs.writeFile( - path.join(changesDir, 'beta-change', 'tasks.md'), - '- [x] Task 1\n- [ ] Task 2\n' - ); - - await fs.mkdir(path.join(changesDir, 'delta-change'), { recursive: true }); - await fs.writeFile( - path.join(changesDir, 'delta-change', 'tasks.md'), - '- [x] Task 1\n- [ ] Task 2\n' - ); - - await fs.mkdir(path.join(changesDir, 'alpha-change'), { recursive: true }); - await fs.writeFile( - path.join(changesDir, 'alpha-change', 'tasks.md'), - '- [ ] Task 1\n- [ ] Task 2\n' - ); + await scaffoldChange(changesDir, 'gamma-change', '- [x] Done\n- [x] Also done\n- [ ] Not done\n'); + await scaffoldChange(changesDir, 'beta-change', '- [x] Task 1\n- [ ] Task 2\n'); + await scaffoldChange(changesDir, 'delta-change', '- [x] Task 1\n- [ ] Task 2\n'); + await scaffoldChange(changesDir, 'alpha-change', '- [ ] Task 1\n- [ ] Task 2\n'); const viewCommand = new ViewCommand(); await viewCommand.execute(tempDir); const activeLines = logOutput .map(stripAnsi) - .filter(line => line.includes('◉')); + .filter( + (line) => + line.includes('alpha-change') || + line.includes('beta-change') || + line.includes('delta-change') || + line.includes('gamma-change') + ); const activeOrder = activeLines.map(line => { - const afterBullet = line.split('◉')[1] ?? ''; - return afterBullet.split('[')[0]?.trim(); + return line.replace(/^.*?◉\s+/, '').split('[')[0]?.trim(); }); expect(activeOrder).toEqual([ @@ -126,6 +138,33 @@ describe('ViewCommand', () => { ]); }); + it('shows nested change ids discovered recursively', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + + await scaffoldChange(changesDir, 'platform/api/add-auth', '- [ ] Task 1\n'); + + const viewCommand = new ViewCommand(); + await viewCommand.execute(tempDir); + + const output = logOutput.map(stripAnsi).join('\n'); + expect(output).toContain('platform/api/add-auth'); + expect(output).toContain('Active Changes'); + }); + + it('shows recursively discovered spec IDs with forward slashes', async () => { + const specDir = path.join(tempDir, 'openspec', 'specs', 'auth', 'oauth', 'login'); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile( + path.join(specDir, 'spec.md'), + '## Purpose\nLogin.\n\n## Requirements\n\n### Requirement: Login\nLogin SHALL work.\n' + ); + + await new ViewCommand().execute(tempDir); + + expect(logOutput.map(stripAnsi).join('\n')).toContain('auth/oauth/login'); + }); + it('classifies a nested glob-tasks change as Active, not Draft (#1202)', async () => { const openspecDir = path.join(tempDir, 'openspec'); const changesDir = path.join(openspecDir, 'changes'); @@ -168,11 +207,12 @@ describe('ViewCommand', () => { const output = logOutput.map(stripAnsi).join('\n'); // Active section lists the change with aggregated 3/5 progress; not Draft. - const activeLines = logOutput.map(stripAnsi).filter(line => line.includes('◉')); + const activeLines = logOutput.map(stripAnsi).filter(line => line.includes('nested-change')); expect(activeLines.some(line => line.includes('nested-change'))).toBe(true); - const draftLines = logOutput.map(stripAnsi).filter(line => line.includes('○')); - expect(draftLines.some(line => line.includes('nested-change'))).toBe(false); + const draftSection = output.includes('Draft Changes') + ? output.slice(output.indexOf('Draft Changes'), output.includes('Active Changes') ? output.indexOf('Active Changes') : undefined) + : ''; + expect(draftSection.includes('nested-change')).toBe(false); expect(output).toContain('60%'); }); }); - diff --git a/test/core/working-set.test.ts b/test/core/working-set.test.ts index 7462d1b53e..e256bfbde6 100644 --- a/test/core/working-set.test.ts +++ b/test/core/working-set.test.ts @@ -14,7 +14,7 @@ const root = { storeId: 'team-context', changesDir: '/team/store/openspec/changes', specsDir: '/team/store/openspec/specs', - archiveDir: '/team/store/openspec/changes/archive', + archiveDir: '/team/store/openspec/archive', defaultSchema: 'spec-driven', } as ResolvedOpenSpecRoot; diff --git a/test/helpers/openspec-fixtures.ts b/test/helpers/openspec-fixtures.ts index e0eb51d464..1392803694 100644 --- a/test/helpers/openspec-fixtures.ts +++ b/test/helpers/openspec-fixtures.ts @@ -4,7 +4,7 @@ import * as path from 'node:path'; /** Minimal healthy OpenSpec root layout shared by slice test suites. */ export function createOpenSpecRoot(rootDir: string): void { fs.mkdirSync(path.join(rootDir, 'openspec', 'specs'), { recursive: true }); - fs.mkdirSync(path.join(rootDir, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'openspec', 'archive'), { recursive: true }); fs.writeFileSync(path.join(rootDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); } diff --git a/test/helpers/store-git.ts b/test/helpers/store-git.ts index ff4d5b1054..85345bffc3 100644 --- a/test/helpers/store-git.ts +++ b/test/helpers/store-git.ts @@ -9,7 +9,7 @@ import { DEFAULT_OPENSPEC_SCHEMA } from '../../src/core/index.js'; export function createHealthyOpenSpecRoot(root: string, configName = 'config.yaml'): void { fs.mkdirSync(path.join(root, 'openspec', 'specs'), { recursive: true }); - fs.mkdirSync(path.join(root, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.mkdirSync(path.join(root, 'openspec', 'archive'), { recursive: true }); fs.writeFileSync(path.join(root, 'openspec', configName), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); } diff --git a/test/utils/change-path.test.ts b/test/utils/change-path.test.ts new file mode 100644 index 0000000000..0a777368a9 --- /dev/null +++ b/test/utils/change-path.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { + splitChangeId, + validateDomainPath, + buildArchivePath, + findAllChangeIds, + findAllArchivedChangeIds, + resolveExistingChangeId, + InvalidChangeIdError, + ChangeNotFoundError, +} from '../../src/utils/change-path.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function writeMarker(root: string, relativeDir: string, marker: '.openspec.yaml' | 'proposal.md'): Promise { + const fullDir = path.join(root, ...relativeDir.split('/')); + await fs.mkdir(fullDir, { recursive: true }); + await fs.writeFile(path.join(fullDir, marker), marker === 'proposal.md' ? '# Proposal\n' : 'schema: spec-driven\n', 'utf-8'); +} + +describe('splitChangeId', () => { + it('returns an empty domain for single-segment change ids', () => { + expect(splitChangeId('add-auth')).toEqual({ + domain: [], + name: 'add-auth', + }); + }); + + it('splits nested change ids into domain segments and the final name', () => { + expect(splitChangeId('Platform/API/add-auth')).toEqual({ + domain: ['Platform', 'API'], + name: 'add-auth', + }); + }); +}); + +describe('validateDomainPath', () => { + it('accepts an empty domain path', () => { + expect(validateDomainPath('')).toEqual({ valid: true }); + }); + + it('accepts case-preserving domain segments with dots, underscores, and hyphens', () => { + expect(validateDomainPath('Platform/API.v2/team_alpha')).toEqual({ valid: true }); + }); + + it('rejects empty segments', () => { + expect(validateDomainPath('Platform//API')).toEqual({ + valid: false, + error: 'Domain path cannot contain empty segments', + }); + }); + + it('rejects whitespace in any segment', () => { + expect(validateDomainPath('Platform/API Team')).toEqual({ + valid: false, + error: 'Domain path cannot contain whitespace', + }); + }); + + it('rejects backslashes', () => { + expect(validateDomainPath('Platform\\API')).toEqual({ + valid: false, + error: 'Domain path must use forward slashes', + }); + }); + + it('rejects dot and dot-dot segments', () => { + expect(validateDomainPath('Platform/../API')).toEqual({ + valid: false, + error: 'Domain path cannot contain "." or ".." segments', + }); + }); + + it('rejects every leading-dot segment', () => { + expect(validateDomainPath('Platform/.hidden')).toEqual({ + valid: false, + error: 'Domain path segments cannot start with a dot', + }); + }); +}); + +describe('buildArchivePath', () => { + it('prefixes only the final change-name segment', () => { + expect(buildArchivePath(path.join('tmp', 'archive'), 'Platform/API/add-auth', '2026-07-15')).toBe( + path.join('tmp', 'archive', 'Platform', 'API', '2026-07-15-add-auth') + ); + }); +}); + +describe('resolveExistingChangeId', () => { + let testDir: string; + let changesDir: string; + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `openspec-resolve-change-${randomUUID()}`); + changesDir = path.join(testDir, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('returns parsed segments and the contained path for an existing slash ID', async () => { + const changePath = path.join(changesDir, 'Platform', 'API', 'add-auth'); + await fs.mkdir(changePath, { recursive: true }); + await fs.writeFile(path.join(changePath, '.openspec.yaml'), 'schema: spec-driven\n'); + + await expect(resolveExistingChangeId('Platform/API/add-auth', changesDir)).resolves.toEqual({ + id: 'Platform/API/add-auth', + domain: ['Platform', 'API'], + name: 'add-auth', + path: changePath, + }); + }); + + it('rejects an empty markerless directory as a domain container', async () => { + await fs.mkdir(path.join(changesDir, 'empty-domain')); + + await expect(resolveExistingChangeId('empty-domain', changesDir)).rejects.toBeInstanceOf( + ChangeNotFoundError + ); + }); + + it('rejects invalid IDs with a typed validation error', async () => { + await expect(resolveExistingChangeId('../specs', changesDir)).rejects.toMatchObject({ + name: 'InvalidChangeIdError', + code: 'invalid_domain', + } satisfies Partial); + }); + + it('rejects valid missing IDs with a distinct typed error', async () => { + await expect(resolveExistingChangeId('missing-change', changesDir)).rejects.toBeInstanceOf( + ChangeNotFoundError + ); + }); + + it('maps an ENOTDIR path failure to the stable not-found error', async () => { + const notDirectory = Object.assign(new Error('not a directory'), { code: 'ENOTDIR' }); + vi.spyOn(fs, 'stat').mockRejectedValueOnce(notDirectory); + + await expect(resolveExistingChangeId('auth/add-login', changesDir)).rejects.toBeInstanceOf( + ChangeNotFoundError + ); + }); +}); + +describe('findAllChangeIds', () => { + let testDir: string; + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `openspec-change-path-${randomUUID()}`); + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('returns sorted forward-slash ids from proposal and metadata leaf directories', async () => { + const changesDir = path.join(testDir, 'openspec', 'changes'); + + await writeMarker(changesDir, 'z-last', 'proposal.md'); + await writeMarker(changesDir, 'Platform/API/add-auth', '.openspec.yaml'); + await writeMarker(changesDir, 'Platform/archive/add-logs', 'proposal.md'); + await writeMarker(changesDir, 'archive/legacy-change', 'proposal.md'); + await writeMarker(changesDir, '.hidden/ignored-change', 'proposal.md'); + await fs.mkdir(path.join(changesDir, 'draft-only'), { recursive: true }); + + await expect(findAllChangeIds(changesDir)).resolves.toEqual([ + 'Platform/API/add-auth', + 'Platform/archive/add-logs', + 'z-last', + ]); + }); + + it('returns an empty array when the changes root is missing', async () => { + await expect(findAllChangeIds(path.join(testDir, 'missing'))).resolves.toEqual([]); + }); + + it('propagates unexpected readdir errors', async () => { + const changesDir = path.join(testDir, 'openspec', 'changes'); + await writeMarker(changesDir, 'Platform/API/add-auth', '.openspec.yaml'); + + const diskFailure = Object.assign(new Error('disk failure'), { code: 'EIO' }); + const originalReaddir = fs.readdir.bind(fs); + + vi.spyOn(fs, 'readdir').mockImplementation(async (targetPath, options) => { + if (targetPath === changesDir) { + throw diskFailure; + } + + return originalReaddir(targetPath, options as { withFileTypes?: boolean } | BufferEncoding | null | undefined); + }); + + await expect(findAllChangeIds(changesDir)).rejects.toBe(diskFailure); + }); +}); + +describe('findAllArchivedChangeIds', () => { + let testDir: string; + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `openspec-archived-change-path-${randomUUID()}`); + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('discovers archived change ids without reserving the archive domain name', async () => { + const archiveDir = path.join(testDir, 'openspec', 'archive'); + + await writeMarker(archiveDir, '2026-07-15-add-auth', 'proposal.md'); + await writeMarker(archiveDir, 'archive/legacy/add-logs', '.openspec.yaml'); + await writeMarker(archiveDir, '.hidden/ignored-change', 'proposal.md'); + + await expect(findAllArchivedChangeIds(archiveDir)).resolves.toEqual([ + '2026-07-15-add-auth', + 'archive/legacy/add-logs', + ]); + }); + + it('returns an empty array when the archive root is missing', async () => { + await expect(findAllArchivedChangeIds(path.join(testDir, 'missing'))).resolves.toEqual([]); + }); +}); diff --git a/test/utils/change-utils.test.ts b/test/utils/change-utils.test.ts index 090b21b24f..6572ca0f0e 100644 --- a/test/utils/change-utils.test.ts +++ b/test/utils/change-utils.test.ts @@ -5,6 +5,23 @@ import os from 'os'; import { randomUUID } from 'crypto'; import { validateChangeName, createChange } from '../../src/utils/change-utils.js'; +const LINK_CREATION_UNAVAILABLE_CODES = new Set([ + 'EACCES', + 'EINVAL', + 'ENOSYS', + 'ENOTSUP', + 'EPERM', +]); + +function isLinkCreationUnavailable(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + LINK_CREATION_UNAVAILABLE_CODES.has(String(error.code)) + ); +} + describe('validateChangeName', () => { describe('valid names', () => { it('should accept simple kebab-case name', () => { @@ -145,6 +162,35 @@ describe('createChange', () => { const content = await fs.readFile(metaPath, 'utf-8'); expect(content).toContain('schema: spec-driven'); }); + + it('should create a nested change from a full slash ID', async () => { + const result = await createChange(testDir, 'Platform/API/add-auth'); + + expect(result.changeDir).toBe( + path.join(testDir, 'openspec', 'changes', 'Platform', 'API', 'add-auth') + ); + await expect( + fs.readFile(path.join(result.changeDir, '.openspec.yaml'), 'utf-8') + ).resolves.toContain('schema: spec-driven'); + }); + + it('should resolve a nested ID beneath an explicit changes directory', async () => { + const changesDir = path.join(testDir, 'selected-root', 'changes'); + + const result = await createChange(testDir, 'Platform/add-auth', { changesDir }); + + expect(result.changeDir).toBe(path.join(changesDir, 'Platform', 'add-auth')); + }); + + it('should scaffold openspec/archive without creating openspec/changes/archive', async () => { + await createChange(testDir, 'add-auth'); + + const archiveDir = path.join(testDir, 'openspec', 'archive'); + const legacyArchiveDir = path.join(testDir, 'openspec', 'changes', 'archive'); + + await expect(fs.stat(archiveDir)).resolves.toMatchObject({ isDirectory: expect.any(Function) }); + await expect(fs.access(legacyArchiveDir)).rejects.toThrow(); + }); }); describe('schema validation', () => { @@ -183,6 +229,73 @@ describe('createChange', () => { /empty/ ); }); + + it('should validate the domain and final name independently', async () => { + await expect(createChange(testDir, 'Platform/../add-auth')).rejects.toThrow( + /Domain path cannot contain "\." or "\.\." segments/ + ); + await expect(createChange(testDir, 'Platform/Add-Auth')).rejects.toThrow(/lowercase/); + }); + + it('should reject a full ID that could escape the changes directory', async () => { + const changesDir = path.join(testDir, 'selected-root', 'changes'); + + await expect( + createChange(testDir, '../add-auth', { changesDir }) + ).rejects.toThrow(/Domain path/); + await expect(fs.access(path.join(testDir, 'selected-root', 'add-auth'))).rejects.toThrow(); + }); + + it('should reject a link ancestor that escapes the changes directory', async (context) => { + const changesDir = path.join(testDir, 'selected-root', 'changes'); + const outsideDir = path.join(testDir, 'outside'); + const linkedDomain = path.join(changesDir, 'Linked'); + await fs.mkdir(changesDir, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + + try { + await fs.symlink( + outsideDir, + linkedDomain, + process.platform === 'win32' ? 'junction' : 'dir' + ); + } catch (error) { + if (isLinkCreationUnavailable(error)) { + context.skip(); + return; + } + throw error; + } + + await expect( + createChange(testDir, 'Linked/add-auth', { changesDir }) + ).rejects.toThrow(/stay within changesDir/); + await expect(fs.access(path.join(outsideDir, 'add-auth'))).rejects.toThrow(); + }); + + it('should reject the reserved archive segment at the changes root', async () => { + await expect(createChange(testDir, 'archive/add-auth')).rejects.toThrow( + /root segment 'archive' is reserved/i + ); + await expect(createChange(testDir, 'archive')).rejects.toThrow( + /root segment 'archive' is reserved/i + ); + }); + + it.each(['.openspec.yaml', 'proposal.md'])( + 'should reject a domain prefix marked as a change by %s', + async (marker) => { + const changesDir = path.join(testDir, 'openspec', 'changes'); + const existingChangeDir = path.join(changesDir, 'existing-change'); + await fs.mkdir(existingChangeDir, { recursive: true }); + await fs.writeFile(path.join(existingChangeDir, marker), 'existing change\n'); + + await expect( + createChange(testDir, 'existing-change/add-auth') + ).rejects.toThrow(/Domain prefix 'existing-change' is already a change/); + await expect(fs.access(path.join(existingChangeDir, 'add-auth'))).rejects.toThrow(); + } + ); }); describe('creates parent directories if needed', () => { @@ -197,5 +310,27 @@ describe('createChange', () => { const stats = await fs.stat(changeDir); expect(stats.isDirectory()).toBe(true); }); + + it('should preserve existing config while scaffolding sibling archive', async () => { + const newProjectDir = path.join(testDir, 'configured-project'); + await fs.mkdir(path.join(newProjectDir, 'openspec'), { recursive: true }); + await fs.writeFile( + path.join(newProjectDir, 'openspec', 'config.yml'), + '# keep existing config\nschema: spec-driven\n', + 'utf-8' + ); + + await createChange(newProjectDir, 'add-auth', { defaultSchema: 'spec-driven' }); + + const archiveDir = path.join(newProjectDir, 'openspec', 'archive'); + const legacyArchiveDir = path.join(newProjectDir, 'openspec', 'changes', 'archive'); + + expect(await fs.readFile(path.join(newProjectDir, 'openspec', 'config.yml'), 'utf-8')).toBe( + '# keep existing config\nschema: spec-driven\n' + ); + await expect(fs.access(path.join(newProjectDir, 'openspec', 'config.yaml'))).rejects.toThrow(); + await expect(fs.stat(archiveDir)).resolves.toMatchObject({ isDirectory: expect.any(Function) }); + await expect(fs.access(legacyArchiveDir)).rejects.toThrow(); + }); }); }); diff --git a/test/utils/item-discovery.test.ts b/test/utils/item-discovery.test.ts new file mode 100644 index 0000000000..86535277ab --- /dev/null +++ b/test/utils/item-discovery.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { getActiveChangeIds, getArchivedChangeIds } from '../../src/utils/item-discovery.js'; + +async function writeMarker( + root: string, + relativeDir: string, + marker: '.openspec.yaml' | 'proposal.md' +): Promise { + const fullDir = path.join(root, ...relativeDir.split('/')); + await fs.mkdir(fullDir, { recursive: true }); + await fs.writeFile( + path.join(fullDir, marker), + marker === 'proposal.md' ? '# Proposal\n' : 'schema: spec-driven\n', + 'utf-8' + ); +} + +describe('item discovery', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-item-discovery-${randomUUID()}`); + await fs.mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('discovers root, one-level, and multi-level active change ids recursively', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + + await writeMarker(changesDir, 'add-root', 'proposal.md'); + await writeMarker(changesDir, 'platform/add-api', '.openspec.yaml'); + await writeMarker(changesDir, 'platform/mobile/add-ios', '.openspec.yaml'); + await writeMarker(changesDir, 'archive/legacy-change', 'proposal.md'); + await writeMarker(changesDir, '.hidden/ignored-change', 'proposal.md'); + + await expect(getActiveChangeIds(tempDir)).resolves.toEqual([ + 'add-root', + 'platform/add-api', + 'platform/mobile/add-ios', + ]); + }); + + it('discovers archived change ids from the sibling openspec/archive tree', async () => { + const siblingArchiveDir = path.join(tempDir, 'openspec', 'archive'); + const legacyArchiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + + await writeMarker(siblingArchiveDir, '2026-07-15-add-root', 'proposal.md'); + await writeMarker(siblingArchiveDir, 'platform/2026-07-15-add-api', '.openspec.yaml'); + await writeMarker(legacyArchiveDir, 'legacy-only-change', 'proposal.md'); + + await expect(getArchivedChangeIds(tempDir)).resolves.toEqual([ + '2026-07-15-add-root', + 'platform/2026-07-15-add-api', + ]); + }); +}); diff --git a/test/vocabulary-sweep.test.ts b/test/vocabulary-sweep.test.ts index ab5bf157d9..7e7db546b0 100644 --- a/test/vocabulary-sweep.test.ts +++ b/test/vocabulary-sweep.test.ts @@ -47,6 +47,55 @@ function* walkFiles(dir: string): Generator { } describe('vocabulary sweep', () => { + it('keeps published docs on sibling archives and explicit domain creation', () => { + const archiveOffenders: string[] = []; + const domainOffenders: string[] = []; + const changeIdOffenders: string[] = []; + const publishedFiles = [ + path.join(REPO_ROOT, 'README.md'), + path.join(REPO_ROOT, 'CHANGELOG.md'), + ...walkFiles(path.join(REPO_ROOT, 'docs')), + ]; + const retiredArchivePath = 'changes' + '/archive'; + + for (const filePath of publishedFiles) { + const relativePath = path.relative(REPO_ROOT, filePath).replaceAll('\\', '/'); + if (relativePath.startsWith('docs/superpowers/')) { + continue; + } + + const lines = fs.readFileSync(filePath, 'utf-8').split('\n'); + lines.forEach((line, index) => { + if (line.includes(retiredArchivePath)) { + archiveOffenders.push(`${relativePath}:${index + 1}: ${line.trim()}`); + } + + const command = line.trim(); + if (command.startsWith('openspec new change ') && !command.includes('--domain')) { + domainOffenders.push(`${relativePath}:${index + 1}: ${command}`); + } + if ( + /--change (?:""|)|(?:\/opsx:(?:apply|archive|continue|verify)|openspec archive) \[?change-name/u.test(line) + ) { + changeIdOffenders.push(`${relativePath}:${index + 1}: ${line.trim()}`); + } + }); + } + + expect( + archiveOffenders, + `retired archive path found in published docs:\n${archiveOffenders.join('\n')}` + ).toEqual([]); + expect( + domainOffenders, + `creation example without explicit domain found:\n${domainOffenders.join('\n')}` + ).toEqual([]); + expect( + changeIdOffenders, + `non-canonical lifecycle placeholder found:\n${changeIdOffenders.join('\n')}` + ).toEqual([]); + }); + it('keeps the retired store vocabulary out of live surfaces', () => { const offenders: string[] = [];