From a7a5931cc50903822019e4b2babe31ec106d9f7a Mon Sep 17 00:00:00 2001 From: Alex <45095641+runsonmypc@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:25:24 +0200 Subject: [PATCH 1/7] feat(changes): organize plans by approval --- .changeset/tidy-change-approval.md | 5 ++ README.md | 7 +- docs/concepts.md | 16 +++- .../.openspec.yaml | 2 + .../organize-changes-by-approval/design.md | 22 +++++ .../organize-changes-by-approval/proposal.md | 38 +++++++++ .../specs/change-approval/spec.md | 77 +++++++++++++++++ .../specs/change-creation/spec.md | 24 ++++++ .../specs/cli-list/spec.md | 20 +++++ .../specs/openspec-conventions/spec.md | 28 ++++++ .../organize-changes-by-approval/tasks.md | 15 ++++ src/commands/change.ts | 27 ++---- src/commands/validate.ts | 2 +- src/commands/workflow/shared.ts | 14 +-- src/core/archive.ts | 23 ++--- src/core/artifact-graph/instruction-loader.ts | 3 +- src/core/init.ts | 2 + src/core/list.ts | 11 ++- src/core/planning-home.ts | 3 +- .../templates/workflows/store-selection.ts | 4 +- src/core/view.ts | 33 ++++--- src/utils/change-directory.ts | 65 ++++++++++++++ src/utils/change-metadata.ts | 6 +- src/utils/change-utils.ts | 13 +-- src/utils/item-discovery.ts | 13 +-- src/utils/task-progress.ts | 4 +- test/cli-e2e/capstone-journeys.test.ts | 6 +- test/cli-e2e/store-lifecycle.test.ts | 22 ++++- test/commands/artifact-workflow.test.ts | 20 ++--- test/commands/change-initiative-link.test.ts | 3 +- test/commands/context.test.ts | 4 +- test/commands/declared-store-fallback.test.ts | 5 +- test/commands/schema.test.ts | 2 +- test/commands/store-references.test.ts | 2 +- test/commands/store-root-selection.test.ts | 6 +- test/core/archive.test.ts | 11 +++ .../change-command.show-validate.test.ts | 6 +- test/utils/change-directory.test.ts | 85 +++++++++++++++++++ test/utils/change-utils.test.ts | 14 +-- 39 files changed, 532 insertions(+), 131 deletions(-) create mode 100644 .changeset/tidy-change-approval.md create mode 100644 openspec/changes/approved/organize-changes-by-approval/.openspec.yaml create mode 100644 openspec/changes/approved/organize-changes-by-approval/design.md create mode 100644 openspec/changes/approved/organize-changes-by-approval/proposal.md create mode 100644 openspec/changes/approved/organize-changes-by-approval/specs/change-approval/spec.md create mode 100644 openspec/changes/approved/organize-changes-by-approval/specs/change-creation/spec.md create mode 100644 openspec/changes/approved/organize-changes-by-approval/specs/cli-list/spec.md create mode 100644 openspec/changes/approved/organize-changes-by-approval/specs/openspec-conventions/spec.md create mode 100644 openspec/changes/approved/organize-changes-by-approval/tasks.md create mode 100644 src/utils/change-directory.ts create mode 100644 test/utils/change-directory.test.ts diff --git a/.changeset/tidy-change-approval.md b/.changeset/tidy-change-approval.md new file mode 100644 index 0000000000..98811491e3 --- /dev/null +++ b/.changeset/tidy-change-approval.md @@ -0,0 +1,5 @@ +--- +'@fission-ai/openspec': minor +--- + +Create changes in `changes/proposed/` and teach agents to move approved plans into `changes/approved/`. CLI commands resolve both folders and existing flat changes by name; updating leaves old changes in place. Archive rejects changes still in proposed. diff --git a/README.md b/README.md index c8b71a475d..c7cfb7b710 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ AI: Let me look at your styling setup... You: Yes, let's do it. You: /opsx:propose add-dark-mode -AI: Created openspec/changes/add-dark-mode/ +AI: Created openspec/changes/proposed/add-dark-mode/ ✓ proposal.md — why we're doing this, what's changing ✓ specs/ — requirements and scenarios ✓ design.md — technical approach @@ -64,7 +64,8 @@ AI: Created openspec/changes/add-dark-mode/ Ready for implementation! You: /opsx:apply -AI: Implementing tasks... +AI: Moved to openspec/changes/approved/add-dark-mode/ + Implementing tasks... ✓ 1.1 Add theme context provider ✓ 1.2 Create toggle component ✓ 2.1 Add CSS variables @@ -216,6 +217,8 @@ Run this inside each project to regenerate AI guidance and ensure the latest sla openspec update ``` +Existing change folders stay where they are. See [change directories](docs/concepts.md#change-directories) for the proposed/approved convention. + ## Usage Notes **Model selection**: OpenSpec works best with high-reasoning models. We recommend Codex 5.5 and Opus 4.7 for both planning and implementation. diff --git a/docs/concepts.md b/docs/concepts.md index 10106c5b78..6b0811d398 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -186,7 +186,7 @@ A change is a proposed modification to your system, packaged as a folder with ev ### Change Structure ``` -openspec/changes/add-dark-mode/ +openspec/changes/proposed/add-dark-mode/ ├── proposal.md # Why and what ├── design.md # How (technical approach) ├── tasks.md # Implementation checklist @@ -201,6 +201,18 @@ Each change is self-contained. It has: - **Delta specs** — specifications for what's being added, modified, or removed - **Metadata** — optional configuration for this specific change +### Change Directories + +- **`changes/proposed/`**: New plans awaiting approval. +- **`changes/approved/`**: Plans you have approved in conversation or selected for apply. Your agent moves the whole change folder here. +- **`changes/archive/`**: Archived changes. + +Approving a plan can leave implementation for later. Existing flat `changes//` folders stay usable after an update; your agent moves one when you approve or apply it. Commands still accept the change name, such as `openspec status --change add-dark-mode`. + +Archive accepts approved changes and existing flat changes. It rejects changes still in `proposed/`. + +Scripts should use the resolved paths returned by `status --json`. Older CLI versions do not discover changes inside the new folders. + ### Why Changes Are Folders Packaging a change as a folder has several benefits: @@ -514,7 +526,7 @@ openspec/ │ └── auth/ │ └── spec.md ◄────────────────┐ └── changes/ │ - └── add-2fa/ │ + └── approved/add-2fa/ │ ├── proposal.md │ ├── design.md │ merge ├── tasks.md │ diff --git a/openspec/changes/approved/organize-changes-by-approval/.openspec.yaml b/openspec/changes/approved/organize-changes-by-approval/.openspec.yaml new file mode 100644 index 0000000000..7a8e2be6f1 --- /dev/null +++ b/openspec/changes/approved/organize-changes-by-approval/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/approved/organize-changes-by-approval/design.md b/openspec/changes/approved/organize-changes-by-approval/design.md new file mode 100644 index 0000000000..34b5a05ded --- /dev/null +++ b/openspec/changes/approved/organize-changes-by-approval/design.md @@ -0,0 +1,22 @@ +## Context + +New changes belong in proposed; user approval or apply moves them into approved. Existing flat changes stay readable. See proposal.md for upstream discussion and contribution guidance. + +## Goals / Non-Goals + +The directory carries approval. The agent performs moves and handles conversational intent. No approval command, migration, metadata, approval column, or automated legacy classification. + +## Decisions + +- Share a small discovery/resolution helper across existing CLI consumers. Search only flat changes and the two explicit containers; keep bare change names and existing command output. Reject ambiguous names instead of choosing a different change silently. +- Create new changes under proposed. Keep old changes where they are; users and their agents can see them during ordinary scanning. Update never mass-moves them. +- Put brief approval guidance in the shared workflow instructions: use the resolved planning home, move the finished change on explicit approval or apply, preserve its contents, and refresh paths. Approval alone does not start implementation. Agents use their available filesystem tools on Windows, macOS, and Linux. +- Archive approved changes; reject proposed changes. Preserve archive behavior for existing flat changes and keep the destination unchanged. Use native Node.js paths in CLI lookup; preserve existing traversal and linked-path protections. + +## Risks / Trade-offs + +Bare names can collide across directories after a merge; report the conflicting paths. Existing changes named proposed or approved can collide with container names; preserve marked change directories and report a conflict before using them as containers. Older CLIs and scripts constructing flat paths need updating. Conversational behavior relies on the user's LLM following workflow instructions. + +## Migration Plan + +No migration. Ship lookup, creation, and workflow instructions together. The user authorized this local implementation; reconcile with #1367 and #1818 before proposing it upstream. diff --git a/openspec/changes/approved/organize-changes-by-approval/proposal.md b/openspec/changes/approved/organize-changes-by-approval/proposal.md new file mode 100644 index 0000000000..9ee9c52151 --- /dev/null +++ b/openspec/changes/approved/organize-changes-by-approval/proposal.md @@ -0,0 +1,38 @@ +## Why + +Shared change directories mix ideas awaiting review with plans approved for implementation. Developers need to see that distinction before treating a change as pending work. + +## What Changes + +- New changes move from `changes//` to `changes/proposed//`. **BREAKING** for scripts that construct paths; change names and CLI selectors stay the same. +- Explicit conversational approval of a finished plan, or invoking the apply workflow for it, moves the change to `changes/approved//` before implementation. Approval alone can leave implementation for later. +- Commands find changes in either directory. The directory is the source of approval state. +- Updating OpenSpec preserves existing flat changes and the agent moves them to `approved/` only upon explicit approval or apply. Existing archives remain in `changes/archive/`. +- The user's agent performs the move using its filesystem tools. No approval command, metadata, migration, or legacy-status UI. +- Changes follow proposed → approved → archive. Archive rejects proposed changes; existing flat changes retain their archive behavior. + +## Capabilities + +### New Capabilities + +- `change-approval`: Approval transition, agent workflow triggers, location-aware command behavior, and legacy compatibility. + +### Modified Capabilities + +- `change-creation`: Create proposed changes and prevent duplicate active names across locations. +- `cli-list`: Discover all active locations. +- `openspec-conventions`: Document proposed, approved, legacy, and archived change locations. + +## Impact + +Change creation and discovery, planning-home resolution, commands that read or archive changes, generated workflow instructions, shell completion, and user documentation need consistent paths. No new dependency or metadata state machine is needed. + +Related upstream work: [#1683](https://github.com/Fission-AI/OpenSpec/issues/1683) proposes lifecycle metadata; its [implementation #1684](https://github.com/Fission-AI/OpenSpec/pull/1684) was closed unmerged. [#1818](https://github.com/Fission-AI/OpenSpec/issues/1818) proposes draft changes. This proposal uses the requested directory distinction and leaves shipped state and sync lifecycle changes outside its scope. + +## Contribution alignment + +The repository's [contribution guidance](../../../../README.md#contributing) asks for an OpenSpec proposal and alignment on intent before implementing new features. The user has authorized a minimal local implementation; upstream acceptance remains a separate decision. It must work across supported agents, schemas, platforms, and stores. Any eventual implementation PR should disclose the coding agent/model, include verified tests, and use conventional commit subjects. + +[#1684 was closed by its author](https://github.com/Fission-AI/OpenSpec/pull/1684#issuecomment-5568728429), who concluded that OpenSpec's coupling of specs and decision records did not fit their team's PR workflows. It was not a maintainer rejection of this proposed approval layout. In the [preceding design review](https://github.com/Fission-AI/OpenSpec/pull/1684#issuecomment-5427198908), the maintainer requested a smaller scope, opposed two lifecycle models, identified correctness/data-loss issues, and noted missing docs and agent workflow updates. + +That review explicitly directs layout work to [open PR #1367](https://github.com/Fission-AI/OpenSpec/pull/1367), which introduces nested domains and relocates archives. Before upstream submission, seek agreement on fixed approval directories and their relationship to #1367 and draft issue #1818. The proposed approach keeps existing archive semantics, avoids lifecycle configuration and bulk migration, and includes all generated workflow surfaces. These scope choices address review concerns; they do not imply maintainer acceptance. diff --git a/openspec/changes/approved/organize-changes-by-approval/specs/change-approval/spec.md b/openspec/changes/approved/organize-changes-by-approval/specs/change-approval/spec.md new file mode 100644 index 0000000000..5bcc73be00 --- /dev/null +++ b/openspec/changes/approved/organize-changes-by-approval/specs/change-approval/spec.md @@ -0,0 +1,77 @@ +## Purpose + +Distinguish changes awaiting review from changes approved for implementation while preserving access to existing plans. + +## ADDED Requirements + +### Requirement: Explicit approval transition +OpenSpec's agent workflows SHALL instruct the agent to move a finished proposed or legacy change into `changes/approved//` within its selected planning root, preserving its name and contents. Approval SHALL be determined by directory location. + +#### Scenario: Approve a finished plan +- **WHEN** the user approves a change whose schema-required planning artifacts are complete +- **THEN** the agent moves the entire change into `approved/` and reports its resolved location +- **AND** task progress and artifact contents remain unchanged + +#### Scenario: Approval without implementation +- **WHEN** the user approves the finished plan in conversation but leaves implementation for later +- **THEN** the agent moves the directory and leaves implementation tasks untouched + +#### Scenario: Apply implies approval +- **WHEN** the user invokes the apply workflow for a finished proposed or legacy change +- **THEN** the agent approves that change before implementation and refreshes its resolved artifact paths + +#### Scenario: Planning is incomplete +- **WHEN** approval or apply is requested for a change missing required planning artifacts +- **THEN** the agent reports the missing artifacts and the change stays at its current location + +#### Scenario: Finishing a proposal is not approval +- **WHEN** the agent completes planning without explicit user approval or an apply request +- **THEN** the change remains proposed + +#### Scenario: Approval is repeated +- **WHEN** an already approved change is approved again +- **THEN** the agent uses the existing approved location + +#### Scenario: Move cannot be completed +- **WHEN** the destination conflicts or a filesystem error prevents approval +- **THEN** the agent reports the failure, preserves existing content, and the agent pauses before implementation + +#### Scenario: Approval on Windows or in a store +- **WHEN** a user approves a change on Windows, macOS, or Linux, including with a selected store +- **THEN** it moves within the selected planning root using native paths, including roots containing spaces + +### Requirement: Stable change identity +OpenSpec SHALL resolve active changes by their existing bare name across proposed, approved, and legacy locations for listing, showing, validation, status, instructions, view, completion, and archive. Resolved artifact paths SHALL identify the actual location. + +#### Scenario: Commands follow approval +- **WHEN** a change moves from proposed to approved +- **THEN** existing name-based commands continue to address the same artifacts and report their new paths + +#### Scenario: Read-only inspection +- **WHEN** a user lists changes or requests status, validation, or apply instructions +- **THEN** the change's approval state and location remain unchanged + +#### Scenario: Duplicate names from a merge +- **WHEN** the same active name exists in more than one supported location +- **THEN** commands report the conflicting paths and require the collision to be resolved before acting on that name + +#### Scenario: Archive an approved or legacy change +- **WHEN** a user archives an approved or legacy change +- **THEN** existing archive checks and spec synchronization behavior apply to its resolved directory +- **AND** the destination remains `changes/archive//` + +#### Scenario: A proposed change cannot be archived +- **WHEN** a user tries to archive a change still in proposed +- **THEN** OpenSpec leaves it in place and asks the user to approve or apply it first + +### Requirement: Preserve legacy changes on update +Updating OpenSpec SHALL preserve flat `changes//` directories and make them accessible by name without assigning approval. + +#### Scenario: Update an existing project +- **WHEN** OpenSpec is updated in a project containing flat changes and archives +- **THEN** existing change and archive contents and locations are preserved +- **AND** new changes use `proposed/` + +#### Scenario: Approve a legacy change +- **WHEN** a finished legacy change is explicitly approved or selected for apply +- **THEN** it moves directly into `approved/` using the same approval behavior as a proposed change diff --git a/openspec/changes/approved/organize-changes-by-approval/specs/change-creation/spec.md b/openspec/changes/approved/organize-changes-by-approval/specs/change-creation/spec.md new file mode 100644 index 0000000000..5e68543c68 --- /dev/null +++ b/openspec/changes/approved/organize-changes-by-approval/specs/change-creation/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Change Creation +The system SHALL provide a function to create new change directories programmatically in the proposed location of the selected planning root. + +#### Scenario: Create change +- **WHEN** `createChange(projectRoot, 'add-auth')` is called +- **THEN** the system creates `openspec/changes/proposed/add-auth/` directory + +#### Scenario: Duplicate change rejected +- **WHEN** `createChange(projectRoot, 'add-auth')` is called and that name already exists in proposed, approved, or legacy active changes +- **THEN** the system throws an error indicating the change already exists + +#### Scenario: Creates parent directories if needed +- **WHEN** `createChange(projectRoot, 'add-auth')` is called and the proposed parent directory does not exist +- **THEN** the system creates the full path including parent directories + +#### Scenario: Invalid change name rejected +- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name +- **THEN** the system throws a validation error + +#### Scenario: Cross-platform creation +- **WHEN** a change is created on Windows, macOS, or Linux +- **THEN** the proposed directory and returned artifact paths use the platform's native path handling diff --git a/openspec/changes/approved/organize-changes-by-approval/specs/cli-list/spec.md b/openspec/changes/approved/organize-changes-by-approval/specs/cli-list/spec.md new file mode 100644 index 0000000000..9d6e880ee6 --- /dev/null +++ b/openspec/changes/approved/organize-changes-by-approval/specs/cli-list/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Command Execution +The command SHALL scan and analyze either active changes or specs based on the selected mode. + +#### Scenario: Scanning for changes (default) +- **WHEN** `openspec list` is executed without flags +- **THEN** scan proposed, approved, and legacy active change directories in the selected planning root +- **AND** exclude archive, hidden directories, and the proposed and approved container directories themselves from results +- **AND** parse each change's `tasks.md` file to count task completion + +#### Scenario: Scanning for specs +- **WHEN** `openspec list --specs` is executed +- **THEN** scan the `openspec/specs/` directory for capabilities +- **AND** read each capability's `spec.md` +- **AND** parse requirements to compute requirement counts + +#### Scenario: Windows discovery +- **WHEN** changes exist in both proposed and approved on Windows +- **THEN** both are listed by their bare names with the same approval and progress semantics as on macOS and Linux diff --git a/openspec/changes/approved/organize-changes-by-approval/specs/openspec-conventions/spec.md b/openspec/changes/approved/organize-changes-by-approval/specs/openspec-conventions/spec.md new file mode 100644 index 0000000000..39bb42825f --- /dev/null +++ b/openspec/changes/approved/organize-changes-by-approval/specs/openspec-conventions/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Project Structure +An OpenSpec project SHALL maintain a consistent directory structure for specifications and changes, using native filesystem paths on Windows, macOS, and Linux. + +#### Scenario: Initializing project structure +- **WHEN** an OpenSpec project is initialized +- **THEN** its change organization SHALL have this structure: +```text +openspec/ +├── specs/ +│ └── / +│ ├── spec.md +│ └── design.md # Optional capability design +└── changes/ + ├── proposed/ # Awaiting approval + │ └── / + ├── approved/ # Approved for implementation + │ └── / + └── archive/ # Archived changes + └── YYYY-MM-DD-/ +``` +- **AND** change directories contain their schema's planning artifacts, including proposal, tasks, optional design, and delta specs for the default workflow +- **AND** project context and agent instruction files follow the existing initialization rules + +#### Scenario: Existing flat changes +- **WHEN** a project contains `changes//` from an earlier version +- **THEN** it remains a supported legacy location until explicitly moved by approval or archive diff --git a/openspec/changes/approved/organize-changes-by-approval/tasks.md b/openspec/changes/approved/organize-changes-by-approval/tasks.md new file mode 100644 index 0000000000..84f5c5c096 --- /dev/null +++ b/openspec/changes/approved/organize-changes-by-approval/tasks.md @@ -0,0 +1,15 @@ +## 1. Directory support + +- [x] 1.1 Share active-change discovery and resolution across CLI consumers; verify proposed, approved, flat, duplicate, and linked-path cases. +- [x] 1.2 Create proposed changes and preserve old directories on update; verify creation and mixed-layout command behavior. + +## 2. Agent workflow + +- [x] 2.1 Add concise instructions for agent-driven approval moves and refreshed paths; regenerate skills and verify rendered workflow coverage. + +## 3. Verification and documentation + +- [x] 3.1 Document the directory convention and add a changeset; verify examples against the built CLI. +- [x] 3.2 Run build, lint, focused tests, and end-to-end move/archive checks; include cross-platform path coverage and report Windows CI as pending if unavailable locally. + +Verification: build, lint, and strict change validation pass. Full-suite run: 4,426 passed; four outdated creation-path fixtures corrected, then all 375 tests in the affected suites passed. After enforcing approved-only archive, all 259 tests in the archive, store journey, and template suites passed. Windows CI has not run locally. Agent instructions were checked through generated-template parity and code review; conversational behavior was not evaluated across LLM providers. diff --git a/src/commands/change.ts b/src/commands/change.ts index fa13b8655f..8a56ecd217 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -1,3 +1,4 @@ +import { resolveChangeDir } from '../utils/change-directory.js'; import { promises as fs } from 'fs'; import path from 'path'; import chalk from 'chalk'; @@ -38,15 +39,6 @@ async function isDefinitelyMissing(target: string): Promise { .catch((error: NodeJS.ErrnoException) => error?.code === 'ENOENT'); } -/** - * A change is a directory directly under changes/. Rejecting anything else up - * front keeps a traversing name (`../..`) from reading a proposal outside the - * changes directory, and keeps the missing-proposal message honest. - */ -function isChangeDirectoryName(changesPath: string, changeDir: string): boolean { - return path.dirname(path.resolve(changeDir)) === path.resolve(changesPath); -} - /** One requirement of one delta spec, paired with its main-spec counterpart. */ interface RequirementDiff { capability: string; @@ -116,13 +108,9 @@ export class ChangeCommand { } } - const changeDir = path.join(changesPath, changeName); + const changeDir = resolveChangeDir(changesPath, changeName); const proposalPath = path.join(changeDir, 'proposal.md'); - if (!isChangeDirectoryName(changesPath, changeDir)) { - throw new Error(`Change "${changeName}" not found at ${proposalPath}`); - } - try { await fs.access(proposalPath); } catch { @@ -200,7 +188,7 @@ export class ChangeCommand { changeName: string, changesPath: string ): Promise<{ capabilities: string[]; results: RequirementDiff[] }> { - const specsDir = path.join(changesPath, changeName, 'specs'); + const specsDir = path.join(resolveChangeDir(changesPath, changeName), 'specs'); const mainSpecsDir = this.getSpecsPath(); // Same discovery ChangeParser uses, so a nested capability (specs//) @@ -432,7 +420,7 @@ export class ChangeCommand { if (options?.json) { const changeDetails = await Promise.all( changes.map(async (changeName) => { - const changeDir = path.join(changesPath, changeName); + const changeDir = resolveChangeDir(changesPath, changeName); const proposalPath = path.join(changeDir, 'proposal.md'); // Resolve task progress through the shared tracked-tasks helper so @@ -483,7 +471,7 @@ export class ChangeCommand { // Long format: id: title and minimal counts for (const changeName of sorted) { - const changeDir = path.join(changesPath, changeName); + const changeDir = resolveChangeDir(changesPath, changeName); const proposalPath = path.join(changeDir, 'proposal.md'); const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd()); const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : ''; @@ -531,10 +519,7 @@ export class ChangeCommand { } } - const changeDir = path.join(changesPath, changeName); - if (!isChangeDirectoryName(changesPath, changeDir)) { - throw new Error(`Change "${changeName}" not found at ${changeDir}`); - } + const changeDir = resolveChangeDir(changesPath, changeName); try { await fs.access(changeDir); } catch { diff --git a/src/commands/validate.ts b/src/commands/validate.ts index cf64a8d682..fa05aaeca1 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -456,7 +456,7 @@ export class ValidateCommand { for (const id of changeIds) { queue.push(async () => { const start = Date.now(); - const changeDir = path.join(root.changesDir, id); + const changeDir = resolveChangeDir(root.changesDir, id); const nestedReport = await this.nestedChangeReport(root, id); if (nestedReport) { return { diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index 0b0075ddbc..6f6070b84c 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -1,3 +1,4 @@ +import { activeChangeNames, resolveChangeDir } from '../../utils/change-directory.js'; /** * Shared Types and Utilities for Artifact Workflow Commands * @@ -149,16 +150,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 activeChangeNames(changesDir); } /** @@ -220,7 +212,7 @@ export async function validateChangeExists( } // Check directory existence directly - const changePath = path.join(changesDir, changeName); + const changePath = resolveChangeDir(changesDir, changeName); const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory(); if (!exists) { diff --git a/src/core/archive.ts b/src/core/archive.ts index 85c74d81ae..b4155f931b 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -1,3 +1,4 @@ +import { activeChangeNames, resolveChangeDir } from '../utils/change-directory.js'; import { constants, createReadStream, promises as fs } from 'fs'; import { createHash, randomUUID } from 'crypto'; import path from 'path'; @@ -162,19 +163,6 @@ async function decideSpecOutcome( return built.counts.removed > 0 ? 'retire' : 'write'; } -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 []; - } -} - export interface ArchiveOptions { yes?: boolean; skipSpecs?: boolean; @@ -1156,7 +1144,10 @@ export class ArchiveCommand { throw new ArchiveBlockedError('archive_change_name_invalid', changeNameProblem); } - const changeDir = path.join(changesDir, changeName); + const changeDir = resolveChangeDir(changesDir, changeName); + if (path.dirname(changeDir) === path.join(changesDir, 'proposed')) { + throw new ArchiveBlockedError('archive_change_unapproved', `Change '${changeName}' is still proposed. Approve or apply it before archiving.`); + } // Verify change exists try { @@ -1172,7 +1163,7 @@ export class ArchiveCommand { } } catch (error) { if (error instanceof ArchiveBlockedError) throw error; - const available = await listActiveChangeNames(changesDir); + const available = activeChangeNames(changesDir); throw new ArchiveBlockedError( 'archive_change_not_found', available.length > 0 @@ -2087,7 +2078,7 @@ export class ArchiveCommand { options: ArchiveOptions ): Promise { const { select } = await import('@inquirer/prompts'); - const changeDirs = await listActiveChangeNames(changesDir); + const changeDirs = activeChangeNames(changesDir); if (changeDirs.length === 0) { console.log('No active changes found.'); diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index e1363daaab..257565a3f4 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -1,3 +1,4 @@ +import { resolveChangeDir } from '../../utils/change-directory.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js'; @@ -269,7 +270,7 @@ export function loadChangeContext( options: LoadChangeContextOptions = {} ): ChangeContext { const changeDir = FileSystemUtils.canonicalizeExistingPath( - options.changeDir ?? path.join(projectRoot, 'openspec', 'changes', changeName) + options.changeDir ?? resolveChangeDir(path.join(projectRoot, 'openspec', 'changes'), changeName) ); const metadata = readChangeMetadata(changeDir, projectRoot) ?? undefined; diff --git a/src/core/init.ts b/src/core/init.ts index c61652be04..6352bc9db4 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -875,6 +875,8 @@ export class InitCommand { openspecPath, path.join(openspecPath, 'specs'), path.join(openspecPath, 'changes'), + path.join(openspecPath, 'changes', 'proposed'), + path.join(openspecPath, 'changes', 'approved'), path.join(openspecPath, 'changes', 'archive'), ]; diff --git a/src/core/list.ts b/src/core/list.ts index d8eeab9052..af10bb4fd1 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -1,5 +1,6 @@ import { promises as fs } from 'fs'; import path from 'path'; +import { activeChangeNames, resolveChangeDir } from '../utils/change-directory.js'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { readFileSync, type Dirent } from 'fs'; import { MarkdownParser } from './parsers/markdown-parser.js'; @@ -126,9 +127,10 @@ export class ListCommand { // Get all directories in changes (excluding archive) const entries = await readChangeDirectoryEntries(changesDir); - const changeDirs = entries + const topLevelChangeDirs = entries .filter(entry => entry.isDirectory() && entry.name !== 'archive') .map(entry => entry.name); + const changeDirs = activeChangeNames(changesDir); if (changeDirs.length === 0) { if (json) { @@ -145,14 +147,17 @@ export class ListCommand { // A directory that only wraps nested change directories is still listed - // hiding it would hide a real change whenever the probe is wrong - but it // is listed as what it is, so the nesting stops failing silently (#1846). - const nestedFindings = await findNestedChanges(changesDir, changeDirs); + const nestedFindings = await findNestedChanges( + changesDir, + topLevelChangeDirs.filter((name) => changeDirs.includes(name)) + ); const nestedByName = new Map( nestedFindings.map((finding) => [finding.name, finding]) ); for (const changeDir of changeDirs) { const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath); - const changePath = path.join(changesDir, changeDir); + const changePath = resolveChangeDir(changesDir, changeDir); const lastModified = await getLastModified(changePath); changes.push({ name: changeDir, diff --git a/src/core/planning-home.ts b/src/core/planning-home.ts index c27a8ccbe7..3344619966 100644 --- a/src/core/planning-home.ts +++ b/src/core/planning-home.ts @@ -1,3 +1,4 @@ +import { resolveChangeDir } from '../utils/change-directory.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -90,7 +91,7 @@ export function resolveCurrentPlanningHomeSync( } export function getChangeDir(planningHome: PlanningHome, changeName: string): string { - return FileSystemUtils.joinPath(planningHome.changesDir, changeName); + return resolveChangeDir(planningHome.changesDir, changeName); } export function formatChangeLocation(planningHome: PlanningHome, changeName: string): string { diff --git a/src/core/templates/workflows/store-selection.ts b/src/core/templates/workflows/store-selection.ts index dfa132c3e5..7d1c774dfe 100644 --- a/src/core/templates/workflows/store-selection.ts +++ b/src/core/templates/workflows/store-selection.ts @@ -4,4 +4,6 @@ * Interpolated into every workflow's instructions so generated skills * consistently teach how to target a registered store with `--store `. */ -export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store \` on the commands that read or write specs and changes (\`new change\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`, \`view\`). Once selected, treat \`--store \` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run \`openspec status --change "" --json --store ""\`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root.`; +export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store \` on the commands that read or write specs and changes (\`new change\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`, \`view\`). Once selected, treat \`--store \` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run \`openspec status --change "" --json --store ""\`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root. + +**Change directories:** New changes start in \`proposed/\`. When the user approves a finished plan in conversation or invokes apply for it, move its resolved \`changeRoot\` to \`/approved/\` before implementation, creating the parent if needed. Leave incomplete plans in place and report missing artifacts. Use your filesystem tools, preserve contents, and never overwrite another change. Stop if the move fails. If already approved, leave it there; after a move, refresh status and instructions for current paths. Approval alone does not request implementation. Archive approved changes, never proposed ones; completed flat changes retain their existing archive workflow. Existing flat changes remain usable; leave them in place until approved or applied.`; diff --git a/src/core/view.ts b/src/core/view.ts index e79c1905a7..d3e20ef52d 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -1,3 +1,4 @@ +import { activeChangeNames } from '../utils/change-directory.js'; import * as fs from 'fs'; import * as path from 'path'; import chalk from 'chalk'; @@ -94,22 +95,20 @@ 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 entries = activeChangeNames(changesDir); + + for (const name of entries) { + const progress = await getTaskProgressForChange(changesDir, name, path.dirname(openspecDir)); + + if (progress.total === 0) { + // No tasks defined yet - still in planning/draft phase + draft.push({ name }); + } else if (progress.completed === progress.total) { + // All tasks complete + completed.push({ name }); + } else { + // Has tasks but not all complete + active.push({ name, progress }); } } @@ -210,4 +209,4 @@ export class ViewCommand { return `[${filledBar}${emptyBar}]`; } -} \ No newline at end of file +} diff --git a/src/utils/change-directory.ts b/src/utils/change-directory.ts new file mode 100644 index 0000000000..95a149e592 --- /dev/null +++ b/src/utils/change-directory.ts @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { FileSystemUtils } from './file-system.js'; + +export const CHANGE_STAGES = ['proposed', 'approved']; + +// Older projects can have a change with the same name as a new container. +function isChange(dir: string): boolean { + return ['.openspec.yaml', 'proposal.md'].some(file => fs.existsSync(path.join(dir, file))); +} + +export function changeStageDir(changesDir: string, stage: string): string { + const dir = path.join(changesDir, stage); + if (isChange(dir)) throw new Error(`Rename the existing change at ${dir} before using it as a change container.`); + FileSystemUtils.assertPathWithin(changesDir, dir); + return dir; +} + +/** Enumerate active names, including pre-approval-layout changes. */ +export function activeChangeNames(changesDir: string): string[] { + const names: string[] = []; + for (const stage of ['', ...CHANGE_STAGES]) { + const dir = path.join(changesDir, stage); + if (stage && isChange(dir)) continue; + if (stage) FileSystemUtils.assertPathWithin(changesDir, dir); + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive') continue; + if (!stage && CHANGE_STAGES.includes(entry.name) && !isChange(path.join(dir, entry.name))) continue; + names.push(entry.name); + } + } + return [...new Set(names)].sort(); +} + +/** Resolve a bare name; keep the old missing-path behavior for callers. */ +export function resolveChangeDir(changesDir: string, name: string): string { + if (!name || name === '.' || name === '..' || /[/\\\0]/.test(name)) { + throw new Error(`Invalid change name '${name}'`); + } + const matches = ['', ...CHANGE_STAGES].flatMap(stage => { + const parent = path.join(changesDir, stage); + if (stage && isChange(parent)) return []; + const dir = path.join(parent, name); + if (!stage && CHANGE_STAGES.includes(name) && !isChange(dir)) return []; + return fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? [dir] : []; + }); + if (new Set(matches.map(dir => fs.realpathSync(dir))).size > 1) { + throw new Error(`Ambiguous change '${name}': ${matches.join(', ')}`); + } + const dir = matches[0] ?? path.join(changesDir, name); + if (!matches.length && CHANGE_STAGES.includes(name)) { + throw new Error(`Change '${name}' not found: '${name}' is a change container.`); + } + if (matches.length && path.dirname(dir) !== changesDir) { + FileSystemUtils.assertPathWithin(changesDir, path.dirname(dir)); + } + return dir; +} diff --git a/src/utils/change-metadata.ts b/src/utils/change-metadata.ts index 1f31a44596..7814714615 100644 --- a/src/utils/change-metadata.ts +++ b/src/utils/change-metadata.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as yaml from 'yaml'; +import { findRepoPlanningRootSync } from '../core/planning-home.js'; import { ChangeMetadataSchema, type ChangeMetadata } from '../core/change-metadata/index.js'; import { listSchemas, resolveSchema } from '../core/artifact-graph/resolver.js'; import { readProjectConfig, type ProjectConfig } from '../core/project-config.js'; @@ -171,8 +172,7 @@ export function resolveSchemaForChange( projectRootOverride?: string, options: ResolveSchemaForChangeOptions = {} ): string { - // Derive project root from changeDir (changeDir is typically projectRoot/openspec/changes/change-name) - const projectRoot = projectRootOverride ?? path.resolve(changeDir, '../../..'); + const projectRoot = projectRootOverride ?? findRepoPlanningRootSync(path.dirname(changeDir)) ?? path.resolve(changeDir, '../../..'); // 1. Explicit override wins if (explicitSchema) { @@ -314,7 +314,7 @@ function readBooleanMarker( // resolveSchema alone would normalize and accept); resolveSchema then // proves the schema actually parses. Any failure fails closed. try { - const projectRoot = path.resolve(changeDir, '../../..'); + const projectRoot = findRepoPlanningRootSync(path.dirname(changeDir)) ?? path.resolve(changeDir, '../../..'); if (!listSchemas(projectRoot).includes(result.data.schema)) { return unhonorable(`schema: unknown schema '${result.data.schema}'`); } diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index 803405953e..bc542d73a3 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -1,3 +1,4 @@ +import { changeStageDir, resolveChangeDir } from './change-directory.js'; import path from 'path'; import { FileSystemUtils } from './file-system.js'; import { writeChangeMetadata, validateSchemaName } from './change-metadata.js'; @@ -119,12 +120,12 @@ export function validateChangeName(name: string): ValidationResult { * @returns Result containing the resolved schema name * * @example - * // Creates openspec/changes/add-auth/ with default schema + * // Creates openspec/changes/proposed/add-auth/ with default schema * const result = await createChange('/path/to/project', 'add-auth') * console.log(result.schema) // 'spec-driven' or value from config * * @example - * // Creates openspec/changes/add-auth/ with custom schema + * // Creates openspec/changes/proposed/add-auth/ with custom schema * const result = await createChange('/path/to/project', 'add-auth', { schema: 'my-workflow' }) * console.log(result.schema) // 'my-workflow' */ @@ -160,11 +161,13 @@ export async function createChange( validateSchemaName(schemaName, projectRoot); // Build the change directory path - const changeDir = path.join(options.changesDir ?? path.join(projectRoot, 'openspec', 'changes'), name); + const changesDir = options.changesDir ?? path.join(projectRoot, 'openspec', 'changes'); + const existingDir = resolveChangeDir(changesDir, name); + const changeDir = path.join(changeStageDir(changesDir, 'proposed'), name); // Check if change already exists - if (await FileSystemUtils.directoryExists(changeDir)) { - throw new Error(`Change '${name}' already exists at ${changeDir}`); + if (await FileSystemUtils.directoryExists(existingDir)) { + throw new Error(`Change '${name}' already exists at ${existingDir}`); } const schema = resolveSchema(schemaName, projectRoot); diff --git a/src/utils/item-discovery.ts b/src/utils/item-discovery.ts index 65d5b45fab..90f3ada8d7 100644 --- a/src/utils/item-discovery.ts +++ b/src/utils/item-discovery.ts @@ -1,3 +1,4 @@ +import { activeChangeNames } from './change-directory.js'; import { promises as fs } from 'fs'; import path from 'path'; import { discoverSpecFiles } from './spec-discovery.js'; @@ -14,16 +15,7 @@ import { discoverSpecFiles } from './spec-discovery.js'; * all (#1161). */ 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 }); - return entries - .filter((entry) => entry.isDirectory() && entry.name !== 'archive' && !entry.name.startsWith('.')) - .map((entry) => entry.name) - .sort(); - } catch { - return []; - } + return activeChangeNames(path.join(root, 'openspec', 'changes')); } export async function getSpecIds(root: string = process.cwd()): Promise { @@ -52,4 +44,3 @@ export async function getArchivedChangeIds(root: string = process.cwd()): Promis return []; } } - diff --git a/src/utils/task-progress.ts b/src/utils/task-progress.ts index 37873aabaa..5a3fa22482 100644 --- a/src/utils/task-progress.ts +++ b/src/utils/task-progress.ts @@ -1,3 +1,4 @@ +import { resolveChangeDir } from './change-directory.js'; import { promises as fs } from 'fs'; import path from 'path'; import type { Artifact, SchemaYaml } from '../core/artifact-graph/index.js'; @@ -203,7 +204,7 @@ export async function getTaskProgressDetailForChange( projectRoot: string, schemaGlobCache?: SchemaGlobCache ): Promise { - const changeDir = path.join(changesDir, changeName); + const changeDir = resolveChangeDir(changesDir, changeName); const files = resolveTaskFilesForChange(changeDir, projectRoot, schemaGlobCache); const targets = files.length > 0 ? files : [path.join(changeDir, 'tasks.md')]; const unreadable: string[] = []; @@ -243,4 +244,3 @@ export function formatTaskStatus(progress: TaskProgress): string { return `${progress.completed}/${progress.total} tasks`; } - diff --git a/test/cli-e2e/capstone-journeys.test.ts b/test/cli-e2e/capstone-journeys.test.ts index adc4271a73..c79b5e2a4b 100644 --- a/test/cli-e2e/capstone-journeys.test.ts +++ b/test/cli-e2e/capstone-journeys.test.ts @@ -90,6 +90,7 @@ describe('capstone persona journeys (6.1)', () => { appRepo, 'openspec', 'changes', + 'proposed', 'implement-invoice-immutability' ); expect(fs.existsSync(changeDir)).toBe(true); @@ -121,7 +122,7 @@ describe('capstone persona journeys (6.1)', () => { { cwd: codeRepo, env } ); expect(created.exitCode).toBe(0); - const changeDir = path.join(storeRoot, 'openspec', 'changes', 'add-rate-limits'); + const changeDir = path.join(storeRoot, 'openspec', 'changes', 'proposed', 'add-rate-limits'); expect(fs.existsSync(changeDir)).toBe(true); const status = await runCLI(['status', '--change', 'add-rate-limits', '--json'], { @@ -164,6 +165,9 @@ describe('capstone persona journeys (6.1)', () => { const writtenArtifacts = fs.readdirSync(changeDir).sort(); expect(writtenArtifacts).toEqual(['.openspec.yaml', 'design.md', 'proposal.md', 'specs', 'tasks.md']); + const approvedDir = path.join(storeRoot, 'openspec', 'changes', 'approved', 'add-rate-limits'); + fs.mkdirSync(path.dirname(approvedDir), { recursive: true }); + fs.renameSync(changeDir, approvedDir); // Archive completes the lifecycle, still without --store. const archived = await runCLI( ['archive', 'add-rate-limits', '--yes', '--skip-specs', '--json'], diff --git a/test/cli-e2e/store-lifecycle.test.ts b/test/cli-e2e/store-lifecycle.test.ts index 39c79da9d8..2ffed3ddb2 100644 --- a/test/cli-e2e/store-lifecycle.test.ts +++ b/test/cli-e2e/store-lifecycle.test.ts @@ -293,13 +293,24 @@ describe('standalone store lifecycle journey', () => { ); expect(instructions.exitCode).toBe(0); expect(instructions.stdout).toContain( - path.join(canonical(storeRoot), 'openspec', 'changes', changeId, 'proposal.md') + path.join(canonical(storeRoot), 'openspec', 'changes', 'proposed', changeId, 'proposal.md') ); // The test acts as the agent and writes the artifacts. - const changeDir = path.join(storeRoot, 'openspec', 'changes', changeId); + const changeDir = path.join(storeRoot, 'openspec', 'changes', 'proposed', changeId); await writeCompletedChangeArtifacts(changeDir, 'billing'); + // The agent records approval with a filesystem move, not a CLI transition. + const approvedDir = path.join(storeRoot, 'openspec', 'changes', 'approved', changeId); + await fs.mkdir(path.dirname(approvedDir), { recursive: true }); + await fs.rename(changeDir, approvedDir); + const apply = await runCLI( + ['instructions', 'apply', '--change', changeId, '--store', STORE_ID, '--json'], + { env: machineA, cwd: projectDir } + ); + expect(apply.exitCode).toBe(0); + expect(JSON.parse(apply.stdout).contextFiles.proposal).toEqual([path.join(canonical(approvedDir), 'proposal.md')]); + const validated = await runCLI( ['validate', changeId, '--store', STORE_ID], { env: machineA, cwd: projectDir } @@ -412,11 +423,14 @@ describe('standalone store lifecycle journey', () => { ); expect(instructions.exitCode).toBe(0); expect(instructions.stdout).toContain( - path.join(canonical(cloneRoot), 'openspec', 'changes', changeId, 'proposal.md') + path.join(canonical(cloneRoot), 'openspec', 'changes', 'proposed', changeId, 'proposal.md') ); - const changeDir = path.join(cloneRoot, 'openspec', 'changes', changeId); + const changeDir = path.join(cloneRoot, 'openspec', 'changes', 'proposed', changeId); await writeCompletedChangeArtifacts(changeDir, 'invoicing'); + const approvedDir = path.join(cloneRoot, 'openspec', 'changes', 'approved', changeId); + await fs.mkdir(path.dirname(approvedDir), { recursive: true }); + await fs.rename(changeDir, approvedDir); const status = await runCLI( ['status', '--change', changeId, '--store', STORE_ID], diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 0658d2beb0..377f1b456a 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -583,7 +583,7 @@ describe('artifact-workflow CLI commands', () => { const output = getOutput(result); expect(output).toContain("Created change 'my-new-feature'"); - const changeDir = path.join(changesDir, 'my-new-feature'); + const changeDir = path.join(changesDir, 'proposed', 'my-new-feature'); const stat = await fs.stat(changeDir); expect(stat.isDirectory()).toBe(true); @@ -625,7 +625,7 @@ apply: expect(result.exitCode).toBe(0); const metadata = await fs.readFile( - path.join(changesDir, 'no-spec-change', '.openspec.yaml'), + path.join(changesDir, 'proposed', 'no-spec-change', '.openspec.yaml'), 'utf-8' ); expect(metadata).toContain('skip_specs: true'); @@ -634,7 +634,7 @@ apply: ['validate', 'no-spec-change', '--type', 'change'], { cwd: tempDir } ); - expect(validation.exitCode).toBe(0); + expect(validation.exitCode, getOutput(validation)).toBe(0); }); it('does not mark spec-producing schemas that use Windows separators', async () => { @@ -664,7 +664,7 @@ artifacts: const result = await runCLI(['new', 'change', changeName], { cwd: tempDir }); expect(result.exitCode).toBe(0); - const changeDir = path.join(changesDir, changeName); + const changeDir = path.join(changesDir, 'proposed', changeName); const metadata = await fs.readFile(path.join(changeDir, '.openspec.yaml'), 'utf-8'); expect(metadata).not.toContain('skip_specs'); @@ -702,7 +702,7 @@ The system SHALL support the example behavior. expect(result.exitCode).toBe(1); const output = getOutput(result); expect(output).toContain('--initiative is no longer supported'); - await expect(fs.stat(path.join(changesDir, 'linked-change'))).rejects.toMatchObject({ + await expect(fs.stat(path.join(changesDir, 'proposed', 'linked-change'))).rejects.toMatchObject({ code: 'ENOENT', }); }); @@ -714,7 +714,7 @@ The system SHALL support the example behavior. expect(result.exitCode).toBe(1); const output = getOutput(result); expect(output).toContain('--areas is no longer supported'); - await expect(fs.stat(path.join(changesDir, 'area-change'))).rejects.toMatchObject({ + await expect(fs.stat(path.join(changesDir, 'proposed', 'area-change'))).rejects.toMatchObject({ code: 'ENOENT', }); }); @@ -727,7 +727,7 @@ The system SHALL support the example behavior. expect(result.exitCode).toBe(0); const metadata = await fs.readFile( - path.join(changesDir, 'goal-change', '.openspec.yaml'), + path.join(changesDir, 'proposed', 'goal-change', '.openspec.yaml'), 'utf-8' ); expect(metadata).toContain('schema: spec-driven'); @@ -743,7 +743,7 @@ The system SHALL support the example behavior. ); expect(result.exitCode).toBe(0); - const readmePath = path.join(changesDir, 'described-feature', 'README.md'); + const readmePath = path.join(changesDir, 'proposed', 'described-feature', 'README.md'); const content = await fs.readFile(readmePath, 'utf-8'); expect(content).toContain('described-feature'); expect(content).toContain('This is a test feature'); @@ -1506,7 +1506,7 @@ operations: expect(result.exitCode).toBe(0); // Verify the change was created with spec-driven schema - const metadataPath = path.join(changesDir, 'test-change', '.openspec.yaml'); + const metadataPath = path.join(changesDir, 'proposed', 'test-change', '.openspec.yaml'); const metadata = await fs.readFile(metadataPath, 'utf-8'); expect(metadata).toContain('schema: spec-driven'); }, 60000); @@ -1527,7 +1527,7 @@ operations: expect(result.exitCode).toBe(0); // Verify the change uses the CLI-specified schema - const metadataPath = path.join(changesDir, 'override-test', '.openspec.yaml'); + const metadataPath = path.join(changesDir, 'proposed', 'override-test', '.openspec.yaml'); const metadata = await fs.readFile(metadataPath, 'utf-8'); expect(metadata).toContain('schema: spec-driven'); }, 60000); diff --git a/test/commands/change-initiative-link.test.ts b/test/commands/change-initiative-link.test.ts index c1a7797b57..df0006ab83 100644 --- a/test/commands/change-initiative-link.test.ts +++ b/test/commands/change-initiative-link.test.ts @@ -97,7 +97,8 @@ describe('legacy repo-local change initiative metadata', () => { const json = parseJson(result); expect(json.initiative).toBeUndefined(); - const metadata = readChangeMetadata(changeDir('fresh-change'), tempDir); + const metadata = readChangeMetadata(json.change.path, tempDir); + expect(metadata).toBeDefined(); expect(metadata?.initiative).toBeUndefined(); }); diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 0b0905c014..4b8f12c727 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -321,10 +321,10 @@ describe('openspec context (4.1)', () => { expect(root.store_id).toBe(selectedRoot === storeRoot ? 'team-context' : undefined); } expect(parseJson(instructions).context).toBe(expectedContext); - expect(fs.existsSync(path.join(selectedRoot, 'openspec', 'changes', 'add-auth', '.openspec.yaml'))).toBe(true); + expect(fs.existsSync(path.join(selectedRoot, 'openspec', 'changes', 'proposed', 'add-auth', '.openspec.yaml'))).toBe(true); expect(fs.existsSync(path.join(cwd, 'openspec'))).toBe(false); if (selectedRoot !== project) { - expect(fs.existsSync(path.join(project, 'openspec', 'changes', 'add-auth'))).toBe(false); + expect(fs.existsSync(path.join(project, 'openspec', 'changes', 'proposed', 'add-auth'))).toBe(false); } }, CONTEXT_MATRIX_TIMEOUT_MS diff --git a/test/commands/declared-store-fallback.test.ts b/test/commands/declared-store-fallback.test.ts index 8eb6b50907..48599284e6 100644 --- a/test/commands/declared-store-fallback.test.ts +++ b/test/commands/declared-store-fallback.test.ts @@ -90,7 +90,7 @@ describe('declared store fallback (3.2)', () => { ); expect(instructions.exitCode).toBe(0); - const changeDir = path.join(storeRoot, 'openspec', 'changes', 'billing-rework'); + const changeDir = path.join(storeRoot, 'openspec', 'changes', 'proposed', 'billing-rework'); fs.writeFileSync( path.join(changeDir, 'proposal.md'), '## Why\n\nBilling rework.\n\n## What Changes\n\n- **billing:** Rework billing\n' @@ -117,6 +117,9 @@ describe('declared store fallback (3.2)', () => { }); expect(show.exitCode).toBe(0); + const approvedDir = path.join(storeRoot, 'openspec', 'changes', 'approved', 'billing-rework'); + fs.mkdirSync(path.dirname(approvedDir), { recursive: true }); + fs.renameSync(changeDir, approvedDir); const archive = await runCLI(['archive', 'billing-rework', '--yes', '--json'], { cwd: pointerRepo, env, diff --git a/test/commands/schema.test.ts b/test/commands/schema.test.ts index 5bd0e73231..ebcbdc191f 100644 --- a/test/commands/schema.test.ts +++ b/test/commands/schema.test.ts @@ -481,7 +481,7 @@ artifacts: expect(created.exitCode).toBe(0); expect( fs.readFileSync( - path.join(tempDir, 'openspec', 'changes', 'uses-default', '.openspec.yaml'), + path.join(tempDir, 'openspec', 'changes', 'proposed', 'uses-default', '.openspec.yaml'), 'utf-8' ) ).toContain('schema: my-workflow'); diff --git a/test/commands/store-references.test.ts b/test/commands/store-references.test.ts index a93a103b0d..a3646d1935 100644 --- a/test/commands/store-references.test.ts +++ b/test/commands/store-references.test.ts @@ -238,7 +238,7 @@ describe('store references in instructions (3.1)', () => { expect(fetchResult.stdout).toContain('Usage-based invoicing.'); // The design lands in the app repo's own root, citing the store spec. - const changeDir = path.join(appRepo, 'openspec', 'changes', 'billing-rework'); + const changeDir = path.join(appRepo, 'openspec', 'changes', 'proposed', 'billing-rework'); fs.writeFileSync( path.join(changeDir, 'proposal.md'), '## Why\n\nDerives from team-context/billing (see referenced stores).\n\n## What Changes\n\n- **invoicing:** Rework invoicing\n' diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index f4a818d85c..981eee21da 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -200,11 +200,11 @@ describe('store root selection for normal commands', () => { expect(result.stderr).toContain(`Using OpenSpec root: team-context (${storeRoot})`); expect(result.stdout).toContain("Created change 'add-billing'"); expect(result.stdout).toContain( - path.join(storeRoot, 'openspec', 'changes', 'add-billing') + path.join(storeRoot, 'openspec', 'changes', 'proposed', 'add-billing') ); expect( - fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'add-billing')) + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'proposed', 'add-billing')) ).toBe(true); expectNoLocalOpenSpec(); }); @@ -224,7 +224,7 @@ describe('store root selection for normal commands', () => { }); expect(path.isAbsolute(json.change.path)).toBe(true); expect(json.change.path).toBe( - path.join(storeRoot, 'openspec', 'changes', 'add-billing') + path.join(storeRoot, 'openspec', 'changes', 'proposed', 'add-billing') ); expectNoLocalOpenSpec(); }); diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 3ad143aa78..e0888a1ec3 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -70,6 +70,17 @@ describe('ArchiveCommand', () => { archiveCommand = new ArchiveCommand(); }); + it('refuses proposed changes even when task completion and validation are bypassed', async () => { + const proposed = path.join(tempDir, 'openspec', 'changes', 'proposed', 'example'); + await fs.mkdir(proposed, { recursive: true }); + await fs.writeFile(path.join(proposed, 'tasks.md'), '- [x] Finished\n'); + await archiveCommand.execute('example', { json: true, yes: true, noValidate: true, skipSpecs: true }); + expect(process.exitCode).toBe(1); + expect(JSON.stringify(vi.mocked(console.log).mock.calls)).toContain('archive_change_unapproved'); + expect(await fs.readFile(path.join(proposed, 'tasks.md'), 'utf8')).toBe('- [x] Finished\n'); + expect(await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive'))).toEqual([]); + }); + afterEach(async () => { vi.useRealTimers(); diff --git a/test/core/commands/change-command.show-validate.test.ts b/test/core/commands/change-command.show-validate.test.ts index 15053d1d68..bcb3da57e5 100644 --- a/test/core/commands/change-command.show-validate.test.ts +++ b/test/core/commands/change-command.show-validate.test.ts @@ -112,7 +112,7 @@ describe('ChangeCommand.show/validate', () => { await fs.writeFile(path.join(tempRoot, 'proposal.md'), '# Outside the changes directory', 'utf-8'); const traversal = path.join('..', '..'); - await expect(cmd.show(traversal, { json: false })).rejects.toThrow(/not found at/); + await expect(cmd.show(traversal, { json: false })).rejects.toThrow(/Invalid change name/); await expect(cmd.show(traversal, { json: false })).rejects.not.toThrow(/has no proposal\.md yet/); }); @@ -160,7 +160,7 @@ describe('ChangeCommand.show/validate', () => { const nested = path.join('sample-change', 'specs'); await fs.mkdir(path.join(tempRoot, 'openspec', 'changes', 'sample-change', 'specs'), { recursive: true }); - await expect(cmd.show(nested, { json: false })).rejects.toThrow(/not found at/); + await expect(cmd.show(nested, { json: false })).rejects.toThrow(/Invalid change name/); await expect(cmd.show(nested, { json: false })).rejects.not.toThrow(/has no proposal\.md yet/); }); }); @@ -186,7 +186,7 @@ describe('ChangeCommand.show/validate', () => { }); it('validate rejects a traversing change name', async () => { - await expect(cmd.validate(path.join('..', '..', 'outside'))).rejects.toThrow(/not found at/u); + await expect(cmd.validate(path.join('..', '..', 'outside'))).rejects.toThrow(/Invalid change name/u); }); }); diff --git a/test/utils/change-directory.test.ts b/test/utils/change-directory.test.ts new file mode 100644 index 0000000000..ddfb424193 --- /dev/null +++ b/test/utils/change-directory.test.ts @@ -0,0 +1,85 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { activeChangeNames, changeStageDir, resolveChangeDir } from '../../src/utils/change-directory.js'; +import { createChange } from '../../src/utils/change-utils.js'; +import { getTaskProgressForChange } from '../../src/utils/task-progress.js'; +import { ListCommand } from '../../src/core/list.js'; +import { ChangeCommand } from '../../src/commands/change.js'; +import { getAvailableChanges, validateChangeExists } from '../../src/commands/workflow/shared.js'; + +describe('change directories', () => { + let root: string; + let changes: string; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec approval ')); + changes = path.join(root, 'openspec', 'changes'); + }); + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('keeps commands and artifacts usable across an agent move', async () => { + const { changeDir } = await createChange(root, 'example'); + expect(changeDir).toBe(path.join(changes, 'proposed', 'example')); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Example\n'); + fs.writeFileSync(path.join(changeDir, 'tasks.md'), '- [ ] Work\n'); + fs.mkdirSync(path.join(changes, 'old-change')); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + for (const stage of ['proposed', 'approved']) { + if (stage === 'approved') { + fs.mkdirSync(path.join(changes, stage)); + fs.renameSync(changeDir, path.join(changes, stage, 'example')); + } + expect(resolveChangeDir(changes, 'example')).toBe(path.join(changes, stage, 'example')); + expect(await getAvailableChanges(root)).toEqual(['example', 'old-change']); + expect(await validateChangeExists('example', root)).toBe('example'); + expect(await getTaskProgressForChange(changes, 'example', root)).toEqual({ total: 1, completed: 0 }); + await new ListCommand().execute(root, 'changes', { json: true }); + expect(JSON.parse(log.mock.calls.at(-1)![0]).changes.map((c: { name: string }) => c.name).sort()).toEqual(['example', 'old-change']); + await new ChangeCommand(root).show('example'); + expect(log).toHaveBeenLastCalledWith('# Example\n'); + await expect(createChange(root, 'example')).rejects.toThrow('already exists'); + } + expect(fs.existsSync(path.join(changes, 'old-change'))).toBe(true); + }); + + it('refuses ambiguous names instead of selecting one', async () => { + await createChange(root, 'example'); + fs.mkdirSync(path.join(changes, 'example')); + expect(() => resolveChangeDir(changes, 'example')).toThrow('Ambiguous change'); + }); + + it('preserves an old change named like a container', () => { + const old = path.join(changes, 'proposed'); + fs.mkdirSync(old, { recursive: true }); + fs.writeFileSync(path.join(old, '.openspec.yaml'), 'schema: spec-driven\n'); + expect(activeChangeNames(changes)).toEqual(['proposed']); + expect(resolveChangeDir(changes, 'proposed')).toBe(old); + expect(() => changeStageDir(changes, 'proposed')).toThrow('Rename the existing change'); + }); + + it('never resolves a container as a change', async () => { + await createChange(root, 'example'); + expect(() => resolveChangeDir(changes, 'proposed')).toThrow('change container'); + await expect(validateChangeExists('proposed', root)).rejects.toThrow('change container'); + expect(fs.existsSync(path.join(changes, 'proposed', 'example'))).toBe(true); + }); + + it('resolves an alias to the same change without ambiguity', async () => { + const { changeDir } = await createChange(root, 'example'); + fs.symlinkSync(changeDir, path.join(changes, 'example'), process.platform === 'win32' ? 'junction' : 'dir'); + expect(fs.realpathSync.native(resolveChangeDir(changes, 'example'))).toBe(fs.realpathSync.native(changeDir)); + }); + + it('reports an unreadable container', () => { + vi.spyOn(fs, 'readdirSync').mockImplementation(() => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); }); + expect(() => activeChangeNames(changes)).toThrow('denied'); + }); + + it.each(['../outside', '..\\outside', '.', '..'])('rejects unsafe name %s', name => { + expect(() => resolveChangeDir(changes, name)).toThrow('Invalid change name'); + }); +}); diff --git a/test/utils/change-utils.test.ts b/test/utils/change-utils.test.ts index 4f32914aa6..2487703296 100644 --- a/test/utils/change-utils.test.ts +++ b/test/utils/change-utils.test.ts @@ -158,7 +158,7 @@ describe('createChange', () => { it('should create change directory', async () => { await createChange(testDir, 'add-auth'); - const changeDir = path.join(testDir, 'openspec', 'changes', 'add-auth'); + const changeDir = path.join(testDir, 'openspec', 'changes', 'proposed', 'add-auth'); const stats = await fs.stat(changeDir); expect(stats.isDirectory()).toBe(true); }); @@ -166,7 +166,7 @@ describe('createChange', () => { it('should create a numeric-prefixed change directory (#850, #1169)', async () => { await createChange(testDir, '100-add-feature'); - const changeDir = path.join(testDir, 'openspec', 'changes', '100-add-feature'); + const changeDir = path.join(testDir, 'openspec', 'changes', 'proposed', '100-add-feature'); const stats = await fs.stat(changeDir); expect(stats.isDirectory()).toBe(true); }); @@ -174,7 +174,7 @@ describe('createChange', () => { it('should create .openspec.yaml metadata file with default schema', async () => { await createChange(testDir, 'add-auth'); - const metaPath = path.join(testDir, 'openspec', 'changes', 'add-auth', '.openspec.yaml'); + const metaPath = path.join(testDir, 'openspec', 'changes', 'proposed', 'add-auth', '.openspec.yaml'); const content = await fs.readFile(metaPath, 'utf-8'); expect(content).toContain('schema: spec-driven'); expect(content).toMatch(/created: \d{4}-\d{2}-\d{2}/); @@ -187,7 +187,7 @@ describe('createChange', () => { await createChange(testDir, 'local-date-change'); - const metaPath = path.join(testDir, 'openspec', 'changes', 'local-date-change', '.openspec.yaml'); + const metaPath = path.join(testDir, 'openspec', 'changes', 'proposed', 'local-date-change', '.openspec.yaml'); const content = await fs.readFile(metaPath, 'utf-8'); expect(content).toContain('created: 2026-07-15'); }); @@ -199,7 +199,7 @@ describe('createChange', () => { await createChange(testDir, 'same-date-change'); - const metaPath = path.join(testDir, 'openspec', 'changes', 'same-date-change', '.openspec.yaml'); + const metaPath = path.join(testDir, 'openspec', 'changes', 'proposed', 'same-date-change', '.openspec.yaml'); const content = await fs.readFile(metaPath, 'utf-8'); expect(content).toContain('created: 2026-01-05'); }); @@ -207,7 +207,7 @@ describe('createChange', () => { it('should create .openspec.yaml with custom schema', async () => { await createChange(testDir, 'add-auth', { schema: 'spec-driven' }); - const metaPath = path.join(testDir, 'openspec', 'changes', 'add-auth', '.openspec.yaml'); + const metaPath = path.join(testDir, 'openspec', 'changes', 'proposed', 'add-auth', '.openspec.yaml'); const content = await fs.readFile(metaPath, 'utf-8'); expect(content).toContain('schema: spec-driven'); }); @@ -259,7 +259,7 @@ describe('createChange', () => { // openspec/changes/ does not exist yet await createChange(newProjectDir, 'add-auth'); - const changeDir = path.join(newProjectDir, 'openspec', 'changes', 'add-auth'); + const changeDir = path.join(newProjectDir, 'openspec', 'changes', 'proposed', 'add-auth'); const stats = await fs.stat(changeDir); expect(stats.isDirectory()).toBe(true); }); From eb3fb4a66b3f96d4c9eee84ef220b5715178a2bd Mon Sep 17 00:00:00 2001 From: Alex <45095641+runsonmypc@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:00:04 +0200 Subject: [PATCH 2/7] refactor(prompts): condense change approval guidance --- src/core/templates/workflows/store-selection.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/templates/workflows/store-selection.ts b/src/core/templates/workflows/store-selection.ts index 7d1c774dfe..b680c1a204 100644 --- a/src/core/templates/workflows/store-selection.ts +++ b/src/core/templates/workflows/store-selection.ts @@ -5,5 +5,4 @@ * consistently teach how to target a registered store with `--store `. */ export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store \` on the commands that read or write specs and changes (\`new change\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`, \`view\`). Once selected, treat \`--store \` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run \`openspec status --change "" --json --store ""\`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root. - -**Change directories:** New changes start in \`proposed/\`. When the user approves a finished plan in conversation or invokes apply for it, move its resolved \`changeRoot\` to \`/approved/\` before implementation, creating the parent if needed. Leave incomplete plans in place and report missing artifacts. Use your filesystem tools, preserve contents, and never overwrite another change. Stop if the move fails. If already approved, leave it there; after a move, refresh status and instructions for current paths. Approval alone does not request implementation. Archive approved changes, never proposed ones; completed flat changes retain their existing archive workflow. Existing flat changes remain usable; leave them in place until approved or applied.`; +When the user approves a finished plan in chat or invokes apply, move its \`changeRoot\` into \`/approved/\` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change.`; From 29ca633cd0d6fa78f2bff3082d46e4c50ac26196 Mon Sep 17 00:00:00 2001 From: Alex <45095641+runsonmypc@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:02:15 +0200 Subject: [PATCH 3/7] docs: remove change directory aside from README --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index c7cfb7b710..480f887fc5 100644 --- a/README.md +++ b/README.md @@ -217,8 +217,6 @@ Run this inside each project to regenerate AI guidance and ensure the latest sla openspec update ``` -Existing change folders stay where they are. See [change directories](docs/concepts.md#change-directories) for the proposed/approved convention. - ## Usage Notes **Model selection**: OpenSpec works best with high-reasoning models. We recommend Codex 5.5 and Opus 4.7 for both planning and implementation. From c8342e3f55eff5c7c2e97c9e47eb0036a21ed167 Mon Sep 17 00:00:00 2001 From: Alex <45095641+runsonmypc@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:23:00 +0200 Subject: [PATCH 4/7] refactor(changes): simplify proposed directory helper and planning text --- .../approved/organize-changes-by-approval/design.md | 4 ++-- .../approved/organize-changes-by-approval/proposal.md | 10 ---------- src/utils/change-directory.ts | 6 +++--- src/utils/change-utils.ts | 4 ++-- test/utils/change-directory.test.ts | 4 ++-- 5 files changed, 9 insertions(+), 19 deletions(-) diff --git a/openspec/changes/approved/organize-changes-by-approval/design.md b/openspec/changes/approved/organize-changes-by-approval/design.md index 34b5a05ded..75572fffd5 100644 --- a/openspec/changes/approved/organize-changes-by-approval/design.md +++ b/openspec/changes/approved/organize-changes-by-approval/design.md @@ -1,6 +1,6 @@ ## Context -New changes belong in proposed; user approval or apply moves them into approved. Existing flat changes stay readable. See proposal.md for upstream discussion and contribution guidance. +New changes belong in proposed; user approval or apply moves them into approved. Existing flat changes stay readable. ## Goals / Non-Goals @@ -19,4 +19,4 @@ Bare names can collide across directories after a merge; report the conflicting ## Migration Plan -No migration. Ship lookup, creation, and workflow instructions together. The user authorized this local implementation; reconcile with #1367 and #1818 before proposing it upstream. +No migration. Ship lookup, creation, and workflow instructions together. diff --git a/openspec/changes/approved/organize-changes-by-approval/proposal.md b/openspec/changes/approved/organize-changes-by-approval/proposal.md index 9ee9c52151..175a00f3f9 100644 --- a/openspec/changes/approved/organize-changes-by-approval/proposal.md +++ b/openspec/changes/approved/organize-changes-by-approval/proposal.md @@ -26,13 +26,3 @@ Shared change directories mix ideas awaiting review with plans approved for impl ## Impact Change creation and discovery, planning-home resolution, commands that read or archive changes, generated workflow instructions, shell completion, and user documentation need consistent paths. No new dependency or metadata state machine is needed. - -Related upstream work: [#1683](https://github.com/Fission-AI/OpenSpec/issues/1683) proposes lifecycle metadata; its [implementation #1684](https://github.com/Fission-AI/OpenSpec/pull/1684) was closed unmerged. [#1818](https://github.com/Fission-AI/OpenSpec/issues/1818) proposes draft changes. This proposal uses the requested directory distinction and leaves shipped state and sync lifecycle changes outside its scope. - -## Contribution alignment - -The repository's [contribution guidance](../../../../README.md#contributing) asks for an OpenSpec proposal and alignment on intent before implementing new features. The user has authorized a minimal local implementation; upstream acceptance remains a separate decision. It must work across supported agents, schemas, platforms, and stores. Any eventual implementation PR should disclose the coding agent/model, include verified tests, and use conventional commit subjects. - -[#1684 was closed by its author](https://github.com/Fission-AI/OpenSpec/pull/1684#issuecomment-5568728429), who concluded that OpenSpec's coupling of specs and decision records did not fit their team's PR workflows. It was not a maintainer rejection of this proposed approval layout. In the [preceding design review](https://github.com/Fission-AI/OpenSpec/pull/1684#issuecomment-5427198908), the maintainer requested a smaller scope, opposed two lifecycle models, identified correctness/data-loss issues, and noted missing docs and agent workflow updates. - -That review explicitly directs layout work to [open PR #1367](https://github.com/Fission-AI/OpenSpec/pull/1367), which introduces nested domains and relocates archives. Before upstream submission, seek agreement on fixed approval directories and their relationship to #1367 and draft issue #1818. The proposed approach keeps existing archive semantics, avoids lifecycle configuration and bulk migration, and includes all generated workflow surfaces. These scope choices address review concerns; they do not imply maintainer acceptance. diff --git a/src/utils/change-directory.ts b/src/utils/change-directory.ts index 95a149e592..7c967e3a5e 100644 --- a/src/utils/change-directory.ts +++ b/src/utils/change-directory.ts @@ -2,15 +2,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { FileSystemUtils } from './file-system.js'; -export const CHANGE_STAGES = ['proposed', 'approved']; +const CHANGE_STAGES = ['proposed', 'approved']; // Older projects can have a change with the same name as a new container. function isChange(dir: string): boolean { return ['.openspec.yaml', 'proposal.md'].some(file => fs.existsSync(path.join(dir, file))); } -export function changeStageDir(changesDir: string, stage: string): string { - const dir = path.join(changesDir, stage); +export function proposedChangesDir(changesDir: string): string { + const dir = path.join(changesDir, 'proposed'); if (isChange(dir)) throw new Error(`Rename the existing change at ${dir} before using it as a change container.`); FileSystemUtils.assertPathWithin(changesDir, dir); return dir; diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index bc542d73a3..7c2c199b10 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -1,4 +1,4 @@ -import { changeStageDir, resolveChangeDir } from './change-directory.js'; +import { proposedChangesDir, resolveChangeDir } from './change-directory.js'; import path from 'path'; import { FileSystemUtils } from './file-system.js'; import { writeChangeMetadata, validateSchemaName } from './change-metadata.js'; @@ -163,7 +163,7 @@ export async function createChange( // Build the change directory path const changesDir = options.changesDir ?? path.join(projectRoot, 'openspec', 'changes'); const existingDir = resolveChangeDir(changesDir, name); - const changeDir = path.join(changeStageDir(changesDir, 'proposed'), name); + const changeDir = path.join(proposedChangesDir(changesDir), name); // Check if change already exists if (await FileSystemUtils.directoryExists(existingDir)) { diff --git a/test/utils/change-directory.test.ts b/test/utils/change-directory.test.ts index ddfb424193..5444bdd4ff 100644 --- a/test/utils/change-directory.test.ts +++ b/test/utils/change-directory.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { activeChangeNames, changeStageDir, resolveChangeDir } from '../../src/utils/change-directory.js'; +import { activeChangeNames, proposedChangesDir, resolveChangeDir } from '../../src/utils/change-directory.js'; import { createChange } from '../../src/utils/change-utils.js'; import { getTaskProgressForChange } from '../../src/utils/task-progress.js'; import { ListCommand } from '../../src/core/list.js'; @@ -58,7 +58,7 @@ describe('change directories', () => { fs.writeFileSync(path.join(old, '.openspec.yaml'), 'schema: spec-driven\n'); expect(activeChangeNames(changes)).toEqual(['proposed']); expect(resolveChangeDir(changes, 'proposed')).toBe(old); - expect(() => changeStageDir(changes, 'proposed')).toThrow('Rename the existing change'); + expect(() => proposedChangesDir(changes)).toThrow('Rename the existing change'); }); it('never resolves a container as a change', async () => { From e038e166ca3a604fb03a4e3e44bc6dae76e40c63 Mon Sep 17 00:00:00 2001 From: Alex <45095641+runsonmypc@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:56:19 +0200 Subject: [PATCH 5/7] fix(changes): preserve task-only legacy stage names --- src/utils/change-directory.ts | 2 +- test/cli-e2e/store-lifecycle.test.ts | 2 +- test/utils/change-directory.test.ts | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/utils/change-directory.ts b/src/utils/change-directory.ts index 7c967e3a5e..d9930a410e 100644 --- a/src/utils/change-directory.ts +++ b/src/utils/change-directory.ts @@ -6,7 +6,7 @@ const CHANGE_STAGES = ['proposed', 'approved']; // Older projects can have a change with the same name as a new container. function isChange(dir: string): boolean { - return ['.openspec.yaml', 'proposal.md'].some(file => fs.existsSync(path.join(dir, file))); + return ['.openspec.yaml', 'proposal.md', 'tasks.md'].some(file => fs.existsSync(path.join(dir, file))); } export function proposedChangesDir(changesDir: string): string { diff --git a/test/cli-e2e/store-lifecycle.test.ts b/test/cli-e2e/store-lifecycle.test.ts index 2ffed3ddb2..442485d532 100644 --- a/test/cli-e2e/store-lifecycle.test.ts +++ b/test/cli-e2e/store-lifecycle.test.ts @@ -309,7 +309,7 @@ describe('standalone store lifecycle journey', () => { { env: machineA, cwd: projectDir } ); expect(apply.exitCode).toBe(0); - expect(JSON.parse(apply.stdout).contextFiles.proposal).toEqual([path.join(canonical(approvedDir), 'proposal.md')]); + expect(JSON.parse(apply.stdout).contextFiles.proposal.map(canonical)).toEqual([canonical(path.join(approvedDir, 'proposal.md'))]); const validated = await runCLI( ['validate', changeId, '--store', STORE_ID], diff --git a/test/utils/change-directory.test.ts b/test/utils/change-directory.test.ts index 5444bdd4ff..17041f3962 100644 --- a/test/utils/change-directory.test.ts +++ b/test/utils/change-directory.test.ts @@ -68,6 +68,16 @@ describe('change directories', () => { expect(fs.existsSync(path.join(changes, 'proposed', 'example'))).toBe(true); }); + it.each(['proposed', 'approved'])('preserves a task-only legacy change named %s', async name => { + const old = path.join(changes, name); + fs.mkdirSync(old, { recursive: true }); + fs.writeFileSync(path.join(old, 'tasks.md'), '- [ ] Work\n'); + expect(activeChangeNames(changes)).toEqual([name]); + expect(resolveChangeDir(changes, name)).toBe(old); + expect(await getTaskProgressForChange(changes, name, root)).toEqual({ total: 1, completed: 0 }); + if (name === 'proposed') expect(() => proposedChangesDir(changes)).toThrow('Rename the existing change'); + }); + it('resolves an alias to the same change without ambiguity', async () => { const { changeDir } = await createChange(root, 'example'); fs.symlinkSync(changeDir, path.join(changes, 'example'), process.platform === 'win32' ? 'junction' : 'dir'); From ba6acc5cb1a0e3684d125e7b81c83db563c75a92 Mon Sep 17 00:00:00 2001 From: Alex <45095641+runsonmypc@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:59:37 +0200 Subject: [PATCH 6/7] test(changes): compare canonical legacy directory paths --- test/utils/change-directory.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils/change-directory.test.ts b/test/utils/change-directory.test.ts index 17041f3962..2441679bd0 100644 --- a/test/utils/change-directory.test.ts +++ b/test/utils/change-directory.test.ts @@ -73,7 +73,7 @@ describe('change directories', () => { fs.mkdirSync(old, { recursive: true }); fs.writeFileSync(path.join(old, 'tasks.md'), '- [ ] Work\n'); expect(activeChangeNames(changes)).toEqual([name]); - expect(resolveChangeDir(changes, name)).toBe(old); + expect(fs.realpathSync.native(resolveChangeDir(changes, name))).toBe(fs.realpathSync.native(old)); expect(await getTaskProgressForChange(changes, name, root)).toEqual({ total: 1, completed: 0 }); if (name === 'proposed') expect(() => proposedChangesDir(changes)).toThrow('Rename the existing change'); }); From 87133e0b9c60567c66e688426100f7882459f8b0 Mon Sep 17 00:00:00 2001 From: Alex <45095641+runsonmypc@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:38:04 +0200 Subject: [PATCH 7/7] fix(changes): integrate approval paths after rebase --- skills/openspec-apply-change/SKILL.md | 1 + skills/openspec-archive-change/SKILL.md | 1 + skills/openspec-bulk-archive-change/SKILL.md | 1 + skills/openspec-continue-change/SKILL.md | 1 + skills/openspec-explore/SKILL.md | 1 + skills/openspec-ff-change/SKILL.md | 1 + skills/openspec-new-change/SKILL.md | 1 + skills/openspec-onboard/SKILL.md | 1 + skills/openspec-propose/SKILL.md | 1 + skills/openspec-sync-specs/SKILL.md | 1 + skills/openspec-update-change/SKILL.md | 1 + skills/openspec-verify-change/SKILL.md | 1 + src/commands/validate.ts | 3 +- ...archive-closed-requirement-heading.test.ts | 8 ++- ...archive-requirement-name-near-miss.test.ts | 8 ++- test/commands/profile-handoffs.test.ts | 4 +- test/commands/store-phantom-root.test.ts | 8 ++- test/commands/store-root-selection.test.ts | 12 ++-- test/core/misplaced-delta-files.test.ts | 4 +- .../change-parser-delta-agreement.test.ts | 4 +- .../templates/skill-templates-parity.test.ts | 72 +++++++++---------- .../validation.scenario-body-parity.test.ts | 4 +- test/utils/task-progress.list-markers.test.ts | 4 +- 23 files changed, 90 insertions(+), 53 deletions(-) diff --git a/skills/openspec-apply-change/SKILL.md b/skills/openspec-apply-change/SKILL.md index ed033ba934..c793d245a1 100644 --- a/skills/openspec-apply-change/SKILL.md +++ b/skills/openspec-apply-change/SKILL.md @@ -12,6 +12,7 @@ metadata: Implement tasks from an OpenSpec change. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/skills/openspec-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md index dd98c4499f..ad11c4deab 100644 --- a/skills/openspec-archive-change/SKILL.md +++ b/skills/openspec-archive-change/SKILL.md @@ -12,6 +12,7 @@ metadata: Archive a completed change in the experimental workflow. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/skills/openspec-bulk-archive-change/SKILL.md b/skills/openspec-bulk-archive-change/SKILL.md index b388f0b802..29954501e7 100644 --- a/skills/openspec-bulk-archive-change/SKILL.md +++ b/skills/openspec-bulk-archive-change/SKILL.md @@ -14,6 +14,7 @@ Archive multiple completed changes in a single operation. This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/skills/openspec-continue-change/SKILL.md b/skills/openspec-continue-change/SKILL.md index 1693da7fa3..73057b840c 100644 --- a/skills/openspec-continue-change/SKILL.md +++ b/skills/openspec-continue-change/SKILL.md @@ -12,6 +12,7 @@ metadata: Continue working on a change by creating the next artifact. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/skills/openspec-explore/SKILL.md b/skills/openspec-explore/SKILL.md index 5b4534e6b9..5e06f326ef 100644 --- a/skills/openspec-explore/SKILL.md +++ b/skills/openspec-explore/SKILL.md @@ -16,6 +16,7 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/skills/openspec-ff-change/SKILL.md b/skills/openspec-ff-change/SKILL.md index f7b4b69611..c542d65b10 100644 --- a/skills/openspec-ff-change/SKILL.md +++ b/skills/openspec-ff-change/SKILL.md @@ -12,6 +12,7 @@ metadata: Fast-forward through artifact creation - generate everything needed to start implementation in one go. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/skills/openspec-new-change/SKILL.md b/skills/openspec-new-change/SKILL.md index 432cc45d97..757b61b5ae 100644 --- a/skills/openspec-new-change/SKILL.md +++ b/skills/openspec-new-change/SKILL.md @@ -12,6 +12,7 @@ metadata: Start a new change using the experimental artifact-driven approach. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/skills/openspec-onboard/SKILL.md b/skills/openspec-onboard/SKILL.md index 66de72f16c..73fa9cc1e1 100644 --- a/skills/openspec-onboard/SKILL.md +++ b/skills/openspec-onboard/SKILL.md @@ -12,6 +12,7 @@ metadata: Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/skills/openspec-propose/SKILL.md b/skills/openspec-propose/SKILL.md index 7f41c4b27a..a325aeba3f 100644 --- a/skills/openspec-propose/SKILL.md +++ b/skills/openspec-propose/SKILL.md @@ -26,6 +26,7 @@ When the user is ready to implement, they must start the apply workflow explicit --- **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/skills/openspec-sync-specs/SKILL.md b/skills/openspec-sync-specs/SKILL.md index 5683f84d5a..4783314b94 100644 --- a/skills/openspec-sync-specs/SKILL.md +++ b/skills/openspec-sync-specs/SKILL.md @@ -14,6 +14,7 @@ Sync delta specs from a change to main specs. This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement). **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/skills/openspec-update-change/SKILL.md b/skills/openspec-update-change/SKILL.md index 9aae524ba0..49666c11c6 100644 --- a/skills/openspec-update-change/SKILL.md +++ b/skills/openspec-update-change/SKILL.md @@ -12,6 +12,7 @@ metadata: Revise a change's existing planning artifacts and keep them coherent. Never edit code. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/skills/openspec-verify-change/SKILL.md b/skills/openspec-verify-change/SKILL.md index 355febc9b2..3cb489579c 100644 --- a/skills/openspec-verify-change/SKILL.md +++ b/skills/openspec-verify-change/SKILL.md @@ -12,6 +12,7 @@ metadata: Verify that an implementation matches the change artifacts (specs, tasks, design). **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +When the user approves a finished plan in chat or invokes apply, move its `changeRoot` into `/approved/` if not already there, then refresh status and instructions before any requested implementation; never archive a proposed change. **Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. diff --git a/src/commands/validate.ts b/src/commands/validate.ts index fa05aaeca1..205e11a5b7 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,5 +1,6 @@ import ora from 'ora'; import path from 'path'; +import { resolveChangeDir } from '../utils/change-directory.js'; import { describeNestedChange, findNestedChangesIn, @@ -326,7 +327,7 @@ export class ValidateCommand { } const validator = new Validator(opts.strict); if (type === 'change') { - const changeDir = path.join(root.changesDir, id); + const changeDir = resolveChangeDir(root.changesDir, id); const start = Date.now(); const nestedReport = await this.nestedChangeReport(root, id); if (nestedReport) { diff --git a/test/cli-e2e/archive-closed-requirement-heading.test.ts b/test/cli-e2e/archive-closed-requirement-heading.test.ts index ea0e0fda92..963c6bcff1 100644 --- a/test/cli-e2e/archive-closed-requirement-heading.test.ts +++ b/test/cli-e2e/archive-closed-requirement-heading.test.ts @@ -66,7 +66,9 @@ async function seededProject() { expect((await cli(['init', '--tools', 'claude'])).exitCode).toBe(0); expect((await cli(['new', 'change', 'seed'])).exitCode).toBe(0); - const seedDir = path.join(project, 'openspec', 'changes', 'seed'); + const seedProposedDir = path.join(project, 'openspec', 'changes', 'proposed', 'seed'); + const seedDir = path.join(project, 'openspec', 'changes', 'approved', 'seed'); + await fs.rename(seedProposedDir, seedDir); await fs.mkdir(path.join(seedDir, 'specs', 'billing'), { recursive: true }); await fs.writeFile(path.join(seedDir, 'proposal.md'), PROPOSAL); await fs.writeFile(path.join(seedDir, 'tasks.md'), '## 1. Work\n- [x] 1.1 Done\n'); @@ -74,7 +76,9 @@ async function seededProject() { expect((await cli(['archive', 'seed', '--yes'])).exitCode).toBe(0); expect((await cli(['new', 'change', 'edit'])).exitCode).toBe(0); - const editDir = path.join(project, 'openspec', 'changes', 'edit'); + const editProposedDir = path.join(project, 'openspec', 'changes', 'proposed', 'edit'); + const editDir = path.join(project, 'openspec', 'changes', 'approved', 'edit'); + await fs.rename(editProposedDir, editDir); await fs.mkdir(path.join(editDir, 'specs', 'billing'), { recursive: true }); await fs.writeFile(path.join(editDir, 'proposal.md'), PROPOSAL); await fs.writeFile(path.join(editDir, 'tasks.md'), '## 1. Work\n- [x] 1.1 Done\n'); diff --git a/test/cli-e2e/archive-requirement-name-near-miss.test.ts b/test/cli-e2e/archive-requirement-name-near-miss.test.ts index 60bff0686d..9edaeac0c3 100644 --- a/test/cli-e2e/archive-requirement-name-near-miss.test.ts +++ b/test/cli-e2e/archive-requirement-name-near-miss.test.ts @@ -82,7 +82,9 @@ async function seededProject() { expect((await cli(['init', '--tools', 'claude'])).exitCode).toBe(0); expect((await cli(['new', 'change', 'seed'])).exitCode).toBe(0); - const seedDir = path.join(project, 'openspec', 'changes', 'seed'); + const seedProposedDir = path.join(project, 'openspec', 'changes', 'proposed', 'seed'); + const seedDir = path.join(project, 'openspec', 'changes', 'approved', 'seed'); + await fs.rename(seedProposedDir, seedDir); await fs.mkdir(path.join(seedDir, 'specs', 'billing'), { recursive: true }); await fs.writeFile(path.join(seedDir, 'proposal.md'), PROPOSAL); await fs.writeFile(path.join(seedDir, 'tasks.md'), '## 1. Work\n- [x] 1.1 Done\n'); @@ -90,7 +92,9 @@ async function seededProject() { expect((await cli(['archive', 'seed', '--yes'])).exitCode).toBe(0); expect((await cli(['new', 'change', 'edit'])).exitCode).toBe(0); - const editDir = path.join(project, 'openspec', 'changes', 'edit'); + const editProposedDir = path.join(project, 'openspec', 'changes', 'proposed', 'edit'); + const editDir = path.join(project, 'openspec', 'changes', 'approved', 'edit'); + await fs.rename(editProposedDir, editDir); await fs.mkdir(path.join(editDir, 'specs', 'billing'), { recursive: true }); await fs.writeFile(path.join(editDir, 'proposal.md'), PROPOSAL); await fs.writeFile(path.join(editDir, 'tasks.md'), '## 1. Work\n- [x] 1.1 Done\n'); diff --git a/test/commands/profile-handoffs.test.ts b/test/commands/profile-handoffs.test.ts index 1df03c78e2..d37df59223 100644 --- a/test/commands/profile-handoffs.test.ts +++ b/test/commands/profile-handoffs.test.ts @@ -109,7 +109,7 @@ apply: await fs.writeFile(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); await fs.writeFile(path.join(root, 'openspec', 'config.yaml'), 'schema: handoff-test\ncontext: Preserve existing behavior.\nrules:\n brief:\n - Include a verification plan.\n'); await cli(['new', 'change', 'original', ...flags]); - const changeRoot = path.join(root, 'openspec', 'changes', 'original'); + const changeRoot = path.join(root, 'openspec', 'changes', 'proposed', 'original'); const before = await fs.readdir(changeRoot); const blocked = JSON.parse(await cli(['instructions', 'apply', '--change', 'original', '--json', ...flags])); expect(blocked.state).toBe('blocked'); @@ -162,7 +162,7 @@ apply: await fs.writeFile(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); await fs.writeFile(path.join(project, 'openspec', 'config.yaml'), 'schema: tracking-test\n'); await cli(['new', 'change', 'tracking']); - const changeRoot = path.join(project, 'openspec', 'changes', 'tracking'); + const changeRoot = path.join(project, 'openspec', 'changes', 'proposed', 'tracking'); const briefPath = path.join(changeRoot, 'brief.md'); await fs.writeFile(briefPath, '# Brief\n\nKeep this plan unchanged.\n'); const trackingPath = path.join(changeRoot, 'implementation', 'checklist.md'); diff --git a/test/commands/store-phantom-root.test.ts b/test/commands/store-phantom-root.test.ts index aa1d975e10..653a4b6744 100644 --- a/test/commands/store-phantom-root.test.ts +++ b/test/commands/store-phantom-root.test.ts @@ -106,8 +106,12 @@ describe('a store named specs or changes at ~/openspec/', () => { const result = await runCLI(['new', 'change', 'probe-change', '--json'], { cwd: workDir, env }); expect(result.exitCode).toBe(0); - expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'probe-change'))).toBe(true); - expect(fs.existsSync(path.join(home, 'openspec', 'changes', 'probe-change'))).toBe(false); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'proposed', 'probe-change')) + ).toBe(true); + expect( + fs.existsSync(path.join(home, 'openspec', 'changes', 'proposed', 'probe-change')) + ).toBe(false); }, 60_000 ); diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index 981eee21da..7f355b0316 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -674,7 +674,9 @@ operations: // relative to where the command ran, so it reads the same on Windows. expect(firstOutput).toContain('created at openspec/.'); expect(firstOutput).toContain('openspec init'); - expect(fs.existsSync(path.join(appRepo, 'openspec', 'changes', 'adopt-me'))).toBe(true); + expect( + fs.existsSync(path.join(appRepo, 'openspec', 'changes', 'proposed', 'adopt-me')) + ).toBe(true); // The root exists now, so the notice must not repeat on every change. const second = await runCLI(['new', 'change', 'already-adopted'], { @@ -702,9 +704,11 @@ operations: expect(created.exitCode).toBe(0); expect(created.stdout + created.stderr).toContain('created at openspec/.'); - expect(fs.existsSync(path.join(nested, 'openspec', 'changes', 'adopt-the-subdir'))).toBe( - true - ); + expect( + fs.existsSync( + path.join(nested, 'openspec', 'changes', 'proposed', 'adopt-the-subdir') + ) + ).toBe(true); expect(fs.existsSync(path.join(appRepo, 'openspec'))).toBe(false); }); diff --git a/test/core/misplaced-delta-files.test.ts b/test/core/misplaced-delta-files.test.ts index 39eb200f37..fee788ed83 100644 --- a/test/core/misplaced-delta-files.test.ts +++ b/test/core/misplaced-delta-files.test.ts @@ -382,7 +382,9 @@ describe('end to end: a delta written as specs/.md', () => { const cli = (args: string[]) => runCLI(args, { cwd: project, env, timeoutMs: 60_000 }); expect((await cli(['init', '--tools', 'claude'])).exitCode).toBe(0); expect((await cli(['new', 'change', 'add-login'])).exitCode).toBe(0); - const dir = path.join(project, 'openspec', 'changes', 'add-login'); + const proposedDir = path.join(project, 'openspec', 'changes', 'proposed', 'add-login'); + const dir = path.join(project, 'openspec', 'changes', 'approved', 'add-login'); + await fs.rename(proposedDir, dir); await write( dir, ['proposal.md'], diff --git a/test/core/parsers/change-parser-delta-agreement.test.ts b/test/core/parsers/change-parser-delta-agreement.test.ts index 720f05d9bb..197e1d6701 100644 --- a/test/core/parsers/change-parser-delta-agreement.test.ts +++ b/test/core/parsers/change-parser-delta-agreement.test.ts @@ -270,7 +270,9 @@ async function projectWithDelta(delta: string) { const cli = (args: string[]) => runCLI(args, { cwd: project, env, timeoutMs: 60_000 }); const writeChange = async (name: string, spec: string) => { expect((await cli(['new', 'change', name])).exitCode).toBe(0); - const dir = path.join(project, 'openspec', 'changes', name); + const proposedDir = path.join(project, 'openspec', 'changes', 'proposed', name); + const dir = path.join(project, 'openspec', 'changes', 'approved', name); + await fs.rename(proposedDir, dir); await fs.mkdir(path.join(dir, 'specs', 'billing'), { recursive: true }); await fs.writeFile(path.join(dir, 'proposal.md'), PROPOSAL); await fs.writeFile(path.join(dir, 'tasks.md'), '## 1. Work\n- [x] 1.1 Done\n'); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 3cbe25b3a3..ee1fb9857d 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -76,46 +76,46 @@ function specDrivenTitles(): Record { } const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: 'b17a409b5634b5e48864a87f038f2111a74c2442288e9f4cb7a704f86b6d75e7', - getNewChangeSkillTemplate: '0e5035b7b42198afc430206a1dbc9579096650ef0813d85e837d5a6cd0b98a85', - getContinueChangeSkillTemplate: '550dc22bc8e0921b1ca5cef867379c4f370c5f1902c420bf9fa3bbfa75cea933', - getApplyChangeSkillTemplate: '04ae407c97b5f9cb0cc15199fe877ccc7cd1eff78bfe10ad70c16a112b10a661', - getFfChangeSkillTemplate: '6fb5492e78b9ceec068949080ec9f2e0d2a8baff75a2fe33d07ad33ffe542b65', - getSyncSpecsSkillTemplate: 'bc80fe9b07eaa289e5eb8a3ce65eb7df722a16d864e37283c678220712e4f230', - getOnboardSkillTemplate: '7d92756ffc0b30053838716005610daf3f65c3fa011f3f4d29b6488f303f9cfb', - getOpsxExploreCommandTemplate: 'f6cf22825643281d653355745623a6c1a4566db46cc2f262d2282243c6d8169a', - getOpsxNewCommandTemplate: '6d504fef1e0d4ced7c423f4cc9d9d2cee11b1a6224edf685e06a3f0757e0ebff', - getOpsxContinueCommandTemplate: 'ace5c9cc239c12b57dc86fd9a1c02a6ca467cb8e1245127340c07ab1b9d37c11', - getOpsxApplyCommandTemplate: 'd70cecce3b7d1dd4dbd5fd1fc2bccb538f5e61f5b43d520e4beca896e3f9e6b3', - getOpsxFfCommandTemplate: '04cb49b0bf3ebe364b45268a283564ee4fd50b78b01ec1d3f975bcae68179d2d', - getArchiveChangeSkillTemplate: '8447a2489240bf0c27f863065d61453dd0264842d1dabafe27b577d6bff96eb3', - getBulkArchiveChangeSkillTemplate: 'f17399959921ff98c7798e4591c8888825b7c9a83b0a90f09d98c7e0984ab793', - getOpsxSyncCommandTemplate: '60550b7bb9829421656d6324a9e4c951bc912f48f88882d1a07ce7f78397a5e7', - getVerifyChangeSkillTemplate: '2e069a277dac23818b13bb50b66e806ab405bc3b7f535400e1ebf81b84153699', - getOpsxArchiveCommandTemplate: '980109e5f8362610872c70fe0a0f1d48d3d2692275b2b17e2f4c91c3de89c2fd', - getOpsxOnboardCommandTemplate: '9cad751f7b938eea039b0ba207247776269c81bec5923eb335bee468f515f244', - getOpsxBulkArchiveCommandTemplate: '3db03eadb764abd74c8c180656c3f64a8b9a4971056c91624d38df3209d7b446', - getOpsxVerifyCommandTemplate: '938f52f20fb9a3b811ea47314baac1034cd550e8ab363ae878ccba4b6329348f', - getOpsxProposeSkillTemplate: '1aa2f2eb9c8cbc4dcab9d777bf8832b92ca04f9ef91d0494f1224a566aefdfe8', - getOpsxProposeCommandTemplate: '3b7090ce5e79e879ab9b5bdaf4ff2b52e3c02211f71188838772d36ac337f96c', + getExploreSkillTemplate: '992431473852c2255cf097df334c8c097e6092d5545b812554233d63c93a289c', + getNewChangeSkillTemplate: '945df55b3592f92fc4d490c831e3ea2fd286defab7701710c1d0ffd77878ebbe', + getContinueChangeSkillTemplate: '575685337da47284563fa3266eaf1b489eafed86d893a1f659248fdf5cf93b12', + getApplyChangeSkillTemplate: 'ed82d0d699731da321424d9a44371c339f6aadf3c0089c0f83ccdfeebf230822', + getFfChangeSkillTemplate: 'f381876a498c98f5d3b88f9f8e2335671f1abcecec1ba000ce85bd6ec69e89dd', + getSyncSpecsSkillTemplate: '5bc51c294b50187ca9102ac1488cfdea2be44291617d264fdc7901698d1cb955', + getOnboardSkillTemplate: '571f53cb74ebc487e09471ed3aa85eee0c3c6b8c1ecd4d7c031b3654adfefdd1', + getOpsxExploreCommandTemplate: 'e6de22607183d8be54d9c0b453ae907a390c881114d8bfdd96b0a61faac40056', + getOpsxNewCommandTemplate: '2201788c63eec048822d0ab82cfaa0c68b8b14bf546be0a120a9a56ec076bb30', + getOpsxContinueCommandTemplate: '77dfd8d626f4d939697bb82fa0c8e52829509948ad3678acbc4ee996d7fc8837', + getOpsxApplyCommandTemplate: '41dbe1317bf74a65025f9f662459a880f453235e701a12178a0ba5251cbb6e1f', + getOpsxFfCommandTemplate: '8dd5b7cd5c007ee7e9f7476023879b2511bc79bbb3af2bba18b66d356ac041a3', + getArchiveChangeSkillTemplate: 'f58f15bc36f4afd74fdaa6d64f3a1e3cd11930f3b05b1c915052c0fc5ca21c3f', + getBulkArchiveChangeSkillTemplate: '667bfb6432b4a7056d5594b9f9e74f8c93b13f172ffd3682b032e4262f7064c5', + getOpsxSyncCommandTemplate: 'f44a8b500e6897b85ea3196532d0a48dd6973a2ca79a6ec21cee1624ef4ba18e', + getVerifyChangeSkillTemplate: '756a7014807504cebcb3e9c03a2ad2e7081d0b46e493eac73957b3893ab255b3', + getOpsxArchiveCommandTemplate: 'b61386c9d8584f075a93238e487b9f2138102c7ea3a81f6cede70986c0c15688', + getOpsxOnboardCommandTemplate: '2730e0efff61dbaa5a9a02d8311c57ae060c99ebcbdc260dccfb4f89aa8d41ed', + getOpsxBulkArchiveCommandTemplate: '3a78d225b29d0a07629b124cc5df935d861f6cd561be7c7c68e8496905ef756d', + getOpsxVerifyCommandTemplate: '9ed615417b23783d02b0d342d5ac394493d6a8f275316a49cf878025a8990a96', + getOpsxProposeSkillTemplate: '3cab69611eb097bb061d7b76c5220dfc60a4dd00be93ee82de58316b6262164d', + getOpsxProposeCommandTemplate: '06be6e2b1cc978b43e0778a0d3dc6cf08a85adbb8c2ca6f0375b66faddcf8c70', getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133', - getUpdateChangeSkillTemplate: 'f4c38adf3c82b3e0af7c460de97b72740d69a8966b5426b259f7c2cb6dc11d3d', - getOpsxUpdateCommandTemplate: 'a3156c2c3b4a429fed56545f315f66a7cc25bc9f8822c5fe30a60ccd87159a0f', + getUpdateChangeSkillTemplate: 'afa63f258019ce47658acc28f11897dc316708d47f52570bf5ab60f3925dc8e4', + getOpsxUpdateCommandTemplate: 'eed8917eb3ca100390ca006889009b084cc6cf35aebf72444cf9353bd76545d5', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': '8b02eb77ae87a4374a43ad33930e5ea19cbaf1c8a7624d5b613c03d7ee1c5f14', - 'openspec-new-change': '27e09d43785953827efc9a98bb9d6cf06db48fe6abe7e1c049409fe5b5061323', - 'openspec-continue-change': '182f015de6a1a114c79a6106c0565fd71f368d629641d0ad088de54bd871b52f', - 'openspec-apply-change': 'f3e92c229fab8d77df9f0a77dcb117cf46279b53a208d53aed89bfe0bab2ac09', - 'openspec-ff-change': '8ffad1b1a2deea5f097eb7294fb8b9474d5dfb1c31ee2fd3311d9a9d78259323', - 'openspec-sync-specs': '3909936a236a21a9a6d5bf495f90b396b3b68fc9220d7b2c1894668653beb2e4', - 'openspec-archive-change': '305a21a9c76a925055f3bdbaac504f208660ef6948d78f73928de166250609bf', - 'openspec-bulk-archive-change': '4bd638a50111d2ee3a667752a2355ed513f770695b137b93fc28848ca7bf60d2', - 'openspec-verify-change': 'ad8a3098bd27d852721687c47a12db7107ed8b8dfc7f071406bb19961652e7ee', - 'openspec-onboard': 'd4c5f3e24c19c8e389950544ea0d1844027753def14748c9684210ae4c6cd5e5', - 'openspec-propose': '66e3395adf9f2d93a09e8ef1d20e4efb010e5e8d4811f2d42a9316e4d1ca5a8b', - 'openspec-update-change': '19163b8c1b40ccdc0840019aa8005877a90a3a1cd9f7aadb87f76ccce1342f19', + 'openspec-explore': '98a53e20ed853e50e709e87343f097b0c9cd7b79e31b09c2f2f2831c44b82dbd', + 'openspec-new-change': 'ecc57c16b50c9662805b42fdfe6cb04faa60cff28394ac48ade912a2fd308929', + 'openspec-continue-change': 'c144ef16c2d028f3cb5a5d423c3ef183f8891e66b4daab610dda85178e024438', + 'openspec-apply-change': '60339cbba59be4ce18b988b306e5a4fb7c0ffff34c3ef82d41ac54541c31861b', + 'openspec-ff-change': '5d52f97f40e8648460a65b6e006f1684148a0c57e18841bc2841c6219ca5cb15', + 'openspec-sync-specs': '8cb525276e8a1118d92cd33dc3ac3747a46d690367ae932d75f4792a5ead5332', + 'openspec-archive-change': '49011e16aa10ddfe0a3ee7ec898c5c032212184e0c35202acbef3e3812dbb547', + 'openspec-bulk-archive-change': '1560334eda1d66957c09e4eb8e03e5b7bfa7aeded292a6b922ec2606051c1eb0', + 'openspec-verify-change': '09674c157f27fa2fc094bc886adb62887b8d75963bc48020e4184b9a338340de', + 'openspec-onboard': 'cdc11271b9870d97b9d6062ebaf23c79481f98941c4d87ebd3192fd865919c77', + 'openspec-propose': 'b47c40bbcb59d854e52ae31d81303a2f5eecd4e036f2cc2a377a38027cdb3801', + 'openspec-update-change': '61ca45183609465af7d81af247ca8a97438afda03e22edc93db166cfc06abbb3', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates diff --git a/test/core/validation.scenario-body-parity.test.ts b/test/core/validation.scenario-body-parity.test.ts index 83a0a6dd4c..343b3dcb0e 100644 --- a/test/core/validation.scenario-body-parity.test.ts +++ b/test/core/validation.scenario-body-parity.test.ts @@ -169,7 +169,9 @@ async function projectWithScenarios(scenarios: string[]) { expect((await cli(['init', '--tools', 'claude'])).exitCode).toBe(0); expect((await cli(['new', 'change', 'edit'])).exitCode).toBe(0); - const dir = path.join(project, 'openspec', 'changes', 'edit'); + const proposedDir = path.join(project, 'openspec', 'changes', 'proposed', 'edit'); + const dir = path.join(project, 'openspec', 'changes', 'approved', 'edit'); + await fs.rename(proposedDir, dir); await fs.mkdir(path.join(dir, 'specs', 'billing'), { recursive: true }); await fs.writeFile( path.join(dir, 'proposal.md'), diff --git a/test/utils/task-progress.list-markers.test.ts b/test/utils/task-progress.list-markers.test.ts index 3335d3ee42..ad6be6484b 100644 --- a/test/utils/task-progress.list-markers.test.ts +++ b/test/utils/task-progress.list-markers.test.ts @@ -126,7 +126,9 @@ describe('ordered and plus tasks through the CLI', () => { expect((await cli(['init', '--tools', 'claude'])).exitCode).toBe(0); expect((await cli(['new', 'change', 'add-thing'])).exitCode).toBe(0); - const changeDir = path.join(project, 'openspec', 'changes', 'add-thing'); + const proposedDir = path.join(project, 'openspec', 'changes', 'proposed', 'add-thing'); + const changeDir = path.join(project, 'openspec', 'changes', 'approved', 'add-thing'); + await fs.rename(proposedDir, changeDir); await fs.mkdir(path.join(changeDir, 'specs', 'billing'), { recursive: true }); await fs.writeFile( path.join(changeDir, 'proposal.md'),