From 17d13c054223ff22a25e107256ba32bbc39ba90c Mon Sep 17 00:00:00 2001 From: TabishB Date: Wed, 10 Jun 2026 01:53:15 +1000 Subject: [PATCH 001/111] Implement context store root parity --- openspec/work/AGENTS.md | 35 + openspec/work/README.md | 87 ++ .../goal.md | 67 + .../roadmap.md | 486 ++++++ .../slices/store-root-parity/plan.md | 629 ++++++++ .../slices/store-root-parity/spec.md | 272 ++++ .../store-root-parity/user-facing-review.html | 1347 +++++++++++++++++ src/commands/context-store.ts | 133 +- src/core/completions/command-registry.ts | 4 + src/core/context-store/errors.ts | 2 +- src/core/context-store/operations.ts | 131 +- src/core/context-store/registry.ts | 19 + src/core/index.ts | 1 + src/core/openspec-root.ts | 267 ++++ test/commands/context-store.test.ts | 342 ++++- test/core/context-store/registry.test.ts | 26 + test/core/openspec-root.test.ts | 118 ++ 17 files changed, 3881 insertions(+), 85 deletions(-) create mode 100644 openspec/work/AGENTS.md create mode 100644 openspec/work/README.md create mode 100644 openspec/work/simplify-context-and-workspace-model/goal.md create mode 100644 openspec/work/simplify-context-and-workspace-model/roadmap.md create mode 100644 openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/plan.md create mode 100644 openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/spec.md create mode 100644 openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/user-facing-review.html create mode 100644 src/core/openspec-root.ts create mode 100644 test/core/openspec-root.test.ts diff --git a/openspec/work/AGENTS.md b/openspec/work/AGENTS.md new file mode 100644 index 000000000..01da7ee7f --- /dev/null +++ b/openspec/work/AGENTS.md @@ -0,0 +1,35 @@ +# Agent Guidance For `/work` + +When working in this directory, use a product-facing lens first. + +Start from how the work is experienced by users, not from the internal command +or file structure. In this product there are two users: + +- Humans: they usually do OpenSpec work by prompting agents. They may run shell + commands for interactive setup or one-off actions, but prompts are the normal + interface. +- Agents: they need clear intent, discoverable state, unambiguous next actions, + and enough structured output to act safely. + +Good human UX is usually good agent UX. A flow that is easy for a human to ask +for and understand is usually easier for an agent to execute, verify, and +explain. + +For roadmap or slice exploration: + +- Describe the user-facing flow before the internal implementation. +- Ask what the human sees, asks for, approves, or corrects. +- Ask what the agent must discover, decide, execute, and report back. +- Ground reasoning in the current repo behavior before proposing new shape. +- Treat shell commands as supporting mechanics, not the primary product story. +- Prefer concrete workflows over abstract model language. + +When an answer gets confusing, reframe it as: + +```text +What does the human want? +What does the agent need to know? +Where does the work live? +What changes on disk? +How does the user know it worked? +``` diff --git a/openspec/work/README.md b/openspec/work/README.md new file mode 100644 index 000000000..4fedc4864 --- /dev/null +++ b/openspec/work/README.md @@ -0,0 +1,87 @@ +# OpenSpec Work + +This directory is an experimental home for Git-native work artifacts. + +The current experiment separates the work model into four layers: + +```text +goal -> roadmap -> slice -> result +``` + +- `goal.md` describes the destination: what we are trying to make true and why. +- `roadmap.md` describes the current path toward that goal. It is expected to + change as implementation reveals better sequencing. +- `slices//spec.md` describes one small desired outcome. +- `slices//plan.md` describes how that slice will be implemented and + verified. +- `slices//result.md` records what actually happened and the evidence that + the slice passed, failed, or needs follow-up. +- `slices//log.md` is optional. Use it only when important changes need a + short explanation of what changed, why, and what downstream artifacts were + affected. + +The goal is to keep high-level work lightweight while still giving agents and +humans enough structure to move one slice at a time. + +Rule of thumb: + +```text +spec.md says what must be true. +plan.md says how we intend to get there. +result.md says what actually happened. +``` + +## Shape + +```text +openspec/work/ + README.md + / + goal.md + roadmap.md + slices/ + / + spec.md + plan.md + result.md + log.md +``` + +## Workflow + +Start with the goal, then maintain a loose roadmap. The roadmap is a living +sequence of likely slices, not a promise to execute everything in order. + +For each slice: + +1. Explore and interview until the slice has a useful `spec.md`. +2. Generate `plan.md` only when the spec is clear enough to implement. +3. Execute the plan. +4. Record proof, verification output, and follow-ups in `result.md`. +5. Update `roadmap.md` when the result changes the path forward. + +## Revision Rules + +Edit `spec.md` when the desired slice outcome changes. + +Edit `plan.md` when the implementation path changes but the slice outcome is +still the same. + +Create or update `result.md` when implementation or verification has happened. +Do not use it as the source of truth for current intent. + +Add `log.md` entries when a meaningful pivot would be hard to understand from +the final files alone. + +Create a new slice when the new work can be accepted, scheduled, verified, or +shipped independently. + +## Compatibility + +This directory is experimental. Current OpenSpec CLI validation, archive, and +spec update behavior still centers on `openspec/changes/` and +`openspec/specs/`. + +Use `/work` to coordinate and learn. When a slice needs today's executable +OpenSpec lifecycle, project that slice into a normal `openspec/changes//` +artifact until `/work` has first-class CLI support. diff --git a/openspec/work/simplify-context-and-workspace-model/goal.md b/openspec/work/simplify-context-and-workspace-model/goal.md new file mode 100644 index 000000000..19e735972 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/goal.md @@ -0,0 +1,67 @@ +# Simplify Context And Workspace Model Goal + +## Destination + +Reorient the current context-store, initiative, workspace, and repo-local change +direction into a simpler OpenSpec model that is easier to explain, implement, +and dogfood. + +The simplified direction is: + +```text +Specs are what is true. +Work is what is in motion. +``` + +OpenSpec artifacts should live in Git. That Git repo may be the project repo, +a standalone planning repo, or a contracts repo. The product should not require +context stores, workspaces, or another state system as primary user-facing +concepts. + +## Desired Experience + +A human should be able to say: + +```text +OpenSpec can live in this project repo or in its own Git repo. +This work targets these repos. +This local machine maps those target repos to these checkouts. +``` + +Agents and commands should be able to assemble the relevant OpenSpec root and +target project repos without asking users to understand context-store, +workspace, collection, and repo-local modes as separate product systems. + +## Product Direction + +- Preserve the current `specs/` and `changes/` baseline while the simpler model + is introduced. +- Make the placement choice explicit: in-project OpenSpec or standalone + OpenSpec repo. +- Treat target repos as declared targets, not as mandatory lifecycle roots. +- Reduce workspace behavior to local repo mapping and optional focused views. +- Treat the future `work/` layout as a later evolution, not a prerequisite for + making standalone OpenSpec repos useful. + +## Constraints + +- Keep the current `openspec/changes/` and `openspec/specs/` lifecycle working. +- Treat this `/work` folder as an experiment for organizing the reorientation, + not as the implemented product model. +- Avoid reviving context stores or workspaces as primary product nouns. +- Avoid global `decisions.md` and `questions.md` files as the default planning + shape. +- Prefer small, reviewable slices over large roadmap items. +- Promote only the information that needs to guide future slices. + +## Success Signals + +- A fresh agent can understand the active goal and current roadmap by reading + the files in this work directory. +- The old context-store and workspace initiative becomes useful transition + history rather than the active product queue. +- The next product slices are about preserving the baseline, clarifying + placement, supporting standalone OpenSpec repos, and resolving target project + repos. +- The roadmap avoids making future `/work` support block the simpler standalone + OpenSpec repo path. diff --git a/openspec/work/simplify-context-and-workspace-model/roadmap.md b/openspec/work/simplify-context-and-workspace-model/roadmap.md new file mode 100644 index 000000000..9ae189197 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/roadmap.md @@ -0,0 +1,486 @@ +# Simplify Context And Workspace Model Roadmap + +This roadmap is a living internal path toward `goal.md`. It is expected to +change as slices reveal better sequencing, missing constraints, or simpler +product shape. + +This is not public product framing yet. Keep it lightweight, avoid promising +future behavior before it exists, and prefer concrete implementation slices over +large documentation rewrites. + +The core move is simple: take the old clean OpenSpec root model and let that +root live in a standalone Git repo. Views or workspaces may open that OpenSpec +repo together with target code repos, but they are not the source of truth. + +## Current Focus + +Use this lightweight `/work` experiment as a place to coordinate the +reorientation captured in `goal.md`. The old +`openspec/initiatives/context-store-and-initiatives/direction-git-native-work.md` +note is transition evidence that was distilled into this work's goal. The +folder shape itself is not a product roadmap item. + +The roadmap order is now: + +1. Make context-store setup/register produce a normal standalone OpenSpec root. +2. Make store selectors route core commands to the selected OpenSpec root. +3. Remove initiative coupling from the product path. +4. Add target repo mapping. +5. Turn workspace/opening behavior into a local view over the OpenSpec root and + target repos. +6. Delete or demote detour residue only when it blocks the simple path. +7. Revisit public concepts only after the behavior is solid. + +## Working Vocabulary + +- OpenSpec root: the `openspec/` directory containing config, `specs/`, and + `changes/`. +- In-project OpenSpec: the OpenSpec root lives inside the code repo. +- Standalone OpenSpec repo: the same OpenSpec root lives in its own Git repo. +- Context store: a named/registered standalone OpenSpec repo shell. It may use + `.openspec-store` for identity or registry metadata, but `openspec/` is the + planning source of truth. +- Target project repo: a code repo the work applies to. +- Local repo map: machine-local mapping from target repo ids to checkout paths. +- View/workspace: optional local way to open the OpenSpec repo plus target repos + together. It is not durable planning state. + +## Operating Guardrails + +- Keep the old simple `openspec/specs/` and `openspec/changes/` lifecycle as + the foundation. +- Treat a context store, when used, as a named/registered standalone OpenSpec + repo, not as a separate planning state. +- Treat planning state as normal OpenSpec artifacts: + `openspec/specs/` and `openspec/changes/`. +- Treat initiative collections and separate workspace planning state as + wrong-direction residue, not compatibility requirements for the simplified + model. +- Treat future initiative-like behavior as a possible type of work, not as a + separate context-store collection for now. +- Treat `/work` as internal dogfooding, not a product requirement. +- Defer broad public docs and vocabulary cleanup until behavior exists; do not + spend roadmap time making obsolete beta framing nicer. +- Do not imply clone, pull, push, sync, branch, worktree, dashboard, workspace + apply, workspace verify, or workspace archive behavior. +- Change accepted specs alongside real behavior, not ahead of it. + +## Phase 0: Authority Cleanup + +### Reorient The Old Context-Store Initiative + +Status: done + +Outcome: Make it clear that +`openspec/initiatives/context-store-and-initiatives/` is beta history and +transition evidence, while this `/work` directory tracks the active +reorientation work. + +Done when: + +- The old initiative points readers to this work's `goal.md` and `roadmap.md` + as the active direction. +- `direction-git-native-work.md` is described as the transition note that led + to this goal, not as the current authority if the two conflict. +- The old `README.md`, `roadmap.md`, `tasks.md`, and `direction.md` make it + clear that they preserve beta history and transition evidence. +- The old roadmap and task files are not treated as the next implementation + queue. +- Useful decisions, work item notes, and beta evidence remain discoverable. + +### Disposition Deferred Workspace Artifacts + +Status: done + +Outcome: Make active no-task workspace changes and historical workspace roadmap +artifacts impossible to mistake for the next implementation queue. + +Done when: + +- `workspace-reimplementation-roadmap`, `workspace-agent-guidance`, + `workspace-apply-repo-slice`, and `workspace-verify-and-archive` are marked + obsolete / pending deletion review. +- Deferred workspace apply, verify, archive, branch/worktree orchestration, + strong cross-repo validation, and progress dashboards are not revived by + accident. +- Any useful research remains available temporarily until unique evidence is + promoted, linked, or deliberately discarded before deletion. + +### Reframe Agent Operating Guidance + +Status: done + +Outcome: Make local agent guidance start from OpenSpec roots, artifact +placement, target project repos, and local repo mapping instead of a +context-store/workspace-first model. + +Done when: + +- Local `.codex/skills/use-openspec/SKILL.md` guidance routes agents through + the current simplified model before beta shared-context flows. +- Local `artifact-placement.md` distinguishes in-project OpenSpec from + standalone OpenSpec repos, then separately asks which target project repo owns + implementation. +- Local `shared-context-beta.md` is framed as non-default beta/detour guidance, + not the product model. +- Agent guidance still tells agents to inspect current CLI state and avoids + promising clone, sync, branch, worktree, dashboard, or edit-boundary behavior. +- The final disposition of this ignored local guidance is reviewed later. + +## Phase 1: Context Store As Standalone OpenSpec Repo + +### Store Root Parity + +Status: next, spec ready; plan pending + +Slice: `slices/store-root-parity/spec.md` + +Outcome: Make `context-store setup` and `context-store register` create or +validate the same OpenSpec root shape a user would get by creating a fresh Git +repo and running `openspec init`, while keeping context-store metadata as a +thin identity shell. + +Research before implementation: + +- Decide how to share the root-only `openspec init` behavior without also + forcing agent/tool installation into context stores. +- Confirm the intended config behavior for non-interactive setup, because + current `openspec init` has config-generation quirks in non-interactive mode. +- Decide whether `context-store register` should require an existing OpenSpec + root, repair/ensure one, or support both modes explicitly. +- Decide how setup should treat non-empty folders such as a freshly initialized + Git repo that only contains `.git/`. + +Research decisions captured on 2026-06-09: + +- `context-store setup` should reuse an extracted root-only init helper, not + call full `InitCommand.execute()`. The helper should ensure `openspec/`, + `openspec/specs/`, `openspec/changes/`, and + `openspec/changes/archive/` without running tool detection, prompts, legacy + cleanup, migration, skill generation, or command generation. +- `context-store setup` should always ensure `openspec/config.yaml` with the + default schema when no config exists, including non-interactive and JSON + flows. Do not inherit the current `openspec init --tools none` + non-interactive config skip. +- `context-store register` should require an existing normal OpenSpec root by + default. It may create or repair thin `.openspec-store/store.yaml` identity + metadata, but it should not silently initialize arbitrary folders as OpenSpec + roots. Add an explicit repair or ensure mode later if that behavior is needed. +- `context-store setup` should accept missing directories, empty directories, + already initialized standalone OpenSpec roots, and fresh Git-only directories + that contain only `.git/`. Existing beta context-store metadata may be + normalized to the thin identity shape, but previous beta file shapes and + command semantics are not a compatibility contract. Setup should keep + rejecting arbitrary non-empty unmarked folders. +- `context-store doctor` should report OpenSpec-root health separately from + context-store metadata and Git health. Root health should cover the + `openspec/` directory, `openspec/config.yaml` or `openspec/config.yml`, + `openspec/specs/`, `openspec/changes/`, and + `openspec/changes/archive/`. +- Store setup/register/help output should point users toward normal OpenSpec + specs and changes. Do not create initiative links, mount initiative + collections, install generated agent files, or revive workspace-owned + planning behavior in this slice. +- 2026-06-09 review note: Store Root Parity should protect user-authored files + and idempotency, not preserve unstable beta context-store behavior. + +Implementation notes captured on 2026-06-09: + +- Add a shared core helper module for OpenSpec-root behavior, likely + `src/core/openspec-root.ts`, instead of putting root creation in + context-store code. It should own path helpers, root ensuring, root + inspection, and healthy-root checks. +- Extract the directory/config scaffold from `InitCommand` into that helper. + `InitCommand` should delegate root creation to the helper while keeping its + existing onboarding responsibilities: prompts, legacy cleanup, migration, + tool selection, skills, commands, profile handling, and success output. +- The helper should create a ledger of paths it created, including + `openspec/config.yaml` and any directories needed for the root shape. Callers + can use that ledger for `created_files` output and rollback. +- Use `planning-home.ts` for nearest-root discovery only. It already treats any + ancestor with `openspec/` as a repo planning home, but it should not become the + scaffolding or doctor module. +- `context-store setup` should call the helper after the store directory exists + and before backend resolution or registry commit. If registration fails in a + pre-existing folder, rollback should remove only ledger-created files and empty + directories, never arbitrary user content. +- `context-store register` should validate the existing OpenSpec root with the + helper's inspector before writing missing `.openspec-store/store.yaml` or + committing registry state. Keep the lower-level `registerContextStore()` + facade loose unless this slice intentionally makes root parity a global core + invariant. +- `context-store doctor` should map the helper's inspector result into a + separate `openspec_root` JSON/human section rather than calling cwd-oriented + command classes such as list, show, or validate. +- Test the helper directly, then test context-store operations directly, then + keep CLI tests focused on command output and JSON shape. Existing config + parsing and `cli-init` specs should not change unless their user-facing + behavior changes. + +Done when: + +- Existing context-store setup, register, list, doctor, path/Git, registry, and + safe-delete behavior is reused or narrowed intentionally. +- `context-store setup` can produce `.openspec-store/store.yaml`, optional + `.git/`, `openspec/config.yaml`, `openspec/specs/`, + `openspec/changes/`, and `openspec/changes/archive/`. +- `context-store register` can handle an already initialized standalone + OpenSpec repo without treating it as an initiative store. +- `.openspec-store/store.yaml` remains identity or registry metadata only, not + the planning model. +- Context-store help and guidance point users toward normal OpenSpec specs and + changes, not initiatives. +- `context-store doctor` reports OpenSpec root health separately from + metadata/Git health. + +### Store Selectors For Core Commands + +Status: candidate, research first + +Outcome: Let normal OpenSpec lifecycle commands operate on a selected OpenSpec +root, so a user in an app repo can create or inspect work in a standalone +context store without creating initiative links. + +Research before implementation: + +- Decide how to migrate `--store` and `--store-path`, since they currently mean + "context store for `--initiative`" rather than "OpenSpec root selector." +- Decide the first command set for selector support instead of trying to touch + every lifecycle command at once. +- Decide whether `--store-path` should require `.openspec-store/store.yaml` or + also accept any normal standalone OpenSpec root. +- Decide how this interacts with current workspace planning homes before + workspace/open is reworked into a view-only surface. + +Done when: + +- The default remains the nearest/current OpenSpec root when no selector is + provided. +- `openspec new change --store ` creates + `openspec/changes/` in the selected store/root, not in the current app + repo and not as an initiative-linked change. +- Store selectors such as `--store` or `--store-path` reuse existing registry, + binding, and path-canonicalization machinery where it is already useful. +- Multiple registered stores have clear list, doctor, and ambiguity behavior. +- The implementation still writes normal `openspec/changes/` and + `openspec/specs/` artifacts. +- Existing initiative metadata remains readable as legacy, but this flow does + not create new initiative metadata. + +### Prove Store-Backed Lifecycle Smoke + +Status: candidate + +Outcome: Prove that a registered standalone store can run the same simple +OpenSpec lifecycle as an in-project root. + +Done when: + +- A clean store-backed standalone fixture or smoke flow exists. +- The smoke covers init, new change, status, instructions, list, show, + validate, archive, and view where applicable. +- The smoke also covers context-store setup/register, list, doctor, and store + selection. +- Known live-repo detour artifacts are not part of the pass/fail gate. + +## Phase 2: Remove Initiative Coupling + +### Freeze New Initiative Links + +Status: candidate + +Outcome: Stop adding new initiative coupling to normal change flows while +keeping old metadata readable as legacy when needed. + +Done when: + +- `new change` and `set change` no longer create new initiative links as part + of the product path. +- Existing `.openspec.yaml` initiative metadata remains parseable if needed, but + is treated as legacy/display-only. +- Context-store selectors route to OpenSpec roots instead of initiative + collections. + +### Remove Public Initiative Surfaces + +Status: candidate + +Outcome: Remove, hide, or clearly demote public initiative command surfaces so +they no longer look like the model. + +Done when: + +- `openspec initiative` is not presented as the product path. +- Completion metadata, generated guidance, and command docs stop advertising + initiative flows as normal workflow steps. +- Existing initiative folders or metadata are not deleted automatically unless + they are explicitly part of a cleanup slice. + +### Decouple Workspace Open From Initiatives + +Status: candidate + +Outcome: Remove initiative attachment from workspace opening so opening becomes +a local view concern. + +Done when: + +- `workspace open --initiative` and related initiative picker behavior no longer + define the opening model. +- Old workspace view files can fail gracefully or be read as legacy without + resolving active initiative context. +- Workspace opening is ready to be replaced by opening a selected store/root + plus target repos. + +## Phase 3: Target Project Repo Resolution + +### Define Target Project Repo Contract + +Status: candidate + +Outcome: Define how work or changes declare which project repos own +implementation. + +Done when: + +- Target repo ids have a simple, explicit shape. +- A target repo declaration is separate from the OpenSpec artifact root. +- The contract does not imply automatic cloning, syncing, branch management, or + edit-boundary enforcement. + +### Map Target Repos To Local Checkouts + +Status: candidate + +Outcome: Let local config map target repo ids to checkout paths. + +Done when: + +- A local repo map can resolve a declared target repo to a local checkout. +- Missing, duplicate, or invalid mappings fail clearly. +- The map is local configuration, not a shared state system. + +### Report Target Repo Mapping Health + +Status: candidate + +Outcome: Surface whether declared target repos are available locally. + +Done when: + +- Doctor or status output reports missing target mappings clearly. +- The report distinguishes OpenSpec root health from target project checkout + health. +- The output stays diagnostic and does not attempt clone, pull, push, branch, + worktree, or sync behavior. + +## Phase 4: Open View + +### Open OpenSpec Root With Target Repos + +Status: candidate + +Outcome: Reuse or replace workspace opening machinery so a user can open a +selected context store or standalone OpenSpec repo together with its mapped +target code repos. + +Done when: + +- The selected context store/root remains the durable planning source of truth + through normal `openspec/` artifacts. +- Target repos are opened from the local repo map. +- The view can generate editor/agent opening surfaces without creating a + workspace planning home. +- The view does not imply clone, pull, push, sync, branch, worktree, dashboard, + or edit-boundary enforcement. + +## Phase 5: Residue Removal When It Blocks + +### Delete Or Demote Detour Surfaces + +Status: later + +Outcome: Remove, hide, or demote workspace-planning and initiative-collection +surfaces when they confuse the simple path or block store-backed standalone +OpenSpec repos. This is not a compatibility preservation pass. + +Done when: + +- Obsolete no-delta workspace changes are deleted, archived, or otherwise kept + out of the active implementation queue when they are no longer useful. +- Workspace-planning and initiative-collection code, docs, specs, and generated + guidance are removed or demoted only where they mislead users/agents or + constrain the new architecture. +- The cleanup does not become a broad docs rewrite or a compatibility support + project. + +## Later Candidates + +- Revisit public concept docs only after the model and behavior are solid + enough for public consumption. Until then, do not rewrite `docs/concepts.md` + or expose detailed standalone OpenSpec repo, target repo, local repo map, or + `/work` language as public product framing. +- Decide how and when accepted workspace-planning specs change once behavior + changes; do not rewrite specs just to match future framing. +- Add richer cross-repo context and doctoring after standalone OpenSpec repos + can target project repos. +- Evolve toward first-class `work/` only after the baseline and standalone repo + flow are solid. +- Revisit whether existing `changes/` become change-shaped work under `work/`. +- Add machine-readable `/work` metadata only after the manual shape proves + useful. +- Decide whether to keep, rename, or replace `context-store` terminology only + after the bridge behavior proves useful; do not block Phase 1 on naming. +- Re-review the local `use-openspec` skill changes and decide how this guidance + should really work: ignored local skill, generated artifact, checked-in source, + or productized default guidance. +- Fix small baseline quirks, such as JSON support for `openspec list --specs`, + only if they matter to the simple smoke path or standalone repo flow. +- Reintroduce initiative-like behavior only as a Git-native work type, if it + still proves useful later. + +## Roadmap Changes + +- 2026-06-07: Started the active reorientation experiment under + `openspec/work/` instead of continuing the context-store initiative roadmap. +- 2026-06-07: Renamed the active work from the abstract Git-native principle to + the concrete context/workspace model simplification. +- 2026-06-08: Removed the experimental `/work` folder shape from the roadmap; + it is the dogfood structure for this thinking, not a product slice. +- 2026-06-08: Preserved the old initiative reorientation item and expanded the + full framing cleanup into separate roadmap slices for old authority, + deferred artifacts, agent guidance, public docs, beta docs, CLI reference, + and generated guidance. +- 2026-06-08: Completed the old initiative reorientation pass by rewriting the + opening sections of the old README, roadmap, tasks, and direction files as + transition evidence / beta history. +- 2026-06-09: Marked the workspace reimplementation roadmap, + workspace-agent-guidance, workspace-apply-repo-slice, and + workspace-verify-and-archive artifacts obsolete / pending deletion review. +- 2026-06-09: Reframed checked-in `use-openspec` guidance around OpenSpec + roots, artifact placement, and target project repos instead of beta + shared-context framing. +- 2026-06-09: Added a later review item for the `use-openspec` skill framing + after noticing the edited `.codex/` skill is ignored local guidance. +- 2026-06-09: Deferred the public concepts reframe until the simplified model + is more solid and implemented enough for public documentation. +- 2026-06-09: Reordered the roadmap around baseline, placement, standalone + OpenSpec repos, and target repo mapping; moved docs and public framing cleanup + to later phases. +- 2026-06-09: Reframed Phase 1 from preserving all current behavior to + recovering the simple OpenSpec baseline, and replaced compatibility-docs work + with as-needed residue removal. +- 2026-06-09: Collapsed baseline recovery and standalone placement into the + first implementation phase: make a standalone OpenSpec root path work, then + remove initiative coupling, add target repo mapping, and build open views as + local convenience. +- 2026-06-09: Reframed Phase 1 around context store as the named/registered + standalone OpenSpec repo bridge: reuse setup/register/list/doctor and store + selectors, keep planning state in normal `openspec/`, and move initiative + removal into its own product-path cleanup. +- 2026-06-09: Split Phase 1 into two research-gated slices: Store Root Parity + for setup/register creating or validating a normal standalone OpenSpec root, + and Store Selectors For Core Commands for routing lifecycle commands to a + selected store/root without initiative links. +- 2026-06-09: Added the Store Root Parity slice spec and linked it from the + roadmap. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/plan.md new file mode 100644 index 000000000..a8a205e80 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/plan.md @@ -0,0 +1,629 @@ +# Context Store Root Parity Plan + +## Status + +Planned. + +This plan follows the slice spec after the 2026-06-10 product review decisions. +It is written as an implementation plan, but the product contract comes first: +humans and agents should experience a context store as a normal OpenSpec root +with one thin identity file. + +## Source Of Truth + +Start from `spec.md`. + +Also keep these nearby artifacts in view: + +- `../../goal.md` +- `../../roadmap.md` +- `../../../AGENTS.md` + +The core model for this slice is: + +```text +context store = normal OpenSpec root + .openspec-store/store.yaml +``` + +That means durable planning state lives in normal OpenSpec artifacts: + +```text +context-store-root/ + .openspec-store/ + store.yaml + openspec/ + config.yaml + specs/ + changes/ + archive/ +``` + +`.openspec-store/store.yaml` is identity metadata only. It is not a planning +model, workspace model, initiative model, migration marker, or compatibility +contract for old beta files. + +## User-Facing Frame + +What the human wants: + +- "Create a context store I can use as a normal OpenSpec place for specs and + changes." +- "Register the context store my teammate already pushed and I cloned locally." +- "Tell me whether this store is healthy without secretly changing files." +- "Do not overwrite my config, specs, changes, archives, or old local files." + +What the agent needs to know: + +- Whether the folder is a healthy OpenSpec root. +- Whether the context-store identity metadata exists and matches the store id. +- Whether the local registry already knows this id and path. +- Exactly which files or directories were created by this operation. +- Whether a refusal means "unsafe folder", "not an OpenSpec root", "missing + confirmation", "metadata problem", or "already registered". + +Where the work lives: + +- User-authored planning work lives under `openspec/`. +- Portable context-store identity lives in `.openspec-store/store.yaml`. +- Machine-local registration state stays in the local context-store registry. +- Old beta files may exist beside these files, but this slice ignores them. + +How the user knows it worked: + +- Human output names the store id and root path, then points toward normal + OpenSpec specs and changes. +- JSON output reports exact resulting state and relative `created_files`. +- Re-running the same command reports "already registered", "already exists", + or "nothing to change" without mutating files. +- `context-store doctor --json` reports `openspec_root` separately from + `metadata` and `git`. + +## Goal + +Make `context-store setup`, `context-store register`, and +`context-store doctor` agree on one product shape: + +- Setup creates or preserves a standalone OpenSpec root, then adds thin + context-store identity metadata. +- Register remembers an existing local root or clone. It does not initialize + planning files. +- Doctor diagnoses root health, metadata health, and Git health as separate + concerns. + +## Non-Goals + +- Do not add store selectors to core lifecycle commands. +- Do not create initiative links, initiative collections, or workspace-owned + planning state. +- Do not install generated agent skills, slash commands, onboarding files, or + tool configuration. +- Do not call full `openspec init` from context-store setup or register. +- Do not add clone, pull, push, sync, branch, worktree, dashboard, apply, + verify, or archive orchestration. +- Do not migrate, clean up, preserve, repair, or back-compat old beta planning + shapes. +- Do not rewrite public terminology or broad docs in this slice. + +## Locked Direction + +- A healthy OpenSpec root contains `openspec/`, a config file + (`openspec/config.yaml` or `openspec/config.yml`), `openspec/specs/`, + `openspec/changes/`, and `openspec/changes/archive/`. +- When setup creates config, it writes `openspec/config.yaml` with the default + `spec-driven` schema. +- Setup accepts missing directories, empty directories, Git-only directories, + and existing healthy OpenSpec roots. +- Setup rejects arbitrary non-empty unmarked folders without writing root or + metadata files. +- Setup rejects nested Git paths for this slice. Keep that rule isolated so a + later slice can relax it if the product direction changes. +- Register is for an existing local root or clone. It does not scaffold + planning files. +- Registering a cloned context store with existing `.openspec-store/store.yaml` + should succeed and only update local registry state when needed. +- Registering a healthy OpenSpec root without context-store identity should ask + before turning it into the named context store. +- For non-interactive conversion, use `--yes` on `context-store register` as the + explicit confirmation for this slice. Without it, JSON/non-interactive mode + refuses before writing metadata or registry state. +- Old beta files such as `initiatives/`, `.openspec-workspace/`, + `workspace.yaml`, `AGENTS.md`, `.codex/`, `.claude/`, and `.cursor/` are + ignored. They are not migrated, deleted, repaired, or treated as proof of a + healthy root. +- Re-running setup or register for the same healthy id and path is a no-op + success with no duplicate registry entries and empty `created_files`. +- Doctor reports root health under `openspec_root`, separate from `metadata` + and `git`, and never repairs while inspecting. + +## User Workflows + +### Fresh Setup + +A human or agent asks OpenSpec to create a new context store in a missing or +empty directory. + +Expected result: + +- The directory exists. +- `.openspec-store/store.yaml` exists. +- `openspec/config.yaml` exists with `schema: spec-driven`. +- `openspec/specs/`, `openspec/changes/`, and + `openspec/changes/archive/` exist. +- JSON `created_files` lists the relative paths created by setup. +- No initiative, workspace, agent, slash-command, or tool files are created. + +### Git-Only Setup + +A human has already run `git init` or cloned an empty repo, so the target folder +contains only `.git/`. + +Expected result: + +- Setup treats the folder as safe fresh input. +- `.git/` is preserved. +- The normal OpenSpec root and context-store identity are created. +- The command does not stage, commit, push, create remotes, or define Git + workflow policy. + +### Existing Healthy Root Setup + +A human already has a standalone OpenSpec root and wants it to become a context +store. + +Expected result: + +- Existing config, specs, changes, archives, and user-authored content are + preserved. +- Missing `.openspec-store/store.yaml` is created. +- Existing valid `.openspec-store/store.yaml` is preserved. +- Setup does not overwrite config just because the command ran. + +### Teammate Clone Register + +A teammate created a context store, pushed it to GitHub, and the human cloned it +locally. + +Expected result: + +- `context-store register ` validates the clone as a healthy OpenSpec + root with valid context-store identity. +- The local registry remembers that id and path. +- The cloned planning files are not created, rewritten, migrated, or repaired. +- Re-registering the same id and path reports that it is already registered or + has nothing to change. + +### Convert Healthy Root Register + +A human has a normal OpenSpec root that does not yet have +`.openspec-store/store.yaml`. + +Expected result: + +- Interactive register asks whether to turn that root into the named context + store. +- If confirmed, register writes only the identity metadata and local registry + entry. +- If declined, register writes nothing. +- JSON/non-interactive register refuses unless explicit confirmation is passed + with `--yes`. + +### Doctor Without Repair + +A human or agent wants to know whether registered stores are usable. + +Expected result: + +- Doctor reports OpenSpec-root health separately from metadata and Git health. +- Missing `openspec/changes/archive/` appears under `openspec_root`. +- Doctor does not create missing directories or repair files. + +## Command Behavior + +### `context-store setup` + +Setup creates or preserves the context-store root for this machine. + +Accept: + +- Missing target directory. +- Empty target directory. +- Existing target directory that contains only `.git/`. +- Existing healthy OpenSpec root. +- Existing root with matching valid context-store identity. + +Reject: + +- A file path. +- An arbitrary non-empty unmarked folder. +- A setup target nested inside another Git repository. +- A root with invalid or conflicting `.openspec-store/store.yaml`. + +Mutations: + +- Create only missing root-shape files and directories. +- Create `.openspec-store/store.yaml` when missing. +- Register the store in the machine-local registry. +- Preserve existing user-authored config, specs, changes, archives, and old + beta files. + +Human output should stay small: + +```text +Context store ready + +ID: team-context +Location: /Users/me/src/team-context +OpenSpec root: ready +Registry: registered + +Next: use normal OpenSpec specs and changes in this store. +``` + +JSON output should report exact state, including relative `created_files`. + +### `context-store register` + +Register remembers an existing local context store path. It is not an init +command. + +Accept: + +- An existing healthy OpenSpec root with valid `.openspec-store/store.yaml`. +- An existing healthy OpenSpec root without identity only after clear + confirmation. + +Reject: + +- Missing paths. +- Partial OpenSpec roots. +- Arbitrary directories. +- Beta-only directories. +- Invalid or mismatched context-store identity. +- Healthy roots without identity in JSON/non-interactive mode unless `--yes` + is passed. + +Mutations: + +- With existing identity, update local registry only when needed. +- With confirmed conversion, create `.openspec-store/store.yaml` and update the + local registry. +- Never create `openspec/` planning files during register. + +Interactive conversion prompt should be direct: + +```text +Turn this OpenSpec root into context store "team-context"? +``` + +### `context-store doctor` + +Doctor is the non-mutating health surface. + +It checks: + +- Registered root path exists and is a directory. +- `.openspec-store/store.yaml` exists, parses, and matches the registry id. +- `openspec/` exists. +- `openspec/config.yaml` or `openspec/config.yml` exists. +- `openspec/specs/` exists. +- `openspec/changes/` exists. +- `openspec/changes/archive/` exists. +- Git health, where existing doctor behavior already reports it. + +It does not: + +- Create missing OpenSpec directories. +- Create missing config. +- Rewrite metadata. +- Repair registry entries. +- Migrate beta files. + +## Agent / JSON Contract + +Setup and register mutation output should keep the existing `created_files` +field, but treat it as "relative paths created by this operation." It may list +directories and files. + +For a no-op success: + +```json +{ + "created_files": [], + "status": [ + { + "code": "already_registered", + "severity": "info", + "message": "Context store is already registered at this path." + } + ] +} +``` + +For doctor, each store should include a distinct `openspec_root` section beside +`metadata` and `git`: + +```json +{ + "id": "team-context", + "root": "/Users/me/src/team-context", + "openspec_root": { + "present": true, + "config": { + "present": true, + "path": "openspec/config.yaml" + }, + "specs": { + "present": true + }, + "changes": { + "present": true + }, + "archive": { + "present": false + }, + "status": [ + { + "code": "openspec_archive_missing", + "severity": "error", + "message": "Missing openspec/changes/archive/." + } + ] + }, + "metadata": {}, + "git": {} +} +``` + +Exact diagnostic wording can follow existing CLI conventions, but the JSON +shape must let agents distinguish root health from metadata and Git health. + +## Implementation Plan + +### 1. Add An OpenSpec Root Helper + +Create `src/core/openspec-root.ts`. + +Responsibilities: + +- Define canonical relative paths for a normal OpenSpec root. +- Inspect root health without mutating files. +- Return a healthy/unhealthy result with diagnostics suitable for doctor. +- Ensure the root shape for setup only. +- Create default `openspec/config.yaml` with `schema: spec-driven` when setup + needs config. +- Preserve existing `config.yaml` or `config.yml`. +- Track a created-path ledger for files and directories. +- Roll back only ledger-created files and empty directories on failure. + +This helper should know nothing about context-store registry state, Git policy, +prompts, agents, slash commands, workspaces, or initiatives. + +### 2. Share Root Scaffolding With Init Safely + +Refactor the directory and config creation pieces from `src/core/init.ts` into +the new helper where useful. + +Keep these behaviors separate: + +- `openspec init` may keep its current prompts, non-interactive config behavior, + legacy cleanup, tool detection, and generated assets. +- `context-store setup` uses only root scaffolding and default config creation. +- `context-store register` does not use root scaffolding. + +Do not call `InitCommand.execute()` from context-store operations. + +### 3. Rework Setup Operations + +Update `src/core/context-store/operations.ts` so setup classifies the target +before writing: + +- Missing path: create root and full OpenSpec shape. +- Empty path: create full OpenSpec shape. +- Git-only path: preserve `.git/`, create full OpenSpec shape. +- Healthy OpenSpec root: preserve root content, add identity if missing. +- Matching context-store identity: preserve and no-op when everything is + already healthy. +- Arbitrary non-empty path: refuse without writes. +- Nested Git path: refuse without writes for this slice. + +Then perform mutations in a safe order: + +1. Ensure the OpenSpec root shape if setup is allowed. +2. Write missing context-store identity metadata. +3. Commit the local registry update. +4. On failure, roll back only paths created in this operation. + +Update setup JSON so `created_files` includes both OpenSpec-root paths and +`.openspec-store/store.yaml` when they were created. + +### 4. Rework Register Operations + +Update register so it begins by inspecting the existing path: + +- The path must exist and be a healthy OpenSpec root. +- Existing valid `.openspec-store/store.yaml` supplies or confirms the store id. +- A healthy OpenSpec root without identity can be converted only after user + confirmation. +- JSON/non-interactive conversion requires `--yes`. +- Missing, partial, arbitrary, beta-only, invalid-metadata, or conflicting roots + fail before registry mutation. + +Register should not create `openspec/`, `config.yaml`, `specs/`, `changes/`, or +`archive/`. It only writes `.openspec-store/store.yaml` for confirmed +conversion, then updates the local registry. + +### 5. Make Idempotency Explicit + +Update registry and operation behavior so same id plus same root path is a +stable no-op success. + +Expected no-op behavior: + +- No metadata rewrite. +- No config rewrite. +- No duplicate registry entry. +- `created_files: []`. +- Human output says already registered, already exists, or nothing to change. +- JSON includes an info diagnostic or status entry that agents can interpret. + +Same id with a different path and same path under a different id should keep +the existing conflict protections unless the spec for a future replacement flow +changes that. + +### 6. Extend Doctor Output + +Extend `ContextStoreInspection` in `src/core/context-store/operations.ts` with +OpenSpec-root inspection results. + +Update `src/commands/context-store.ts` output types and printers so: + +- Human doctor output names OpenSpec-root health separately. +- JSON doctor output includes `openspec_root`. +- Metadata diagnostics remain metadata diagnostics. +- Git diagnostics remain Git diagnostics. +- Doctor never calls the root ensure/scaffold helper. + +### 7. Remove Old Initiative-Oriented Guidance + +Update setup/register human output and help text in `src/commands/context-store.ts` +so the next step points toward normal OpenSpec specs and changes. + +Avoid language like: + +- "create an initiative" +- "workspace planning" +- "collections" +- generated agent/tool setup + +Use language like: + +- "Use normal OpenSpec specs and changes in this store." +- "This store is a standalone OpenSpec root." + +### 8. Keep Old Beta Files Ignored + +Do not add migration or cleanup logic for old beta files. + +If old beta files exist inside an otherwise healthy root, setup/register should +leave them byte-for-byte unchanged. + +If old beta files are the only signal in a directory, setup/register should not +treat that directory as healthy or registered. The folder is still arbitrary +non-empty input unless the new root shape is present. + +## Test Plan + +### Root Helper Tests + +Add focused helper coverage, likely in `test/core/openspec-root.test.ts`: + +- Healthy root with `config.yaml`. +- Healthy root with `config.yml`. +- Missing config. +- Missing `specs/`. +- Missing `changes/`. +- Missing `changes/archive/`. +- Ensure creates root shape and default config. +- Ensure preserves existing config and user-authored files. +- Rollback removes only ledger-created files and empty directories. + +### Command Tests + +Update `test/commands/context-store.test.ts`: + +- Setup JSON for a missing directory expects the full root shape and + `created_files`. +- Setup accepts an empty directory. +- Setup accepts a Git-only directory and preserves `.git/`. +- Setup preserves an existing healthy OpenSpec root and config edits. +- Setup creates config in JSON/non-interactive mode without tool selection. +- Setup rejects arbitrary non-empty folders and creates no OpenSpec files. +- Setup rejects nested Git paths, including the old interactive override path. +- Registering a plain folder now fails. +- Registering a cloned healthy context store succeeds without planning-file + mutation. +- Registering a healthy root without identity prompts for conversion. +- Declining conversion writes nothing. +- JSON/non-interactive conversion without `--yes` refuses. +- JSON/non-interactive conversion with `--yes` writes identity and registry. +- Repeating setup/register produces `created_files: []` and no duplicate + registry entry. +- Setup/register do not create `initiatives/`, `.openspec-workspace/`, + `workspace.yaml`, `AGENTS.md`, `.codex/`, `.claude/`, or `.cursor/`. +- Old beta files inside healthy roots are ignored and preserved. +- Beta-only folders are rejected as unsafe or non-root. +- Doctor JSON includes `openspec_root` separate from `metadata` and `git`. +- Doctor reports missing archive under `openspec_root` without creating it. + +### Core Context-Store Tests + +Add or update operation-level tests around: + +- `prepareContextStoreSetup`. +- `setupPreparedContextStore`. +- `registerExistingContextStore`. +- `doctorContextStores`. +- Registry no-op behavior for same id and same path. +- Registry conflict behavior for same id different path and same path different + id. +- Failure cleanup when registry commit fails after setup/register created files. + +### Regression Tests + +Keep existing init and workspace tests honest: + +- `openspec init` still creates its expected files and generated assets. +- Context-store setup/register do not accidentally inherit those generated + assets. +- Existing metadata validation tests still enforce the thin identity shape. + +## Verification + +Run targeted tests first: + +```bash +pnpm exec vitest run test/core/openspec-root.test.ts +pnpm exec vitest run test/core/context-store/registry.test.ts +pnpm exec vitest run test/commands/context-store.test.ts +pnpm exec vitest run test/core/init.test.ts +``` + +Then run the broader repo checks: + +```bash +pnpm test +pnpm run build +``` + +## Main Risks + +- Rollback is the easiest place to damage user trust. Use a ledger and remove + only files/directories created by the current operation. +- Register currently accepts arbitrary folders. Changing that behavior is + intentional, but tests and user-facing errors need to make the new rule clear. +- Nested Git rejection is locked for this slice but may change later. Keep the + check small and easy to replace. +- Full `openspec init` is tempting to reuse, but it carries unrelated behavior. + Use only root scaffolding. +- JSON shape changes should be explicit enough for agents while preserving + existing fields where practical. + +## Done When + +- A fresh setup leaves a normal OpenSpec root plus + `.openspec-store/store.yaml`. +- Setup accepts Git-only directories and existing healthy roots. +- Setup rejects arbitrary non-empty folders and nested Git paths without writes. +- Register succeeds for cloned context stores with existing identity metadata. +- Register can turn a healthy OpenSpec root into a context store only after + confirmation. +- Register refuses missing, partial, arbitrary, beta-only, or unconfirmed roots + without writes. +- Doctor reports `openspec_root`, `metadata`, and `git` as separate health + areas. +- Re-running setup/register is a no-op success for the same healthy id and path. +- User-authored config, specs, changes, archives, identity metadata, and old + beta files are preserved. +- Setup/register do not create initiative, workspace, agent, slash-command, or + tool-generation artifacts. +- Targeted tests, `pnpm test`, and `pnpm run build` pass. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/spec.md new file mode 100644 index 000000000..b8592e816 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/spec.md @@ -0,0 +1,272 @@ +# Context Store As Standalone OpenSpec Root Spec + +## Outcome + +`context-store setup` and `context-store register` treat a context store as a +normal standalone OpenSpec root with a thin identity file. + +After setup or registration, the durable planning state lives in normal +OpenSpec artifacts: config, specs, changes, and archived changes. The +`.openspec-store/` directory remains identity or local registry metadata, not a +separate planning model. + +The existing beta context-store, initiative, and workspace shapes are not a +compatibility contract. This slice ignores old beta files unless they are the +thin `.openspec-store/store.yaml` identity file used by the new model. + +## User Experience + +A human or agent can create or register a standalone OpenSpec repo and then see +the same root shape they would expect from a normal OpenSpec project: + +```text +context-store-root/ + .openspec-store/ + store.yaml + openspec/ + config.yaml + specs/ + changes/ + archive/ +``` + +The command output and help point users toward normal OpenSpec specs and +changes, not initiatives, workspace-owned planning, generated agent files, or +collection-specific state. + +In plain terms: + +```text +context store = normal OpenSpec root + .openspec-store/store.yaml +``` + +## Scope + +In scope: + +- Root shape parity for `context-store setup` and `context-store register`. +- Default config creation during setup. +- Safe handling of missing, empty, Git-only, and existing healthy OpenSpec-root + directories. +- Registering cloned or existing context stores on the local machine. +- Turning a healthy standalone OpenSpec root into a context store only after + clear user confirmation. +- Separate `context-store doctor` reporting for OpenSpec-root health. +- Tests that verify setup, register, doctor, idempotency for the new model, and + unsafe-folder behavior. + +Out of scope: + +- Store selectors for core lifecycle commands. +- Creating initiative links or initiative collections. +- Workspace-owned planning behavior. +- Agent/tool installation, generated commands, migration, or onboarding flows. +- Clone, pull, push, sync, branch, worktree, dashboard, apply, verify, or archive + orchestration. +- Migrating, preserving, or cleaning up old beta context-store, initiative, or + workspace file shapes. +- Public terminology cleanup or broad documentation rewrites. + +## Acceptance Criteria + +### Setup Ensures A Normal Root + +`context-store setup` creates or preserves a healthy OpenSpec root. A healthy +OpenSpec root contains `openspec/`, a config file +(`openspec/config.yaml` or `openspec/config.yml`), `openspec/specs/`, +`openspec/changes/`, and `openspec/changes/archive/`. + +When setup creates a config file, it creates `openspec/config.yaml` with the +default `spec-driven` schema. + +#### Scenario: Setting Up A Missing Or Empty Store + +- **GIVEN** a missing directory or empty directory +- **WHEN** the user runs `context-store setup` +- **THEN** OpenSpec leaves the directory with `.openspec-store/store.yaml` +- **AND** `openspec/config.yaml` exists with the default `spec-driven` schema +- **AND** `openspec/specs/`, `openspec/changes/`, and + `openspec/changes/archive/` exist +- **AND** JSON output reports the relative paths created by the operation in + `created_files` + +#### Scenario: Accepting A Git-Only Directory + +- **GIVEN** an existing directory that contains only `.git/` +- **WHEN** the user runs `context-store setup` +- **THEN** OpenSpec treats the directory as a safe fresh store +- **AND** OpenSpec preserves `.git/` +- **AND** OpenSpec creates the context-store identity metadata and healthy + OpenSpec root + +#### Scenario: Preserving An Existing Healthy Root + +- **GIVEN** an initialized standalone OpenSpec root +- **WHEN** the user runs `context-store setup` +- **THEN** OpenSpec preserves existing config, specs, changes, and archived + changes +- **AND** OpenSpec creates `.openspec-store/store.yaml` when identity metadata + is missing + +#### Scenario: Creating Default Config Non-Interactively + +- **GIVEN** setup runs in non-interactive or JSON mode without tool selection +- **AND** no `openspec/config.yaml` or `openspec/config.yml` exists +- **WHEN** setup completes successfully +- **THEN** `openspec/config.yaml` exists with the default `spec-driven` schema + +#### Scenario: Preserving Existing Config + +- **GIVEN** `openspec/config.yaml` or `openspec/config.yml` already exists +- **WHEN** setup completes successfully +- **THEN** OpenSpec preserves the existing config file + +#### Scenario: Rejecting Unsafe Folders + +- **GIVEN** an arbitrary non-empty unmarked folder +- **WHEN** the user runs `context-store setup` +- **THEN** OpenSpec rejects it without treating it as a store root +- **AND** it does not create context-store metadata or OpenSpec-root files in + that folder + +#### Scenario: Rejecting Nested Git Setup Paths + +- **GIVEN** a setup target path inside another Git repository +- **WHEN** the user runs `context-store setup` +- **THEN** OpenSpec rejects the path as unsafe for this slice +- **AND** it does not create context-store metadata or OpenSpec-root files in + that path + +### Register Requires An Existing Root + +`context-store register` remembers a local clone or existing local root on this +machine. It does not initialize planning files. + +#### Scenario: Registering A Cloned Context Store + +- **GIVEN** an existing healthy OpenSpec root with `.openspec-store/store.yaml` +- **WHEN** the user runs `context-store register` +- **THEN** OpenSpec registers it +- **AND** OpenSpec writes local registry state only when needed +- **AND** OpenSpec does not create or rewrite OpenSpec planning files + +#### Scenario: Turning A Healthy Root Into A Context Store + +- **GIVEN** an existing healthy OpenSpec root without `.openspec-store/store.yaml` +- **WHEN** the user runs `context-store register` +- **THEN** OpenSpec asks whether to turn the root into the named context store +- **AND** if the user confirms, OpenSpec creates `.openspec-store/store.yaml` + and registers the store locally +- **AND** if the user declines, OpenSpec does not write metadata or registry + state + +#### Scenario: Refusing Unconfirmed Non-Interactive Conversion + +- **GIVEN** an existing healthy OpenSpec root without `.openspec-store/store.yaml` +- **WHEN** the user runs `context-store register` in non-interactive or JSON mode + without explicit confirmation +- **THEN** OpenSpec refuses to convert the root into a context store +- **AND** OpenSpec does not write metadata or registry state + +#### Scenario: Refusing Arbitrary Directories + +- **GIVEN** a missing directory, partial OpenSpec root, or existing directory + that is not a healthy OpenSpec root +- **WHEN** the user runs `context-store register` +- **THEN** OpenSpec refuses to register it +- **AND** OpenSpec does not silently initialize it as an OpenSpec root +- **AND** OpenSpec does not create `.openspec-store/store.yaml` or local + registry state + +### Metadata Stays Thin + +Context-store metadata remains identity or registry metadata only. + +#### Scenario: Avoiding Old Planning Models In This Slice + +- **WHEN** setup or register completes +- **THEN** OpenSpec does not create initiative links, initiative collections, or + workspace-owned planning state +- **AND** OpenSpec does not install generated agent skills, slash commands, or + tool configuration files into the store +- **AND** OpenSpec does not run full `openspec init`, tool detection, legacy + cleanup, migration, skill generation, command generation, or onboarding flows + +#### Scenario: Ignoring Old Beta Files + +- **GIVEN** a directory contains old beta files such as `initiatives/`, + `.openspec-workspace/`, `workspace.yaml`, `AGENTS.md`, `.codex/`, `.claude/`, + or `.cursor/` +- **WHEN** setup or register succeeds for the new model +- **THEN** OpenSpec ignores those files for this slice +- **AND** OpenSpec does not migrate, upgrade, delete, or repair those files +- **AND** OpenSpec does not treat those files as proof that the folder is a + healthy OpenSpec root or valid context store +- **AND** OpenSpec does not preserve old beta planning behavior as a requirement + +#### Scenario: Validating Thin Identity Metadata + +- **GIVEN** `.openspec-store/store.yaml` exists +- **WHEN** setup, register, or doctor reads it +- **THEN** OpenSpec treats it as the context-store identity file +- **AND** the file must match the thin identity shape for the new model +- **AND** invalid or mismatched identity metadata is reported as a metadata issue + +### Doctor Separates Root Health + +`context-store doctor` reports OpenSpec-root health separately from +context-store metadata and Git health. In JSON output, each store includes a +distinct `openspec_root` section. + +#### Scenario: Reporting OpenSpec Root Health + +- **WHEN** doctor inspects a context store +- **THEN** the report covers the `openspec/` directory, + `openspec/config.yaml` or `openspec/config.yml`, `openspec/specs/`, + `openspec/changes/`, and `openspec/changes/archive/` +- **AND** root-health issues are distinguishable from metadata and Git issues in + human and JSON output +- **AND** JSON output includes `openspec_root` separately from `metadata` and + `git` +- **AND** doctor does not mutate files + +#### Scenario: Reporting Without Repairing + +- **GIVEN** a registered context store has valid metadata and Git state but is + missing `openspec/changes/archive/` +- **WHEN** doctor inspects the context store +- **THEN** doctor reports the missing archive directory under `openspec_root` +- **AND** doctor does not create `openspec/changes/archive/` + +### Safety, Not Beta Compatibility + +This slice protects user-authored files and repeatable command behavior. It does +not treat previous beta context-store behavior as a stable surface. + +#### Scenario: Repeating Setup Or Register + +- **GIVEN** the same context-store id and path are already registered and the + OpenSpec root is healthy +- **WHEN** setup or register runs again for that root +- **THEN** OpenSpec reports that the store is already registered, already exists, + or has nothing to change +- **AND** OpenSpec does not mutate files just to prove the command worked +- **AND** JSON output reports no newly created files for the no-op operation +- **AND** OpenSpec does not duplicate registry entries + +#### Scenario: Preserving User Edits Across Reruns + +- **GIVEN** the user edits `openspec/config.yaml` or `openspec/config.yml` after + setup +- **WHEN** setup or register runs again for that root +- **THEN** OpenSpec preserves the edited config file +- **AND** OpenSpec preserves user-authored specs, changes, archived changes, and + valid identity metadata + +#### Scenario: Preserving User Content On Failure + +- **GIVEN** setup or register creates files or directories during an operation +- **WHEN** the operation fails before completion +- **THEN** OpenSpec removes only files and empty directories it created during + that operation +- **AND** OpenSpec preserves unrelated user content diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/user-facing-review.html b/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/user-facing-review.html new file mode 100644 index 000000000..66c99c2d4 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/user-facing-review.html @@ -0,0 +1,1347 @@ + + + + + + Context Store Review - User Facing Scenarios + + + +
+
+
+ +
+ Context Store Review + User-facing scenario contract +
+
+ +
+
+ +
+
+
+

Spec before plan

+

Make setup feel boring.

+

A user should be able to point OpenSpec at a folder and know exactly what will happen: normal OpenSpec files appear, thin identity metadata is added, and nothing else gets quietly installed or rewritten.

+
+ Explore the scenarios -> +
+ Intent clear + Oracles need sharpening + Plan after spec edits +
+
+
+ +
+ +
+
The user promise
+

A context store is not a new planning world.

+

It is a named standalone OpenSpec repo. The real planning state is still the normal OpenSpec shape users already understand: config, specs, changes, and archived changes.

+ +
+
+ Predictable files +

Setup creates the same OpenSpec root shape every time. Register only remembers roots that already exist.

+
+
+ Thin identity +

The `.openspec-store/` folder names the store. It does not become a second planning model.

+
+
+ No surprise tooling +

Setting up storage does not install agent skills, slash commands, migrations, or editor configuration.

+
+
+ +
+
+
Before the user runs setupsafe starting points
+
missing folder
+
+empty folder/
+
+git-only folder/
+  .git/
+
+already-open-spec/
+  openspec/
+    config.yaml
+    specs/
+    changes/
+      archive/
+
+
+
After setup succeedsnormal OpenSpec root
+
context-store-root/
+  .openspec-store/
+    store.yaml
+  openspec/
+    config.yaml
+    specs/
+    changes/
+      archive/
+
+
+
+ +
+
Scenario explorer
+

What changes for the user?

+

Pick a scenario. Each one shows the fuzzy version of the spec beside the clearer user-facing contract.

+ +
+
+ + + + + + +
+ +
+
+

+

+
+
+
Before spec editfuzzy
+

+
+
+
After spec editcheckable
+

+
+
+
+
User expectation
+
Surprise avoided
+
Test oracle
+
+
+
+ + + +
+
+
+
+
before
+

+              
+
+
after
+

+              
+
+
+
+
+
+
+
+
+
+
+
+ +
+
Doctor output
+

Tell users which layer is broken.

+

`context-store doctor` should not flatten every problem into one status list. The user needs to know whether the identity metadata, Git repo, or OpenSpec root is unhealthy.

+ +
+
+

Metadata identity

+
    +
  • Store id matches registry
  • +
  • Missing store.yaml is metadata trouble
  • +
+
+
+

Git transport

+
    +
  • Repository detected
  • +
  • No automatic clone or sync promised
  • +
+
+
+

OpenSpec root new

+
    +
  • openspec/ exists
  • +
  • config.yaml or config.yml exists
  • +
  • Missing changes/archive is root health
  • +
+
+
+ +
+
{
+  "context_stores": [
+    {
+      "id": "team-context",
+      "metadata": { "status": [] },
+      "git": { "is_repository": true, "status": [] },
+      "openspec_root": {
+        "path": "/stores/team-context/openspec",
+        "config": "config.yaml",
+        "specs": true,
+        "changes": true,
+        "archive": false,
+        "status": [
+          {
+            "code": "openspec_root_archive_missing",
+            "message": "openspec/changes/archive/ is missing."
+          }
+        ]
+      }
+    }
+  ]
+}
+
+
+ +
+
Recommended spec edits
+

The short list, translated into product promises.

+

These edits do not change the product direction. They make the user's expectations and the test author's checks line up.

+ +
+
+ 1 +
Rename the spec around the user model.

Use a title like "Context Store As Standalone OpenSpec Root" so the slice is not mistaken for beta store preservation.

+ Polish +
+
+ 2 +
Split setup starting points.

Missing, empty, Git-only, and already-initialized roots should each have a clear outcome.

+ Load-bearing +
+
+ 3 +
Define a healthy root.

Spell out `.openspec-store/store.yaml`, `openspec/config.yaml`, specs, changes, and archive.

+ Oracle +
+
+ 4 +
Pin non-interactive config creation.

JSON setup with no tool selection must still create `openspec/config.yaml` with the `spec-driven` schema.

+ Load-bearing +
+
+ 5 +
Make register refusal safe.

Register should fail on non-root folders before writing metadata or registry state.

+ Load-bearing +
+
+ 6 +
Forbid setup side effects.

No full init, tool detection, legacy cleanup, migration, skill generation, slash commands, or editor files.

+ Load-bearing +
+
+ 7 +
Name the doctor output layer.

Add an `openspec_root` JSON section and require doctor to report, not repair, root-health issues.

+ Oracle +
+
+ 8 +
Make beta metadata deterministic.

Either list supported old shapes or move normalization out of acceptance criteria.

+ Oracle +
+
+ 9 +
Lock idempotency and rollback.

User-edited config survives reruns. Failed setup removes only paths created during that operation.

+ Load-bearing +
+
+
+ +
+
Plan readiness
+

After these edits, plan.md can be boring too.

+
+
+ Step 1 + Sharpen spec oracles +

Every scenario says what the user sees and what files are touched.

+
+
+ Step 2 + Write plan.md +

The plan can focus on implementation shape instead of re-deciding product behavior.

+
+
+ Step 3 + Build tests from scenarios +

Each test follows a named product promise.

+
+
+ Step 4 + Implement safely +

Users get normal OpenSpec roots without surprise beta or tooling behavior.

+
+
+
+
+ +
+
+ Bottom line: the spec already has the right product thesis. These edits make the command behavior boring, predictable, and safe enough for users to trust. +
+
+ + + + diff --git a/src/commands/context-store.ts b/src/commands/context-store.ts index b9ad53230..baf1b39cd 100644 --- a/src/commands/context-store.ts +++ b/src/commands/context-store.ts @@ -33,6 +33,7 @@ interface ContextStoreSetupOptions { interface ContextStoreRegisterOptions { id?: string; + yes?: boolean; json?: boolean; } @@ -60,6 +61,7 @@ interface ContextStoreMutationOutput { registry: { path: string; registered: boolean; + already_registered: boolean; } | null; git: { is_repository: boolean; @@ -88,7 +90,12 @@ interface ContextStoreListOutput { status: ContextStoreDiagnostic[]; } +type OpenSpecRootOutput = Omit & { + status: ContextStoreDiagnostic[]; +}; + interface ContextStoreDoctorStoreOutput extends ContextStoreOutput { + openspec_root: OpenSpecRootOutput; metadata: ContextStoreInspection['metadata']; git: { is_repository: boolean | null; @@ -132,14 +139,15 @@ function toMutationOutput(result: ContextStoreMutationResult): ContextStoreMutat context_store: toStoreOutput(result.store), registry: { path: result.registryCommit.path, - registered: true, + registered: result.registryCommit.registered, + already_registered: result.registryCommit.alreadyRegistered, }, git: { is_repository: result.git.isRepository, initialized: result.git.initialized, }, created_files: result.createdArtifacts, - status: [], + status: result.diagnostics, }; } @@ -166,9 +174,22 @@ function toListOutput(result: ContextStoreListResult): ContextStoreListOutput { }; } +function toOpenSpecRootOutput(root: ContextStoreInspection['openspecRoot']): OpenSpecRootOutput { + return { + present: root.present, + config: root.config, + specs: root.specs, + changes: root.changes, + archive: root.archive, + healthy: root.healthy, + status: root.diagnostics, + }; +} + function toDoctorStoreOutput(store: ContextStoreInspection): ContextStoreDoctorStoreOutput { return { ...toStoreOutput(store), + openspec_root: toOpenSpecRootOutput(store.openspecRoot), metadata: store.metadata, git: { is_repository: store.git.isRepository, @@ -263,13 +284,6 @@ async function promptContextStorePath(id: string): Promise { }); } -function isSetupInsideGitRepositoryError(error: unknown): boolean { - return ( - error instanceof ContextStoreError && - error.diagnostic.code === 'context_store_setup_inside_git_repo' - ); -} - async function resolveSetupInput( id: string | undefined, options: ContextStoreSetupOptions @@ -300,37 +314,9 @@ async function resolveSetupInput( async function prepareSetupInput( input: ResolvedContextStoreSetupInput, - options: ContextStoreSetupOptions + _options: ContextStoreSetupOptions ) { - try { - return await prepareContextStoreSetup(input); - } catch (error) { - if (!isSetupInsideGitRepositoryError(error) || options.json || !isInteractive()) { - throw error; - } - - const { confirm } = await import('@inquirer/prompts'); - const shouldContinue = await confirm({ - message: `${asErrorMessage(error)}. Use this location anyway?`, - default: false, - }); - - if (!shouldContinue) { - throw new ContextStoreError( - 'Context store setup cancelled.', - 'context_store_setup_cancelled', - { - target: 'context_store.root', - fix: 'Choose another path or rerun setup later.', - } - ); - } - - return prepareContextStoreSetup({ - ...input, - allowInsideGitRepository: true, - }); - } + return prepareContextStoreSetup(input); } async function confirmSetup( @@ -396,6 +382,32 @@ async function confirmRemove(id: string, root: string, options: ContextStoreRemo } } +function isRegisterIdentityConfirmationError(error: unknown): boolean { + return ( + error instanceof ContextStoreError && + error.diagnostic.code === 'context_store_register_identity_confirmation_required' + ); +} + +async function confirmRegisterConversion(error: unknown): Promise { + const { confirm } = await import('@inquirer/prompts'); + const confirmed = await confirm({ + message: asErrorMessage(error), + default: false, + }); + + if (!confirmed) { + throw new ContextStoreError( + 'Context store register cancelled.', + 'context_store_register_cancelled', + { + target: 'context_store.metadata', + fix: 'Rerun register when you are ready to create context-store identity metadata.', + } + ); + } +} + function printMutationHuman(title: string, payload: ContextStoreMutationOutput): void { if (!payload.context_store || !payload.registry || !payload.git) { return; @@ -403,8 +415,13 @@ function printMutationHuman(title: string, payload: ContextStoreMutationOutput): console.log(`${title}: ${payload.context_store.id}`); console.log(`Location: ${formatPathForHuman(payload.context_store.root)}`); + console.log('OpenSpec root: ready'); + console.log(`Registry: ${payload.registry.already_registered ? 'already registered' : 'registered'}`); + for (const status of payload.status) { + console.log(`${status.severity === 'error' ? 'Issue' : 'Note'}: ${status.message}`); + } console.log(''); - console.log(`Next: ask your agent to create an initiative in ${payload.context_store.id}.`); + console.log('Next: use normal OpenSpec specs and changes in this store.'); } function printCleanupHuman(title: string, payload: ContextStoreCleanupOutput): void { @@ -423,7 +440,7 @@ function printCleanupHuman(title: string, payload: ContextStoreCleanupOutput): v } for (const status of payload.status) { - console.log(`${status.severity === 'warning' ? 'Note' : 'Issue'}: ${status.message}`); + console.log(`${status.severity === 'error' ? 'Issue' : 'Note'}: ${status.message}`); } } @@ -457,6 +474,13 @@ function formatDoctorGitHuman(store: ContextStoreDoctorOutput['context_stores'][ return store.git.is_repository ? 'repository detected' : 'not detected'; } +function formatOpenSpecRootHuman(store: ContextStoreDoctorOutput['context_stores'][number]): string { + if (store.openspec_root.healthy) return 'ok'; + if (store.openspec_root.present === false) return 'missing'; + if (store.openspec_root.present === null) return 'unknown'; + return 'incomplete'; +} + function printDoctorHuman(payload: ContextStoreDoctorOutput): void { if (payload.context_stores.length === 0) { console.log('No context stores registered.'); @@ -468,6 +492,7 @@ function printDoctorHuman(payload: ContextStoreDoctorOutput): void { console.log(''); console.log(store.id); console.log(` Location: ${store.root}`); + console.log(` OpenSpec root: ${formatOpenSpecRootHuman(store)}`); console.log(` Metadata: ${formatMetadataHuman(store)}`); console.log(` Git: ${formatDoctorGitHuman(store)}`); @@ -516,10 +541,27 @@ class ContextStoreCommand { async register(inputPath: string | undefined, options: ContextStoreRegisterOptions = {}): Promise { try { - const payload = toMutationOutput(await registerExistingContextStore({ - path: inputPath, - id: options.id, - })); + let result: ContextStoreMutationResult; + try { + result = await registerExistingContextStore({ + path: inputPath, + id: options.id, + allowCreateIdentity: options.yes, + }); + } catch (error) { + if (!isRegisterIdentityConfirmationError(error) || options.json || !isInteractive()) { + throw error; + } + + await confirmRegisterConversion(error); + result = await registerExistingContextStore({ + path: inputPath, + id: options.id, + allowCreateIdentity: true, + }); + } + + const payload = toMutationOutput(result); if (options.json) { printJson(payload); @@ -653,6 +695,7 @@ export function registerContextStoreCommand(program: Command): void { .command('register [path]') .description('Register an existing local context store') .option('--id ', 'Context store id; defaults to metadata or folder name') + .option('--yes', 'Confirm creating context-store identity metadata for a healthy OpenSpec root') .option('--json', 'Output as JSON') .action(async (inputPath: string | undefined, options: ContextStoreRegisterOptions) => { await contextStoreCommand.register(inputPath, options); diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 88ec88e05..a15f28adf 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -494,6 +494,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Context store id', takesValue: true, }, + { + name: 'yes', + description: 'Confirm creating context-store identity metadata', + }, COMMON_FLAGS.json, ], }, diff --git a/src/core/context-store/errors.ts b/src/core/context-store/errors.ts index 708e23e73..e5bd92bee 100644 --- a/src/core/context-store/errors.ts +++ b/src/core/context-store/errors.ts @@ -1,4 +1,4 @@ -export type ContextStoreDiagnosticSeverity = 'error' | 'warning'; +export type ContextStoreDiagnosticSeverity = 'error' | 'warning' | 'info'; export interface ContextStoreDiagnostic { severity: ContextStoreDiagnosticSeverity; diff --git a/src/core/context-store/operations.ts b/src/core/context-store/operations.ts index c61a3e07e..b4e414df9 100644 --- a/src/core/context-store/operations.ts +++ b/src/core/context-store/operations.ts @@ -5,6 +5,13 @@ import * as path from 'node:path'; import { promisify } from 'node:util'; import { FileSystemUtils } from '../../utils/file-system.js'; +import { + ensureOpenSpecRoot, + inspectOpenSpecRoot, + rollbackCreatedPaths, + type CreatedPathLedgerEntry, + type OpenSpecRootInspection, +} from '../openspec-root.js'; import { getDefaultContextStoreRoot, getContextStoreMetadataPath, @@ -43,12 +50,15 @@ export interface ContextStoreMutationResult { store: ContextStoreInfo; registryCommit: { path: string; + registered: boolean; + alreadyRegistered: boolean; }; git: { isRepository: boolean; initialized: boolean; }; createdArtifacts: string[]; + diagnostics: ContextStoreDiagnostic[]; } export interface ContextStoreCleanupResult { @@ -75,6 +85,7 @@ export interface ContextStoreDoctorResult { } export interface ContextStoreInspection extends ContextStoreInfo { + openspecRoot: OpenSpecRootInspection; metadata: { present: boolean | null; valid: boolean | null; @@ -96,6 +107,7 @@ export interface SetupContextStoreInput { export interface RegisterExistingContextStoreInput { path?: string; id?: string; + allowCreateIdentity?: boolean; } export interface CleanupContextStoreInput extends ContextStorePathOptions { @@ -166,6 +178,30 @@ async function isGitRepositoryAtRoot(storeRoot: string): Promise { return kind === 'directory' || kind === 'file'; } +async function isGitOnlyDirectory(storeRoot: string): Promise { + const entries = await fs.readdir(storeRoot); + return entries.length === 1 && entries[0] === '.git' && await isGitRepositoryAtRoot(storeRoot); +} + +function alreadyRegisteredDiagnostic(id: string): ContextStoreDiagnostic { + return makeContextStoreDiagnostic( + 'info', + 'context_store_already_registered', + `Context store '${id}' is already registered at this path.`, + { + target: 'context_store.registry', + } + ); +} + +function createdPath(relativePath: string, absolutePath: string, kind: CreatedPathLedgerEntry['kind']): CreatedPathLedgerEntry { + return { + relativePath, + absolutePath, + kind, + }; +} + async function nearestExistingDirectory(targetPath: string): Promise { let current = path.resolve(targetPath); @@ -233,7 +269,7 @@ async function assertSetupPathIsNotNestedInGitRepo( 'context_store_setup_inside_git_repo', { target: 'context_store.root', - fix: 'Choose the managed OpenSpec location, choose a path outside that Git repository, or rerun setup interactively to confirm this location.', + fix: 'Choose the managed OpenSpec location or choose a path outside that Git repository.', } ); } @@ -303,7 +339,9 @@ function mutationPayload( id: string, storeRoot: string, git: { isRepository: boolean; initialized: boolean }, - createdFiles: string[] + createdFiles: string[], + registry: { registered: boolean; alreadyRegistered: boolean }, + diagnostics: ContextStoreDiagnostic[] = [] ): ContextStoreMutationResult { return { store: { @@ -313,12 +351,15 @@ function mutationPayload( }, registryCommit: { path: getContextStoreRegistryPath(), + registered: registry.registered, + alreadyRegistered: registry.alreadyRegistered, }, git: { isRepository: git.isRepository, initialized: git.initialized, }, createdArtifacts: createdFiles, + diagnostics, }; } @@ -363,15 +404,19 @@ async function prepareSetupPlan( } ); } - } else if (!(await isDirectoryEmpty(storeRoot))) { - throw new ContextStoreError( - 'Context store setup does not support initializing a non-empty folder yet.', - 'context_store_setup_non_empty_directory', - { - target: 'context_store.root', - fix: 'Create an empty folder or use context-store register for an existing context store.', - } - ); + } else { + const openspecRoot = await inspectOpenSpecRoot(storeRoot); + const safeFreshDirectory = await isDirectoryEmpty(storeRoot) || await isGitOnlyDirectory(storeRoot); + if (!openspecRoot.healthy && !safeFreshDirectory) { + throw new ContextStoreError( + 'Context store setup does not support initializing a non-empty folder that is not a healthy OpenSpec root.', + 'context_store_setup_non_empty_directory', + { + target: 'context_store.root', + fix: 'Choose an empty folder, a Git-only folder, or an existing healthy OpenSpec root.', + } + ); + } } backend = await resolveGitContextStoreBackendConfig({ localPath: storeRoot }); @@ -422,18 +467,19 @@ export async function setupPreparedContextStore( const { id, storeRoot, kind, registry } = plan; let { backend } = plan; const createdFiles: string[] = []; + let createdPaths: CreatedPathLedgerEntry[] = []; const initGit = input.initGit ?? false; - if (kind === 'missing') { - await fs.mkdir(storeRoot, { recursive: true }); - } - try { + const root = await ensureOpenSpecRoot(storeRoot); + createdFiles.push(...root.createdArtifacts); + createdPaths = root.createdPaths; backend ??= await resolveGitContextStoreBackendConfig({ localPath: storeRoot }); assertNoRegisteredStoreConflict(registry, id, backend); const gitInitialized = initGit ? await initGitRepository(storeRoot) : false; + const isRepository = await isGitRepositoryAtRoot(storeRoot); const registered = await commitContextStoreRegistration({ id, backend, @@ -442,14 +488,24 @@ export async function setupPreparedContextStore( if (registered.metadataCreated) { createdFiles.push('.openspec-store/store.yaml'); } - const isRepository = await isGitRepositoryAtRoot(registered.storeRoot); + const diagnostics = registered.alreadyRegistered && createdFiles.length === 0 + ? [alreadyRegisteredDiagnostic(id)] + : []; return mutationPayload(id, registered.storeRoot, { isRepository, initialized: gitInitialized, - }, createdFiles); + }, createdFiles, { + registered: registered.registryUpdated, + alreadyRegistered: registered.alreadyRegistered, + }, diagnostics); } catch (error) { - if (kind === 'missing') { + if (createdPaths.length > 0) { + await rollbackCreatedPaths(createdPaths); + if (kind === 'missing') { + await fs.rmdir(storeRoot).catch(() => undefined); + } + } else if (kind === 'missing') { await fs.rm(storeRoot, { recursive: true, force: true }); } @@ -493,6 +549,18 @@ export async function registerExistingContextStore( ); } + const openspecRoot = await inspectOpenSpecRoot(storeRoot); + if (!openspecRoot.healthy) { + throw new ContextStoreError( + 'Context store register requires an existing healthy OpenSpec root.', + 'context_store_register_root_unhealthy', + { + target: 'openspec.root', + fix: 'Register a cloned context store or run setup for a new context store root.', + } + ); + } + const metadata = await readStoreMetadataForOperation(storeRoot); const explicitId = input.id !== undefined ? validateContextStoreId(input.id) : undefined; @@ -508,10 +576,22 @@ export async function registerExistingContextStore( } const id = metadata?.id ?? explicitId ?? inferStoreIdFromPath(storeRoot); + if (!metadata && !input.allowCreateIdentity) { + throw new ContextStoreError( + `Turn this OpenSpec root into context store '${id}'?`, + 'context_store_register_identity_confirmation_required', + { + target: 'context_store.metadata', + fix: `Run interactively or pass --yes to create ${getContextStoreMetadataPath(storeRoot)}.`, + } + ); + } + const backend = await resolveGitContextStoreBackendConfig({ localPath: storeRoot }); const registry = await readContextStoreRegistryState(); assertNoRegisteredStoreConflict(registry, id, backend); const createdFiles: string[] = []; + const isRepository = await isGitRepositoryAtRoot(storeRoot); const registered = await commitContextStoreRegistration({ id, @@ -521,11 +601,17 @@ export async function registerExistingContextStore( if (registered.metadataCreated) { createdFiles.push('.openspec-store/store.yaml'); } + const diagnostics = registered.alreadyRegistered && createdFiles.length === 0 + ? [alreadyRegisteredDiagnostic(id)] + : []; return mutationPayload(id, registered.storeRoot, { - isRepository: await isGitRepositoryAtRoot(registered.storeRoot), + isRepository, initialized: false, - }, createdFiles); + }, createdFiles, { + registered: registered.registryUpdated, + alreadyRegistered: registered.alreadyRegistered, + }, diagnostics); } function cleanupStoreOutput(id: string, storeRoot: string): ContextStoreInfo { @@ -713,6 +799,7 @@ async function inspectContextStore(entry: { let git: ContextStoreInspection['git'] = { isRepository: null, }; + let openspecRoot: OpenSpecRootInspection = await inspectOpenSpecRoot(root); if (kind === 'missing') { diagnostics.push(makeContextStoreDiagnostic( @@ -735,6 +822,9 @@ async function inspectContextStore(entry: { } )); } else { + openspecRoot = await inspectOpenSpecRoot(root); + diagnostics.push(...openspecRoot.diagnostics); + try { const parsed = await readOptionalContextStoreMetadataState(root); if (!parsed) { @@ -781,6 +871,7 @@ async function inspectContextStore(entry: { id: entry.id, root, metadataPath, + openspecRoot, metadata, git, diagnostics, diff --git a/src/core/context-store/registry.ts b/src/core/context-store/registry.ts index 0544c37f8..1f76df9e0 100644 --- a/src/core/context-store/registry.ts +++ b/src/core/context-store/registry.ts @@ -55,6 +55,8 @@ export interface ResolvedContextStore { export interface ContextStoreRegistrationCommit extends ResolvedContextStore { metadataCreated: boolean; + registryUpdated: boolean; + alreadyRegistered: boolean; } export interface CommitContextStoreRegistrationInput extends ContextStorePathOptions { @@ -260,6 +262,21 @@ export async function commitContextStoreRegistration( metadataCreated = await ensureStoreMetadata(storeRoot, id, { writeIfMissing: input.writeMetadataIfMissing, }); + const registry = await readContextStoreRegistryState({ + globalDataDir: input.globalDataDir, + }); + const existing = registry?.stores[id]; + if (existing && contextStoreBackendsMatch(existing.backend as ContextStoreGitBackendConfig, backend)) { + return { + id, + storeRoot, + backend, + metadataCreated, + registryUpdated: false, + alreadyRegistered: true, + }; + } + await updateContextStoreRegistryState( (registry) => withRegisteredStore(registry, id, backend), { globalDataDir: input.globalDataDir } @@ -278,6 +295,8 @@ export async function commitContextStoreRegistration( storeRoot, backend, metadataCreated, + registryUpdated: true, + alreadyRegistered: false, }; } diff --git a/src/core/index.ts b/src/core/index.ts index b29ae725a..d60af58b4 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -16,3 +16,4 @@ export * from './workspace/index.js'; export * from './context-store/index.js'; export * from './collections/index.js'; export * from './planning-home.js'; +export * from './openspec-root.js'; diff --git a/src/core/openspec-root.ts b/src/core/openspec-root.ts new file mode 100644 index 000000000..f93ee1227 --- /dev/null +++ b/src/core/openspec-root.ts @@ -0,0 +1,267 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { FileSystemUtils } from '../utils/file-system.js'; +import { serializeConfig } from './config-prompts.js'; +import { + makeContextStoreDiagnostic, + type ContextStoreDiagnostic, +} from './context-store/errors.js'; + +export const OPENSPEC_ROOT_DIR = 'openspec'; +export const OPENSPEC_CONFIG_YAML = 'openspec/config.yaml'; +export const OPENSPEC_CONFIG_YML = 'openspec/config.yml'; +export const OPENSPEC_SPECS_DIR = 'openspec/specs'; +export const OPENSPEC_CHANGES_DIR = 'openspec/changes'; +export const OPENSPEC_ARCHIVE_DIR = 'openspec/changes/archive'; +export const DEFAULT_OPENSPEC_SCHEMA = 'spec-driven'; + +type PathKind = 'missing' | 'directory' | 'file' | 'other'; + +export interface CreatedPathLedgerEntry { + relativePath: string; + absolutePath: string; + kind: 'directory' | 'file'; +} + +export interface OpenSpecRootInspection { + present: boolean | null; + config: { + present: boolean | null; + path?: string; + }; + specs: { + present: boolean | null; + }; + changes: { + present: boolean | null; + }; + archive: { + present: boolean | null; + }; + healthy: boolean; + diagnostics: ContextStoreDiagnostic[]; +} + +export interface EnsureOpenSpecRootResult { + inspection: OpenSpecRootInspection; + createdArtifacts: string[]; + createdPaths: CreatedPathLedgerEntry[]; +} + +async function pathKind(targetPath: string): Promise { + try { + const stat = await fs.stat(targetPath); + if (stat.isDirectory()) return 'directory'; + if (stat.isFile()) return 'file'; + return 'other'; + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return 'missing'; + } + + throw error; + } +} + +function relativeArtifact(relativePath: string, kind: CreatedPathLedgerEntry['kind']): string { + const normalized = FileSystemUtils.toPosixPath(relativePath); + return kind === 'directory' ? `${normalized}/` : normalized; +} + +function unresolvedInspection(): OpenSpecRootInspection { + return { + present: null, + config: { present: null }, + specs: { present: null }, + changes: { present: null }, + archive: { present: null }, + healthy: false, + diagnostics: [], + }; +} + +function missingDirectoryDiagnostic( + code: string, + message: string, + target: string +): ContextStoreDiagnostic { + return makeContextStoreDiagnostic('error', code, message, { target }); +} + +export async function inspectOpenSpecRoot(storeRoot: string): Promise { + const rootKind = await pathKind(storeRoot); + const inspection = unresolvedInspection(); + + if (rootKind === 'missing') { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_store_root_missing', + 'Store root does not exist.', + 'context_store.root' + )); + return inspection; + } + + if (rootKind !== 'directory') { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_store_root_not_directory', + 'Store root is not a directory.', + 'context_store.root' + )); + return inspection; + } + + const openspecPath = path.join(storeRoot, OPENSPEC_ROOT_DIR); + const openspecKind = await pathKind(openspecPath); + inspection.present = openspecKind === 'directory'; + + if (openspecKind === 'missing') { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_root_missing', + 'Missing openspec/ directory.', + 'openspec.root' + )); + return inspection; + } + + if (openspecKind !== 'directory') { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_root_not_directory', + 'openspec/ exists but is not a directory.', + 'openspec.root' + )); + return inspection; + } + + const configYamlKind = await pathKind(path.join(storeRoot, OPENSPEC_CONFIG_YAML)); + const configYmlKind = await pathKind(path.join(storeRoot, OPENSPEC_CONFIG_YML)); + if (configYamlKind === 'file') { + inspection.config = { present: true, path: OPENSPEC_CONFIG_YAML }; + } else if (configYmlKind === 'file') { + inspection.config = { present: true, path: OPENSPEC_CONFIG_YML }; + } else { + inspection.config = { present: false }; + if (configYamlKind !== 'missing' || configYmlKind !== 'missing') { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_config_not_file', + 'OpenSpec config path exists but is not a file.', + 'openspec.config' + )); + } else { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_config_missing', + 'Missing openspec/config.yaml or openspec/config.yml.', + 'openspec.config' + )); + } + } + + for (const [key, relativePath, code, message, target] of [ + ['specs', OPENSPEC_SPECS_DIR, 'openspec_specs_missing', 'Missing openspec/specs/.', 'openspec.specs'], + ['changes', OPENSPEC_CHANGES_DIR, 'openspec_changes_missing', 'Missing openspec/changes/.', 'openspec.changes'], + ['archive', OPENSPEC_ARCHIVE_DIR, 'openspec_archive_missing', 'Missing openspec/changes/archive/.', 'openspec.archive'], + ] as const) { + const kind = await pathKind(path.join(storeRoot, relativePath)); + inspection[key] = { present: kind === 'directory' }; + if (kind === 'directory') continue; + + inspection.diagnostics.push(missingDirectoryDiagnostic( + kind === 'missing' ? code : code.replace('_missing', '_not_directory'), + kind === 'missing' ? message : `${relativePath}/ exists but is not a directory.`, + target + )); + } + + inspection.healthy = + inspection.present === true && + inspection.config.present === true && + inspection.specs.present === true && + inspection.changes.present === true && + inspection.archive.present === true; + + return inspection; +} + +async function ensureDirectory( + storeRoot: string, + relativePath: string, + ledger: CreatedPathLedgerEntry[] +): Promise { + const absolutePath = path.join(storeRoot, relativePath); + const kind = await pathKind(absolutePath); + + if (kind === 'directory') return; + if (kind !== 'missing') { + throw new Error(`${relativePath}/ exists but is not a directory.`); + } + + await fs.mkdir(absolutePath, { recursive: true }); + ledger.push({ + relativePath: relativeArtifact(relativePath, 'directory'), + absolutePath, + kind: 'directory', + }); +} + +async function ensureDefaultConfig( + storeRoot: string, + ledger: CreatedPathLedgerEntry[] +): Promise { + const configYamlPath = path.join(storeRoot, OPENSPEC_CONFIG_YAML); + const configYmlPath = path.join(storeRoot, OPENSPEC_CONFIG_YML); + const yamlKind = await pathKind(configYamlPath); + const ymlKind = await pathKind(configYmlPath); + + if (yamlKind === 'file' || ymlKind === 'file') return; + if (yamlKind !== 'missing' || ymlKind !== 'missing') { + throw new Error('OpenSpec config path exists but is not a file.'); + } + + await FileSystemUtils.writeFile( + configYamlPath, + serializeConfig({ schema: DEFAULT_OPENSPEC_SCHEMA }) + ); + ledger.push({ + relativePath: relativeArtifact(OPENSPEC_CONFIG_YAML, 'file'), + absolutePath: configYamlPath, + kind: 'file', + }); +} + +export async function ensureOpenSpecRoot(storeRoot: string): Promise { + const ledger: CreatedPathLedgerEntry[] = []; + const rootKind = await pathKind(storeRoot); + + if (rootKind === 'missing') { + await fs.mkdir(storeRoot, { recursive: true }); + } else if (rootKind !== 'directory') { + throw new Error('Store root is not a directory.'); + } + + await ensureDirectory(storeRoot, OPENSPEC_ROOT_DIR, ledger); + await ensureDirectory(storeRoot, OPENSPEC_SPECS_DIR, ledger); + await ensureDirectory(storeRoot, OPENSPEC_CHANGES_DIR, ledger); + await ensureDirectory(storeRoot, OPENSPEC_ARCHIVE_DIR, ledger); + await ensureDefaultConfig(storeRoot, ledger); + + return { + inspection: await inspectOpenSpecRoot(storeRoot), + createdArtifacts: ledger.map((entry) => entry.relativePath), + createdPaths: ledger, + }; +} + +export async function rollbackCreatedPaths(entries: CreatedPathLedgerEntry[]): Promise { + for (const entry of [...entries].reverse()) { + if (entry.kind === 'file') { + await fs.rm(entry.absolutePath, { force: true }).catch(() => undefined); + } else { + await fs.rmdir(entry.absolutePath).catch(() => undefined); + } + } +} diff --git a/test/commands/context-store.test.ts b/test/commands/context-store.test.ts index 6f7ae926e..0612d25e7 100644 --- a/test/commands/context-store.test.ts +++ b/test/commands/context-store.test.ts @@ -6,6 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { + DEFAULT_OPENSPEC_SCHEMA, getDefaultContextStoreRoot, getGlobalDataDir, getContextStoreMetadataPath, @@ -94,6 +95,33 @@ describe('context-store command', () => { return fs.realpathSync.native(existingPath); } + function createHealthyOpenSpecRoot(root: string, configName = 'config.yaml'): void { + fs.mkdirSync(path.join(root, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(root, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', configName), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + } + + function expectHealthyOpenSpecRoot(root: string): void { + expect(fs.existsSync(path.join(root, 'openspec', 'config.yaml')) || fs.existsSync(path.join(root, 'openspec', 'config.yml'))).toBe(true); + expect(fs.existsSync(path.join(root, 'openspec', 'specs'))).toBe(true); + expect(fs.existsSync(path.join(root, 'openspec', 'changes'))).toBe(true); + expect(fs.existsSync(path.join(root, 'openspec', 'changes', 'archive'))).toBe(true); + } + + function expectNoGeneratedAgentOrBetaArtifacts(root: string): void { + for (const artifact of [ + 'initiatives', + '.openspec-workspace', + 'workspace.yaml', + 'AGENTS.md', + '.codex', + '.claude', + '.cursor', + ]) { + expect(fs.existsSync(path.join(root, artifact))).toBe(false); + } + } + function parseJson(result: RunCLIResult): any { try { return JSON.parse(result.stdout); @@ -124,8 +152,25 @@ describe('context-store command', () => { is_repository: false, initialized: false, }); - expect(payload.created_files).toEqual(['.openspec-store/store.yaml']); + expect(payload.registry).toEqual({ + path: expect.any(String), + registered: true, + already_registered: false, + }); + expect(payload.created_files).toEqual([ + 'openspec/', + 'openspec/specs/', + 'openspec/changes/', + 'openspec/changes/archive/', + 'openspec/config.yaml', + '.openspec-store/store.yaml', + ]); expect(payload.status).toEqual([]); + expectHealthyOpenSpecRoot(storeRoot); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'utf-8')).toContain( + `schema: ${DEFAULT_OPENSPEC_SCHEMA}` + ); + expectNoGeneratedAgentOrBetaArtifacts(storeRoot); await expect(readContextStoreMetadataState(storeRoot)).resolves.toEqual({ version: 1, id: 'team-context', @@ -183,6 +228,7 @@ describe('context-store command', () => { default: true, }); expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(true); + expectHealthyOpenSpecRoot(storeRoot); expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(false); expect(process.exitCode).toBeUndefined(); }); @@ -208,6 +254,99 @@ describe('context-store command', () => { expect(result.exitCode).toBe(0); expect(parseJson(result).context_store.root).toBe(expectedExistingPath(storeRoot)); + expectHealthyOpenSpecRoot(storeRoot); + }); + + it('accepts an existing Git-only setup directory', async () => { + const storeRoot = mkdir('team-context'); + execFileSync('git', ['init'], { cwd: storeRoot, stdio: 'ignore' }); + + const result = await runCLI( + ['context-store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.git).toEqual({ + is_repository: true, + initialized: false, + }); + expect(payload.created_files).toEqual([ + 'openspec/', + 'openspec/specs/', + 'openspec/changes/', + 'openspec/changes/archive/', + 'openspec/config.yaml', + '.openspec-store/store.yaml', + ]); + expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(true); + expectHealthyOpenSpecRoot(storeRoot); + }); + + it('preserves an existing healthy OpenSpec root during setup', async () => { + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot, 'config.yml'); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'specs', 'note.md'), 'keep\n'); + + const result = await runCLI( + ['context-store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.created_files).toEqual(['.openspec-store/store.yaml']); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'config.yaml'))).toBe(false); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'config.yml'), 'utf-8')).toBe( + `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` + ); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'specs', 'note.md'), 'utf-8')).toBe('keep\n'); + }); + + it('ignores old beta files inside an otherwise healthy root', async () => { + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot); + fs.mkdirSync(path.join(storeRoot, 'initiatives'), { recursive: true }); + fs.mkdirSync(path.join(storeRoot, '.codex'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'workspace.yaml'), 'old: beta\n'); + fs.writeFileSync(path.join(storeRoot, 'AGENTS.md'), 'old beta guidance\n'); + + const result = await runCLI( + ['context-store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(path.join(storeRoot, 'initiatives'))).toBe(true); + expect(fs.existsSync(path.join(storeRoot, '.codex'))).toBe(true); + expect(fs.readFileSync(path.join(storeRoot, 'workspace.yaml'), 'utf-8')).toBe('old: beta\n'); + expect(fs.readFileSync(path.join(storeRoot, 'AGENTS.md'), 'utf-8')).toBe('old beta guidance\n'); + }); + + it('does not treat beta-only folders as healthy roots', async () => { + const storeRoot = mkdir('team-context'); + fs.mkdirSync(path.join(storeRoot, 'initiatives'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'workspace.yaml'), 'old: beta\n'); + + const setup = await runCLI( + ['context-store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + const register = await runCLI( + ['context-store', 'register', storeRoot, '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(setup.exitCode).toBe(1); + expect(parseJson(setup).status[0]).toEqual(expect.objectContaining({ + code: 'context_store_setup_non_empty_directory', + })); + expect(register.exitCode).toBe(1); + expect(parseJson(register).status[0]).toEqual(expect.objectContaining({ + code: 'context_store_register_root_unhealthy', + })); + expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(false); }); it('rejects explicit setup paths inside an existing Git repo in non-interactive mode', async () => { @@ -227,6 +366,7 @@ describe('context-store command', () => { }) ); expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec'))).toBe(false); }); it('rejects setup paths inside git-like parents when git cannot resolve the repo', async () => { @@ -248,7 +388,7 @@ describe('context-store command', () => { expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(false); }); - it('requires confirmation before interactive setup uses a path inside an existing Git repo', async () => { + it('rejects interactive setup paths inside an existing Git repo without prompting through', async () => { process.env = { ...process.env, XDG_DATA_HOME: dataHome, @@ -265,24 +405,14 @@ describe('context-store command', () => { const repoRoot = mkdir('repo'); execFileSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }); const storeRoot = path.join(repoRoot, 'team-context'); - confirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + confirm.mockResolvedValue(true); await runContextStoreCommand(['setup', 'team-context', '--path', storeRoot]); - expect(confirm).toHaveBeenNthCalledWith(1, { - message: expect.stringContaining('inside another Git repository'), - default: false, - }); - expect(confirm).toHaveBeenNthCalledWith(2, { - message: 'Initialize Git in this context store?', - default: true, - }); - expect(confirm).toHaveBeenNthCalledWith(3, { - message: 'Create this context store?', - default: true, - }); - expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(true); - expect(process.exitCode).toBeUndefined(); + expect(confirm).not.toHaveBeenCalled(); + expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec'))).toBe(false); + expect(process.exitCode).toBe(1); }); it('rejects non-empty setup folders without context-store metadata', async () => { @@ -328,7 +458,7 @@ describe('context-store command', () => { expect(process.exitCode).toBe(1); }); - it('registers an existing folder by inferring the folder name', async () => { + it('refuses to register a plain folder by inferring the folder name', async () => { const storeRoot = mkdir('team-context'); const result = await runCLI( @@ -336,21 +466,153 @@ describe('context-store command', () => { { cwd: tempDir, env } ); + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_register_root_unhealthy', + }) + ); + expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(false); + }); + + it('registers a cloned healthy context store without rewriting planning files', async () => { + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'specs', 'note.md'), 'keep\n'); + await writeContextStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + + const result = await runCLI( + ['context-store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(0); const payload = parseJson(result); expect(payload.context_store.id).toBe('team-context'); - expect(payload.created_files).toEqual(['.openspec-store/store.yaml']); + expect(payload.registry.registered).toBe(true); + expect(payload.created_files).toEqual([]); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'specs', 'note.md'), 'utf-8')).toBe('keep\n'); + }); + + it('requires confirmation before registering a healthy root without identity', async () => { + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot); + + const refused = await runCLI( + ['context-store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + + expect(refused.exitCode).toBe(1); + expect(parseJson(refused).status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_register_identity_confirmation_required', + }) + ); + expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(false); + + const confirmed = await runCLI( + ['context-store', 'register', storeRoot, '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(confirmed.exitCode).toBe(0); + expect(parseJson(confirmed).created_files).toEqual(['.openspec-store/store.yaml']); await expect(readContextStoreMetadataState(storeRoot)).resolves.toEqual({ version: 1, id: 'team-context', }); }); + it('writes nothing when interactive register conversion is declined', async () => { + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPENSPEC_TELEMETRY: '0', + }; + delete process.env.OPEN_SPEC_INTERACTIVE; + delete process.env.CI; + process.chdir(tempDir); + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { confirm } = await getPromptMocks(); + confirm.mockResolvedValue(false); + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot); + + await runContextStoreCommand(['register', storeRoot]); + + expect(confirm).toHaveBeenCalledWith({ + message: "Turn this OpenSpec root into context store 'team-context'?", + default: false, + }); + expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(false); + await expect(readContextStoreRegistryState({ globalDataDir })).resolves.toBeNull(); + expect(process.exitCode).toBe(1); + }); + + it('reports repeated setup and register as no-op success', async () => { + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n# user edit\n'); + + const firstSetup = await runCLI( + ['context-store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + expect(firstSetup.exitCode).toBe(0); + + const secondSetup = await runCLI( + ['context-store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + expect(secondSetup.exitCode).toBe(0); + const setupPayload = parseJson(secondSetup); + expect(setupPayload.created_files).toEqual([]); + expect(setupPayload.status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_already_registered', + }) + ); + + const secondRegister = await runCLI( + ['context-store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + expect(secondRegister.exitCode).toBe(0); + const registerPayload = parseJson(secondRegister); + expect(registerPayload.created_files).toEqual([]); + expect(registerPayload.status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_already_registered', + }) + ); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'utf-8')).toBe( + 'schema: spec-driven\n# user edit\n' + ); + await expect(readContextStoreRegistryState({ globalDataDir })).resolves.toEqual({ + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: expectedExistingPath(storeRoot), + }, + }, + }, + }); + }); + it('rejects registry id and alias path conflicts', async () => { const firstRoot = mkdir('first/team-context'); const secondRoot = mkdir('second/team-context'); const aliasRoot = path.join(tempDir, 'alias-team-context'); + createHealthyOpenSpecRoot(firstRoot); + createHealthyOpenSpecRoot(secondRoot); await writeContextStoreMetadataState(firstRoot, { version: 1, id: 'team-context' }); + await writeContextStoreMetadataState(secondRoot, { version: 1, id: 'team-context' }); await writeContextStoreRegistryState( { version: 1, @@ -378,6 +640,7 @@ describe('context-store command', () => { ); fs.rmSync(path.join(firstRoot, '.openspec-store'), { recursive: true, force: true }); + await writeContextStoreMetadataState(firstRoot, { version: 1, id: 'other-context' }); fs.symlinkSync(firstRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir'); const samePath = await runCLI( ['context-store', 'register', aliasRoot, '--id', 'other-context', '--json'], @@ -611,6 +874,8 @@ describe('context-store command', () => { const healthyRoot = mkdir('healthy-context'); const mismatchRoot = mkdir('mismatch-context'); fs.mkdirSync(path.join(healthyRoot, '.git')); + createHealthyOpenSpecRoot(healthyRoot); + createHealthyOpenSpecRoot(mismatchRoot); await writeContextStoreMetadataState(healthyRoot, { version: 1, id: 'healthy-context' }); await writeContextStoreMetadataState(mismatchRoot, { version: 1, id: 'other-context' }); await writeContextStoreRegistryState( @@ -646,12 +911,14 @@ describe('context-store command', () => { const payload = parseJson(result); const byId = Object.fromEntries(payload.context_stores.map((store: any) => [store.id, store])); expect(byId['healthy-context'].status).toEqual([]); + expect(byId['healthy-context'].openspec_root.healthy).toBe(true); expect(byId['healthy-context'].git.is_repository).toBe(true); expect(byId['missing-context'].status[0]).toEqual( expect.objectContaining({ code: 'context_store_root_missing', }) ); + expect(byId['missing-context'].openspec_root.present).toBeNull(); expect(byId['mismatch-context'].status[0]).toEqual( expect.objectContaining({ code: 'context_store_metadata_id_mismatch', @@ -659,6 +926,43 @@ describe('context-store command', () => { ); }); + it('reports OpenSpec root health separately without repairing it', async () => { + const storeRoot = mkdir('team-context'); + fs.mkdirSync(path.join(storeRoot, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(storeRoot, 'openspec', 'changes'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + await writeContextStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + await writeContextStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: storeRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + const result = await runCLI(['context-store', 'doctor', 'team-context', '--json'], { + cwd: tempDir, + env, + }); + + expect(result.exitCode).toBe(0); + const store = parseJson(result).context_stores[0]; + expect(store.openspec_root.archive.present).toBe(false); + expect(store.openspec_root.status[0]).toEqual( + expect.objectContaining({ + code: 'openspec_archive_missing', + }) + ); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive'))).toBe(false); + }); + it('prompts for Git initialization in interactive setup', async () => { process.env = { ...process.env, diff --git a/test/core/context-store/registry.test.ts b/test/core/context-store/registry.test.ts index 533fcb453..ca22ecfba 100644 --- a/test/core/context-store/registry.test.ts +++ b/test/core/context-store/registry.test.ts @@ -18,6 +18,7 @@ import { resolveContextStoreBinding, resolveRegisteredContextStore, listRegisteredContextStores, + setupContextStore, setupPreparedContextStore, unregisterContextStoreRegistration, writeContextStoreMetadataState, @@ -242,6 +243,31 @@ describe('context store registry facade', () => { } }); + it('removes only setup-created root files when registry write fails', async () => { + const originalEnv = { ...process.env }; + const dataHome = mkdir('blocked-data-home'); + fs.writeFileSync(path.join(dataHome, 'openspec'), 'not a directory\n'); + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + }; + const storeRoot = path.join(tempDir, 'team-context'); + + try { + await expect( + setupContextStore({ + id: 'team-context', + path: storeRoot, + initGit: false, + }) + ).rejects.toThrow(); + + expect(fs.existsSync(storeRoot)).toBe(false); + } finally { + process.env = originalEnv; + } + }); + it('lists registered context stores from the machine-local registry', async () => { const acmeRoot = mkdir('acme-context'); const zetaRoot = mkdir('zeta-context'); diff --git a/test/core/openspec-root.test.ts b/test/core/openspec-root.test.ts new file mode 100644 index 000000000..b0d27da48 --- /dev/null +++ b/test/core/openspec-root.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + DEFAULT_OPENSPEC_SCHEMA, + ensureOpenSpecRoot, + inspectOpenSpecRoot, + rollbackCreatedPaths, +} from '../../src/core/index.js'; + +describe('OpenSpec root helper', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-root-helper-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function createHealthyRoot(root: string, configName = 'config.yaml'): void { + fs.mkdirSync(path.join(root, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(root, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', configName), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + } + + it('inspects a healthy root with config.yaml', async () => { + const root = path.join(tempDir, 'store'); + createHealthyRoot(root); + + await expect(inspectOpenSpecRoot(root)).resolves.toEqual(expect.objectContaining({ + healthy: true, + present: true, + config: { + present: true, + path: 'openspec/config.yaml', + }, + diagnostics: [], + })); + }); + + it('inspects a healthy root with config.yml', async () => { + const root = path.join(tempDir, 'store'); + createHealthyRoot(root, 'config.yml'); + + await expect(inspectOpenSpecRoot(root)).resolves.toEqual(expect.objectContaining({ + healthy: true, + config: { + present: true, + path: 'openspec/config.yml', + }, + })); + }); + + it('reports missing root pieces without mutating files', async () => { + const root = path.join(tempDir, 'store'); + fs.mkdirSync(path.join(root, 'openspec', 'changes'), { recursive: true }); + + const inspection = await inspectOpenSpecRoot(root); + + expect(inspection.healthy).toBe(false); + expect(inspection.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'openspec_config_missing', + 'openspec_specs_missing', + 'openspec_archive_missing', + ]); + expect(fs.existsSync(path.join(root, 'openspec', 'changes', 'archive'))).toBe(false); + }); + + it('ensures the default root shape and records created paths', async () => { + const root = path.join(tempDir, 'store'); + + const result = await ensureOpenSpecRoot(root); + + expect(result.createdArtifacts).toEqual([ + 'openspec/', + 'openspec/specs/', + 'openspec/changes/', + 'openspec/changes/archive/', + 'openspec/config.yaml', + ]); + expect(result.inspection.healthy).toBe(true); + expect(fs.readFileSync(path.join(root, 'openspec', 'config.yaml'), 'utf-8')).toContain( + `schema: ${DEFAULT_OPENSPEC_SCHEMA}` + ); + }); + + it('preserves existing config and user files', async () => { + const root = path.join(tempDir, 'store'); + createHealthyRoot(root, 'config.yml'); + fs.writeFileSync(path.join(root, 'openspec', 'specs', 'note.md'), 'keep me\n'); + + const result = await ensureOpenSpecRoot(root); + + expect(result.createdArtifacts).toEqual([]); + expect(fs.existsSync(path.join(root, 'openspec', 'config.yaml'))).toBe(false); + expect(fs.readFileSync(path.join(root, 'openspec', 'config.yml'), 'utf-8')).toBe( + `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` + ); + expect(fs.readFileSync(path.join(root, 'openspec', 'specs', 'note.md'), 'utf-8')).toBe( + 'keep me\n' + ); + }); + + it('rolls back only ledger-created files and empty directories', async () => { + const root = path.join(tempDir, 'store'); + const result = await ensureOpenSpecRoot(root); + fs.writeFileSync(path.join(root, 'user.md'), 'mine\n'); + + await rollbackCreatedPaths(result.createdPaths); + + expect(fs.existsSync(path.join(root, 'openspec'))).toBe(false); + expect(fs.readFileSync(path.join(root, 'user.md'), 'utf-8')).toBe('mine\n'); + }); +}); From 0c2d3eb6e044e346bf2741c15568bd8538a13b93 Mon Sep 17 00:00:00 2001 From: TabishB Date: Wed, 10 Jun 2026 02:01:50 +1000 Subject: [PATCH 002/111] Clarify simplified model roadmap --- .../roadmap.md | 859 ++++++++++-------- 1 file changed, 484 insertions(+), 375 deletions(-) diff --git a/openspec/work/simplify-context-and-workspace-model/roadmap.md b/openspec/work/simplify-context-and-workspace-model/roadmap.md index 9ae189197..c091a3178 100644 --- a/openspec/work/simplify-context-and-workspace-model/roadmap.md +++ b/openspec/work/simplify-context-and-workspace-model/roadmap.md @@ -1,445 +1,573 @@ # Simplify Context And Workspace Model Roadmap -This roadmap is a living internal path toward `goal.md`. It is expected to -change as slices reveal better sequencing, missing constraints, or simpler -product shape. +This roadmap is an internal plan for the work described in `goal.md`. -This is not public product framing yet. Keep it lightweight, avoid promising -future behavior before it exists, and prefer concrete implementation slices over -large documentation rewrites. +The goal is simple: -The core move is simple: take the old clean OpenSpec root model and let that -root live in a standalone Git repo. Views or workspaces may open that OpenSpec -repo together with target code repos, but they are not the source of truth. +```text +Specs are what is true. +Work is what is in motion. +``` -## Current Focus +OpenSpec work should live in normal Git files. Those files can live inside the +project repo, or they can live in a separate OpenSpec repo that points at one or +more project repos. -Use this lightweight `/work` experiment as a place to coordinate the -reorientation captured in `goal.md`. The old -`openspec/initiatives/context-store-and-initiatives/direction-git-native-work.md` -note is transition evidence that was distilled into this work's goal. The -folder shape itself is not a product roadmap item. +This roadmap should be readable by someone with no beta context. Each item says: -The roadmap order is now: +- What the user can do. +- Why it matters. +- What changes in commands or files. +- How the user or agent knows it worked. -1. Make context-store setup/register produce a normal standalone OpenSpec root. -2. Make store selectors route core commands to the selected OpenSpec root. -3. Remove initiative coupling from the product path. -4. Add target repo mapping. -5. Turn workspace/opening behavior into a local view over the OpenSpec root and - target repos. -6. Delete or demote detour residue only when it blocks the simple path. -7. Revisit public concepts only after the behavior is solid. +This is not public product copy yet. Keep it practical, small, and honest about +what exists. -## Working Vocabulary +## The Story In Plain English -- OpenSpec root: the `openspec/` directory containing config, `specs/`, and +Today, too much of this area is explained through beta terms: context stores, +initiatives, workspaces, collections, and repo-local modes. + +The simpler product story should become: + +1. OpenSpec can live in this project repo or in its own Git repo. +2. If OpenSpec lives in its own repo, users can register that repo locally. +3. Normal OpenSpec commands can create, read, validate, and archive work in that + selected OpenSpec repo. +4. Work can say which project repos it targets. +5. The local machine can map those target repos to local checkout paths. +6. An optional view can open the OpenSpec repo and target repos together. + +The product should not require users or agents to understand initiatives, +workspace-owned planning, or collection state as the main model. + +## Vocabulary For This Roadmap + +- **OpenSpec root**: the `openspec/` folder with `config.yaml`, `specs/`, and `changes/`. -- In-project OpenSpec: the OpenSpec root lives inside the code repo. -- Standalone OpenSpec repo: the same OpenSpec root lives in its own Git repo. -- Context store: a named/registered standalone OpenSpec repo shell. It may use - `.openspec-store` for identity or registry metadata, but `openspec/` is the - planning source of truth. -- Target project repo: a code repo the work applies to. -- Local repo map: machine-local mapping from target repo ids to checkout paths. -- View/workspace: optional local way to open the OpenSpec repo plus target repos - together. It is not durable planning state. - -## Operating Guardrails - -- Keep the old simple `openspec/specs/` and `openspec/changes/` lifecycle as - the foundation. -- Treat a context store, when used, as a named/registered standalone OpenSpec - repo, not as a separate planning state. -- Treat planning state as normal OpenSpec artifacts: - `openspec/specs/` and `openspec/changes/`. -- Treat initiative collections and separate workspace planning state as - wrong-direction residue, not compatibility requirements for the simplified - model. -- Treat future initiative-like behavior as a possible type of work, not as a - separate context-store collection for now. -- Treat `/work` as internal dogfooding, not a product requirement. -- Defer broad public docs and vocabulary cleanup until behavior exists; do not - spend roadmap time making obsolete beta framing nicer. -- Do not imply clone, pull, push, sync, branch, worktree, dashboard, workspace - apply, workspace verify, or workspace archive behavior. -- Change accepted specs alongside real behavior, not ahead of it. - -## Phase 0: Authority Cleanup - -### Reorient The Old Context-Store Initiative +- **OpenSpec inside a project repo**: the `openspec/` folder lives inside the + code repo. +- **Standalone OpenSpec repo**: the `openspec/` folder lives in its own Git + repo. +- **Context store**: the current bridge name for a registered standalone + OpenSpec repo. It has a thin `.openspec-store/store.yaml` identity file, but + the real planning work still lives under `openspec/`. +- **Target project repo**: a code repo that the OpenSpec work is about. +- **Local repo map**: local machine settings that say where target project repos + are checked out. +- **View**: a local convenience for opening the OpenSpec repo and project repos + together. It is not the source of truth. + +## Rules We Should Not Forget + +- Keep the normal `openspec/specs/` and `openspec/changes/` lifecycle working. +- When context stores are used, treat them as standalone OpenSpec repos, not as + a separate planning system. +- Do not create new initiative links in the simpler product path. +- Do not create workspace-owned planning state in the simpler product path. +- Do not promise clone, pull, push, sync, branch, worktree, dashboard, apply, + verify, or archive orchestration in these slices. +- Treat old beta files as history unless they block the simpler path. +- Do not rewrite public docs before the behavior is solid. + +## Phase 0: Make The Active Direction Easy To Find + +This phase is already done. It cleaned up old roadmap sources so agents and +humans do not follow the wrong plan. + +### Point People Away From The Old Context-Store Beta Plan Status: done -Outcome: Make it clear that -`openspec/initiatives/context-store-and-initiatives/` is beta history and -transition evidence, while this `/work` directory tracks the active -reorientation work. +What the user or agent needs: + +- A clear place to find the current direction. +- Confidence that old initiative docs are history, not the active plan. + +What changed: -Done when: +- The old context-store initiative now points readers to this `goal.md` and + `roadmap.md`. +- Old beta notes remain discoverable as transition evidence. +- The old initiative roadmap is no longer treated as the implementation queue. -- The old initiative points readers to this work's `goal.md` and `roadmap.md` - as the active direction. -- `direction-git-native-work.md` is described as the transition note that led - to this goal, not as the current authority if the two conflict. -- The old `README.md`, `roadmap.md`, `tasks.md`, and `direction.md` make it - clear that they preserve beta history and transition evidence. -- The old roadmap and task files are not treated as the next implementation - queue. -- Useful decisions, work item notes, and beta evidence remain discoverable. +How we know it worked: -### Disposition Deferred Workspace Artifacts +- A new reader can start from this `/work` folder instead of chasing the old + initiative roadmap. + +### Mark Deferred Workspace Plans As Not The Current Queue Status: done -Outcome: Make active no-task workspace changes and historical workspace roadmap -artifacts impossible to mistake for the next implementation queue. +What the user or agent needs: + +- No accidental revival of old workspace apply, verify, archive, branch, + worktree, or dashboard plans. + +What changed: -Done when: +- The old workspace reimplementation artifacts were marked obsolete or pending + deletion review. +- Useful research can still be copied forward later. -- `workspace-reimplementation-roadmap`, `workspace-agent-guidance`, - `workspace-apply-repo-slice`, and `workspace-verify-and-archive` are marked - obsolete / pending deletion review. -- Deferred workspace apply, verify, archive, branch/worktree orchestration, - strong cross-repo validation, and progress dashboards are not revived by - accident. -- Any useful research remains available temporarily until unique evidence is - promoted, linked, or deliberately discarded before deletion. +How we know it worked: -### Reframe Agent Operating Guidance +- The old workspace changes no longer look like the next thing to implement. + +### Reframe Local Agent Guidance Around OpenSpec Roots Status: done -Outcome: Make local agent guidance start from OpenSpec roots, artifact -placement, target project repos, and local repo mapping instead of a -context-store/workspace-first model. +What the user or agent needs: + +- Agent instructions that start with "where is the OpenSpec root?" instead of + "which beta workspace/context-store mode is this?" + +What changed: -Done when: +- Local guidance was reframed around OpenSpec roots, artifact placement, target + project repos, and local repo mapping. +- Beta shared-context guidance was described as old, non-default history. -- Local `.codex/skills/use-openspec/SKILL.md` guidance routes agents through - the current simplified model before beta shared-context flows. -- Local `artifact-placement.md` distinguishes in-project OpenSpec from - standalone OpenSpec repos, then separately asks which target project repo owns - implementation. -- Local `shared-context-beta.md` is framed as non-default beta/detour guidance, - not the product model. -- Agent guidance still tells agents to inspect current CLI state and avoids - promising clone, sync, branch, worktree, dashboard, or edit-boundary behavior. -- The final disposition of this ignored local guidance is reviewed later. +How we know it worked: -## Phase 1: Context Store As Standalone OpenSpec Repo +- Agents are guided to inspect current files and commands, while avoiding + promises about clone, sync, branch, worktree, dashboard, or edit-boundary + behavior. -### Store Root Parity +## Phase 1: Make A Standalone OpenSpec Repo Useful -Status: next, spec ready; plan pending +The user-facing goal of this phase: + +```text +I can keep OpenSpec work in its own Git repo and still use normal OpenSpec +commands. +``` + +### Create Or Register A Standalone OpenSpec Repo + +Status: implemented in draft PR #1190 Slice: `slices/store-root-parity/spec.md` -Outcome: Make `context-store setup` and `context-store register` create or -validate the same OpenSpec root shape a user would get by creating a fresh Git -repo and running `openspec init`, while keeping context-store metadata as a -thin identity shell. - -Research before implementation: - -- Decide how to share the root-only `openspec init` behavior without also - forcing agent/tool installation into context stores. -- Confirm the intended config behavior for non-interactive setup, because - current `openspec init` has config-generation quirks in non-interactive mode. -- Decide whether `context-store register` should require an existing OpenSpec - root, repair/ensure one, or support both modes explicitly. -- Decide how setup should treat non-empty folders such as a freshly initialized - Git repo that only contains `.git/`. - -Research decisions captured on 2026-06-09: - -- `context-store setup` should reuse an extracted root-only init helper, not - call full `InitCommand.execute()`. The helper should ensure `openspec/`, - `openspec/specs/`, `openspec/changes/`, and - `openspec/changes/archive/` without running tool detection, prompts, legacy - cleanup, migration, skill generation, or command generation. -- `context-store setup` should always ensure `openspec/config.yaml` with the - default schema when no config exists, including non-interactive and JSON - flows. Do not inherit the current `openspec init --tools none` - non-interactive config skip. -- `context-store register` should require an existing normal OpenSpec root by - default. It may create or repair thin `.openspec-store/store.yaml` identity - metadata, but it should not silently initialize arbitrary folders as OpenSpec - roots. Add an explicit repair or ensure mode later if that behavior is needed. -- `context-store setup` should accept missing directories, empty directories, - already initialized standalone OpenSpec roots, and fresh Git-only directories - that contain only `.git/`. Existing beta context-store metadata may be - normalized to the thin identity shape, but previous beta file shapes and - command semantics are not a compatibility contract. Setup should keep - rejecting arbitrary non-empty unmarked folders. -- `context-store doctor` should report OpenSpec-root health separately from - context-store metadata and Git health. Root health should cover the - `openspec/` directory, `openspec/config.yaml` or `openspec/config.yml`, - `openspec/specs/`, `openspec/changes/`, and - `openspec/changes/archive/`. -- Store setup/register/help output should point users toward normal OpenSpec - specs and changes. Do not create initiative links, mount initiative - collections, install generated agent files, or revive workspace-owned - planning behavior in this slice. -- 2026-06-09 review note: Store Root Parity should protect user-authored files - and idempotency, not preserve unstable beta context-store behavior. - -Implementation notes captured on 2026-06-09: - -- Add a shared core helper module for OpenSpec-root behavior, likely - `src/core/openspec-root.ts`, instead of putting root creation in - context-store code. It should own path helpers, root ensuring, root - inspection, and healthy-root checks. -- Extract the directory/config scaffold from `InitCommand` into that helper. - `InitCommand` should delegate root creation to the helper while keeping its - existing onboarding responsibilities: prompts, legacy cleanup, migration, - tool selection, skills, commands, profile handling, and success output. -- The helper should create a ledger of paths it created, including - `openspec/config.yaml` and any directories needed for the root shape. Callers - can use that ledger for `created_files` output and rollback. -- Use `planning-home.ts` for nearest-root discovery only. It already treats any - ancestor with `openspec/` as a repo planning home, but it should not become the - scaffolding or doctor module. -- `context-store setup` should call the helper after the store directory exists - and before backend resolution or registry commit. If registration fails in a - pre-existing folder, rollback should remove only ledger-created files and empty - directories, never arbitrary user content. -- `context-store register` should validate the existing OpenSpec root with the - helper's inspector before writing missing `.openspec-store/store.yaml` or - committing registry state. Keep the lower-level `registerContextStore()` - facade loose unless this slice intentionally makes root parity a global core - invariant. -- `context-store doctor` should map the helper's inspector result into a - separate `openspec_root` JSON/human section rather than calling cwd-oriented - command classes such as list, show, or validate. -- Test the helper directly, then test context-store operations directly, then - keep CLI tests focused on command output and JSON shape. Existing config - parsing and `cli-init` specs should not change unless their user-facing - behavior changes. - -Done when: - -- Existing context-store setup, register, list, doctor, path/Git, registry, and - safe-delete behavior is reused or narrowed intentionally. -- `context-store setup` can produce `.openspec-store/store.yaml`, optional - `.git/`, `openspec/config.yaml`, `openspec/specs/`, - `openspec/changes/`, and `openspec/changes/archive/`. -- `context-store register` can handle an already initialized standalone - OpenSpec repo without treating it as an initiative store. -- `.openspec-store/store.yaml` remains identity or registry metadata only, not - the planning model. -- Context-store help and guidance point users toward normal OpenSpec specs and - changes, not initiatives. -- `context-store doctor` reports OpenSpec root health separately from - metadata/Git health. - -### Store Selectors For Core Commands - -Status: candidate, research first - -Outcome: Let normal OpenSpec lifecycle commands operate on a selected OpenSpec -root, so a user in an app repo can create or inspect work in a standalone -context store without creating initiative links. - -Research before implementation: - -- Decide how to migrate `--store` and `--store-path`, since they currently mean - "context store for `--initiative`" rather than "OpenSpec root selector." -- Decide the first command set for selector support instead of trying to touch - every lifecycle command at once. -- Decide whether `--store-path` should require `.openspec-store/store.yaml` or - also accept any normal standalone OpenSpec root. -- Decide how this interacts with current workspace planning homes before - workspace/open is reworked into a view-only surface. - -Done when: - -- The default remains the nearest/current OpenSpec root when no selector is - provided. -- `openspec new change --store ` creates - `openspec/changes/` in the selected store/root, not in the current app - repo and not as an initiative-linked change. -- Store selectors such as `--store` or `--store-path` reuse existing registry, - binding, and path-canonicalization machinery where it is already useful. -- Multiple registered stores have clear list, doctor, and ambiguity behavior. -- The implementation still writes normal `openspec/changes/` and - `openspec/specs/` artifacts. -- Existing initiative metadata remains readable as legacy, but this flow does - not create new initiative metadata. - -### Prove Store-Backed Lifecycle Smoke +What the user can do: + +- Run `context-store setup` and get a normal OpenSpec root in a standalone repo. +- Clone a teammate's standalone OpenSpec repo and register it locally. +- Run `context-store doctor` and see whether the OpenSpec root is healthy. + +Why it matters: + +- A context store should not feel like a special beta planning system. +- It should be a normal OpenSpec root plus a small identity file. + +What changes in commands or files: + +- Setup creates or preserves this shape: + +```text +context-store-root/ + .openspec-store/ + store.yaml + openspec/ + config.yaml + specs/ + changes/ + archive/ +``` + +- Register requires an existing healthy OpenSpec root. +- Register can add `.openspec-store/store.yaml` only after confirmation. +- Doctor reports OpenSpec-root health separately from metadata and Git health. +- Setup/register do not create initiatives, workspace planning files, generated + agent files, slash commands, or tool config. + +How the user or agent knows it worked: + +- `created_files` reports the exact files and folders created. +- Re-running setup/register for the same root reports nothing to change. +- `context-store doctor --json` includes a separate `openspec_root` section. +- Existing config, specs, changes, archived changes, and old beta files are not + overwritten. + +### Let Normal Commands Use A Named Standalone OpenSpec Repo + +Status: next, research first + +Plain-English version of the next slice: + +```text +When I am in an app repo, I can tell OpenSpec to create or read work in my +registered standalone OpenSpec repo. +``` + +Example user flow: + +```bash +openspec new change add-billing --store team-context +openspec status --store team-context +openspec instructions apply --store team-context +``` + +What the user can do: + +- Stay in the project repo they are working on. +- Pick a registered standalone OpenSpec repo by name. +- Create, inspect, validate, and archive normal OpenSpec work in that selected + repo. + +Why it matters: + +- Without this, users can create/register a standalone OpenSpec repo, but normal + commands still mostly act on the nearest local `openspec/` folder. +- The user should not need initiative links or workspace planning state just to + put work in a standalone OpenSpec repo. + +What changes in commands or files: + +- Add a clear way to choose the OpenSpec root for normal commands, likely + `--store ` and/or `--store-path `. +- Start with a small command set instead of every command at once. +- Suggested first commands: + `new change`, `status`, `instructions`, `list`, `show`, `validate`, and + `archive`. +- The selected command writes normal `openspec/changes/` and reads normal + `openspec/specs/`. +- The command does not create initiative metadata. +- The command does not create workspace planning files. + +Questions to answer before implementation: + +- Should `--store-path` require `.openspec-store/store.yaml`, or can it point at + any healthy standalone OpenSpec root? +- Which commands get support first? +- How should existing `--store` and `--store-path` meanings from initiative + flows be handled? +- How should this behave when the current directory is already inside a + workspace planning home? + +How the user or agent knows it worked: + +- Without `--store`, commands keep using the nearest/current OpenSpec root. +- With `--store team-context`, `openspec/changes/` is created in the + registered store root. +- JSON output shows which OpenSpec root was used. +- No new initiative link is created. + +### Prove The Standalone Repo Lifecycle End To End Status: candidate -Outcome: Prove that a registered standalone store can run the same simple -OpenSpec lifecycle as an in-project root. +Plain-English version: + +```text +Show that a registered standalone OpenSpec repo can do the same basic lifecycle +as an OpenSpec root inside a project repo. +``` + +What the user can do: + +- Set up or register a standalone OpenSpec repo. +- Create a change there. +- Inspect the change. +- Get instructions. +- Validate it. +- Archive it when done. + +Why it matters: + +- This proves standalone OpenSpec repos are not just setup plumbing. +- It catches missing command support before more features are built on top. + +What changes in commands or files: + +- Add a clean fixture or smoke flow for a registered standalone OpenSpec repo. +- Cover setup/register, list, doctor, root selection, change creation, status, + instructions, list/show, validate, archive, and view where relevant. + +How the user or agent knows it worked: + +- The smoke passes without using old initiative collections or workspace-owned + planning state. +- The final files are normal `openspec/specs/`, `openspec/changes/`, and + `openspec/changes/archive/` files in the standalone repo. -Done when: +## Phase 2: Stop Putting New Work Through Initiatives -- A clean store-backed standalone fixture or smoke flow exists. -- The smoke covers init, new change, status, instructions, list, show, - validate, archive, and view where applicable. -- The smoke also covers context-store setup/register, list, doctor, and store - selection. -- Known live-repo detour artifacts are not part of the pass/fail gate. +The user-facing goal of this phase: -## Phase 2: Remove Initiative Coupling +```text +Normal OpenSpec work should not require an initiative. +``` -### Freeze New Initiative Links +Old initiative data can remain readable as legacy history, but the simpler path +should stop attaching new work to initiatives. + +### Stop Creating New Initiative Links In Normal Change Flows Status: candidate -Outcome: Stop adding new initiative coupling to normal change flows while -keeping old metadata readable as legacy when needed. +What the user can do: + +- Create normal changes without attaching them to an initiative. +- Still read old initiative metadata if it already exists. + +Why it matters: + +- Initiative links make the simple model harder to understand. +- They make users think the initiative system is required when it should not be + the normal path. -Done when: +What changes in commands or files: -- `new change` and `set change` no longer create new initiative links as part - of the product path. -- Existing `.openspec.yaml` initiative metadata remains parseable if needed, but - is treated as legacy/display-only. -- Context-store selectors route to OpenSpec roots instead of initiative +- `new change` and `set change` stop creating new initiative links as part of + the main product path. +- Existing `.openspec.yaml` initiative metadata remains parseable if needed. +- Store/root selection points to normal OpenSpec roots, not initiative collections. -### Remove Public Initiative Surfaces +How the user or agent knows it worked: + +- New changes do not get initiative metadata by default. +- Old initiative-linked changes can still be displayed or handled as legacy. + +### Hide Or Demote Initiative Commands From The Main Path Status: candidate -Outcome: Remove, hide, or clearly demote public initiative command surfaces so -they no longer look like the model. +What the user can do: + +- Follow normal OpenSpec commands without being pointed toward + `openspec initiative`. -Done when: +Why it matters: -- `openspec initiative` is not presented as the product path. -- Completion metadata, generated guidance, and command docs stop advertising +- If generated guidance and completions keep advertising initiatives, users and + agents will keep treating them as the product model. + +What changes in commands or files: + +- Completion metadata, generated guidance, and command docs stop presenting initiative flows as normal workflow steps. -- Existing initiative folders or metadata are not deleted automatically unless - they are explicitly part of a cleanup slice. +- Existing initiative folders are not deleted automatically. +- Any cleanup is explicit and separate. + +How the user or agent knows it worked: -### Decouple Workspace Open From Initiatives +- A fresh user is guided toward specs and changes, not initiatives. +- Existing initiative data remains untouched unless an explicit cleanup slice + says otherwise. + +### Make Workspace Opening Stop Depending On Initiatives Status: candidate -Outcome: Remove initiative attachment from workspace opening so opening becomes -a local view concern. +What the user can do: + +- Open useful local views without needing to choose an initiative first. + +Why it matters: -Done when: +- Opening files is a local convenience. It should not define where planning + state lives. -- `workspace open --initiative` and related initiative picker behavior no longer - define the opening model. -- Old workspace view files can fail gracefully or be read as legacy without - resolving active initiative context. -- Workspace opening is ready to be replaced by opening a selected store/root - plus target repos. +What changes in commands or files: -## Phase 3: Target Project Repo Resolution +- `workspace open --initiative` and initiative picker behavior no longer define + the main opening model. +- Old workspace view files can be read as legacy or fail clearly. +- Opening prepares to use a selected OpenSpec root plus target repos instead. -### Define Target Project Repo Contract +How the user or agent knows it worked: + +- Opening a view does not create or require initiative planning state. +- Errors explain local view problems separately from OpenSpec-root problems. + +## Phase 3: Say Which Project Repos The Work Is About + +The user-facing goal of this phase: + +```text +This OpenSpec work lives here, and it targets these project repos. +``` + +### Let Work Declare Its Target Project Repos Status: candidate -Outcome: Define how work or changes declare which project repos own -implementation. +What the user can do: + +- Mark a change or work item as applying to one or more project repos. -Done when: +Why it matters: -- Target repo ids have a simple, explicit shape. -- A target repo declaration is separate from the OpenSpec artifact root. -- The contract does not imply automatic cloning, syncing, branch management, or - edit-boundary enforcement. +- A standalone OpenSpec repo is separate from the code repos. +- Users and agents need a simple way to know which code repos the work is about. -### Map Target Repos To Local Checkouts +What changes in commands or files: + +- Add a simple target repo declaration shape. +- Keep target repo declarations separate from the OpenSpec artifact root. +- Do not imply automatic clone, sync, branch, worktree, or edit-boundary + enforcement. + +How the user or agent knows it worked: + +- A change can clearly say which project repo ids it targets. +- The declaration is visible in normal OpenSpec files or metadata. + +### Map Target Repo Names To Local Checkout Paths Status: candidate -Outcome: Let local config map target repo ids to checkout paths. +What the user can do: + +- Tell OpenSpec where each target project repo lives on this machine. + +Why it matters: -Done when: +- Shared OpenSpec work can name a target repo, but each developer may have that + repo checked out in a different local path. -- A local repo map can resolve a declared target repo to a local checkout. +What changes in commands or files: + +- Add local machine settings that map target repo ids to local checkout paths. +- Keep the map local; it is not shared planning state. - Missing, duplicate, or invalid mappings fail clearly. -- The map is local configuration, not a shared state system. -### Report Target Repo Mapping Health +How the user or agent knows it worked: + +- Given a target repo id, OpenSpec can resolve the local checkout path. +- If the path is missing or ambiguous, the error tells the user what to fix. + +### Report Whether Target Repos Are Available Locally Status: candidate -Outcome: Surface whether declared target repos are available locally. +What the user can do: + +- Ask OpenSpec whether the target project repos for this work are available on + the current machine. + +Why it matters: + +- Agents need to know whether they can inspect or edit the relevant code repo. +- This should be diagnostic only; it should not clone or sync anything. + +What changes in commands or files: -Done when: +- Doctor or status output reports target repo mapping health. +- The report clearly separates OpenSpec root health, context-store metadata + health, and target project checkout health. -- Doctor or status output reports missing target mappings clearly. -- The report distinguishes OpenSpec root health from target project checkout - health. -- The output stays diagnostic and does not attempt clone, pull, push, branch, - worktree, or sync behavior. +How the user or agent knows it worked: -## Phase 4: Open View +- Missing target repo mappings are easy to see. +- The output does not attempt clone, pull, push, sync, branch, or worktree + behavior. -### Open OpenSpec Root With Target Repos +## Phase 4: Open The Right Files Together + +The user-facing goal of this phase: + +```text +Open my standalone OpenSpec repo and the project repos it targets in one useful +local view. +``` + +### Open The OpenSpec Repo And Target Repos Together Status: candidate -Outcome: Reuse or replace workspace opening machinery so a user can open a -selected context store or standalone OpenSpec repo together with its mapped -target code repos. +What the user can do: + +- Open a selected standalone OpenSpec repo plus its mapped project repos in the + editor or agent surface they use. + +Why it matters: + +- Users usually need both the plan and the code. +- Opening them together should be a local convenience, not a new planning + system. -Done when: +What changes in commands or files: -- The selected context store/root remains the durable planning source of truth - through normal `openspec/` artifacts. -- Target repos are opened from the local repo map. -- The view can generate editor/agent opening surfaces without creating a - workspace planning home. +- Reuse or replace workspace opening machinery. +- Use the selected OpenSpec root as the durable planning source of truth. +- Use the local repo map to find project repo checkouts. +- Do not create workspace-owned planning state. + +How the user or agent knows it worked: + +- The opened view contains the OpenSpec repo and relevant target repos. +- The durable files remain normal OpenSpec artifacts. - The view does not imply clone, pull, push, sync, branch, worktree, dashboard, or edit-boundary enforcement. -## Phase 5: Residue Removal When It Blocks +## Phase 5: Remove Old Surfaces Only When They Confuse The Simple Path + +The user-facing goal of this phase: -### Delete Or Demote Detour Surfaces +```text +Remove or hide old beta surfaces only when they make the simple path harder to +use or understand. +``` + +### Remove Or Hide Old Workspace And Initiative Paths Status: later -Outcome: Remove, hide, or demote workspace-planning and initiative-collection -surfaces when they confuse the simple path or block store-backed standalone -OpenSpec repos. This is not a compatibility preservation pass. +What the user can do: + +- Follow the simple OpenSpec root path without being distracted by obsolete beta + workflows. + +Why it matters: -Done when: +- Cleanup is useful only when it reduces confusion or removes a blocker. +- It should not become a broad compatibility project or docs rewrite. -- Obsolete no-delta workspace changes are deleted, archived, or otherwise kept - out of the active implementation queue when they are no longer useful. +What changes in commands or files: + +- Obsolete no-delta workspace changes can be deleted, archived, or moved out of + the active queue. - Workspace-planning and initiative-collection code, docs, specs, and generated - guidance are removed or demoted only where they mislead users/agents or - constrain the new architecture. -- The cleanup does not become a broad docs rewrite or a compatibility support - project. - -## Later Candidates - -- Revisit public concept docs only after the model and behavior are solid - enough for public consumption. Until then, do not rewrite `docs/concepts.md` - or expose detailed standalone OpenSpec repo, target repo, local repo map, or - `/work` language as public product framing. -- Decide how and when accepted workspace-planning specs change once behavior - changes; do not rewrite specs just to match future framing. -- Add richer cross-repo context and doctoring after standalone OpenSpec repos - can target project repos. -- Evolve toward first-class `work/` only after the baseline and standalone repo - flow are solid. -- Revisit whether existing `changes/` become change-shaped work under `work/`. + guidance can be removed or moved out of the main path where they mislead + users or agents. +- Existing user data is not deleted automatically. + +How the user or agent knows it worked: + +- The active roadmap and generated guidance point to the simple path. +- Old surfaces no longer look like required workflow. + +## Later Ideas + +Keep these out of the main queue until the simpler standalone OpenSpec repo path +is working: + +- Rewrite public concept docs after behavior is solid. +- Decide how accepted workspace-planning specs should change once behavior has + changed. +- Add richer cross-repo context and doctoring after target repo mapping works. +- Consider first-class `work/` only after the baseline and standalone repo flow + are solid. +- Revisit whether `changes/` should evolve into change-shaped work under + `work/`. - Add machine-readable `/work` metadata only after the manual shape proves useful. -- Decide whether to keep, rename, or replace `context-store` terminology only - after the bridge behavior proves useful; do not block Phase 1 on naming. -- Re-review the local `use-openspec` skill changes and decide how this guidance - should really work: ignored local skill, generated artifact, checked-in source, - or productized default guidance. +- Decide whether to keep, rename, or replace `context-store` terminology after + the bridge behavior proves useful. +- Review local `use-openspec` skill guidance and decide whether it should be an + ignored local skill, generated artifact, checked-in source, or productized + default. - Fix small baseline quirks, such as JSON support for `openspec list --specs`, - only if they matter to the simple smoke path or standalone repo flow. -- Reintroduce initiative-like behavior only as a Git-native work type, if it + only if they matter to the simple standalone repo flow. +- Reintroduce initiative-like behavior only as a Git-native work type if it still proves useful later. -## Roadmap Changes +## Roadmap Change Log - 2026-06-07: Started the active reorientation experiment under `openspec/work/` instead of continuing the context-store initiative roadmap. @@ -448,39 +576,20 @@ Done when: - 2026-06-08: Removed the experimental `/work` folder shape from the roadmap; it is the dogfood structure for this thinking, not a product slice. - 2026-06-08: Preserved the old initiative reorientation item and expanded the - full framing cleanup into separate roadmap slices for old authority, - deferred artifacts, agent guidance, public docs, beta docs, CLI reference, - and generated guidance. + framing cleanup into separate roadmap slices. - 2026-06-08: Completed the old initiative reorientation pass by rewriting the - opening sections of the old README, roadmap, tasks, and direction files as - transition evidence / beta history. -- 2026-06-09: Marked the workspace reimplementation roadmap, - workspace-agent-guidance, workspace-apply-repo-slice, and - workspace-verify-and-archive artifacts obsolete / pending deletion review. -- 2026-06-09: Reframed checked-in `use-openspec` guidance around OpenSpec - roots, artifact placement, and target project repos instead of beta - shared-context framing. -- 2026-06-09: Added a later review item for the `use-openspec` skill framing - after noticing the edited `.codex/` skill is ignored local guidance. -- 2026-06-09: Deferred the public concepts reframe until the simplified model - is more solid and implemented enough for public documentation. -- 2026-06-09: Reordered the roadmap around baseline, placement, standalone - OpenSpec repos, and target repo mapping; moved docs and public framing cleanup - to later phases. -- 2026-06-09: Reframed Phase 1 from preserving all current behavior to - recovering the simple OpenSpec baseline, and replaced compatibility-docs work - with as-needed residue removal. -- 2026-06-09: Collapsed baseline recovery and standalone placement into the - first implementation phase: make a standalone OpenSpec root path work, then - remove initiative coupling, add target repo mapping, and build open views as - local convenience. -- 2026-06-09: Reframed Phase 1 around context store as the named/registered - standalone OpenSpec repo bridge: reuse setup/register/list/doctor and store - selectors, keep planning state in normal `openspec/`, and move initiative - removal into its own product-path cleanup. -- 2026-06-09: Split Phase 1 into two research-gated slices: Store Root Parity - for setup/register creating or validating a normal standalone OpenSpec root, - and Store Selectors For Core Commands for routing lifecycle commands to a - selected store/root without initiative links. -- 2026-06-09: Added the Store Root Parity slice spec and linked it from the - roadmap. + opening sections of old initiative files as transition evidence and beta + history. +- 2026-06-09: Marked old workspace reimplementation artifacts obsolete or + pending deletion review. +- 2026-06-09: Reframed checked-in `use-openspec` guidance around OpenSpec roots, + artifact placement, and target project repos instead of beta shared-context + framing. +- 2026-06-09: Deferred public concept docs until the simplified model is more + solid. +- 2026-06-09: Reordered the roadmap around standalone OpenSpec repos, target + repo mapping, and local views. +- 2026-06-09: Added the store-root-parity slice spec. +- 2026-06-10: Rewrote this roadmap in user-facing language so each slice says + what the user can do, why it matters, what changes, and how success is + visible. From 2662ff4a0cb8795113f02b7df60e9e078297932f Mon Sep 17 00:00:00 2001 From: TabishB Date: Wed, 10 Jun 2026 08:24:35 +1000 Subject: [PATCH 003/111] Add roadmap progress checklists --- .../roadmap.md | 161 ++++++++++++++++-- 1 file changed, 147 insertions(+), 14 deletions(-) diff --git a/openspec/work/simplify-context-and-workspace-model/roadmap.md b/openspec/work/simplify-context-and-workspace-model/roadmap.md index c091a3178..344b878a5 100644 --- a/openspec/work/simplify-context-and-workspace-model/roadmap.md +++ b/openspec/work/simplify-context-and-workspace-model/roadmap.md @@ -70,14 +70,47 @@ workspace-owned planning, or collection state as the main model. - Treat old beta files as history unless they block the simpler path. - Do not rewrite public docs before the behavior is solid. +## Progress At A Glance + +Use this as the quick "where are we?" view. + +- [x] **Phase 0: Make the active direction easy to find.** + Old beta plans were marked as history, and this `/work` roadmap became the + active direction. +- [ ] **Phase 1: Make a standalone OpenSpec repo useful.** + One slice is implemented in draft PR #1190, but the full phase still needs + normal commands to work against a named standalone repo. +- [ ] **Phase 2: Stop putting new work through initiatives.** + Not started. +- [ ] **Phase 3: Say which project repos the work is about.** + Not started. +- [ ] **Phase 4: Open the right files together.** + Not started. +- [ ] **Phase 5: Remove old surfaces only when they confuse the simple path.** + Later cleanup; not started. + +Next incomplete item: + +- [ ] **Let normal commands use a named standalone OpenSpec repo.** + In plain English: when a user is in an app repo, they can tell OpenSpec to + create or read work in a registered standalone OpenSpec repo. + ## Phase 0: Make The Active Direction Easy To Find This phase is already done. It cleaned up old roadmap sources so agents and humans do not follow the wrong plan. +Phase checklist: + +- [x] Point people away from the old context-store beta plan. +- [x] Mark deferred workspace plans as not the current queue. +- [x] Reframe local agent guidance around OpenSpec roots. + ### Point People Away From The Old Context-Store Beta Plan -Status: done +Progress: + +- [x] Done. What the user or agent needs: @@ -98,7 +131,9 @@ How we know it worked: ### Mark Deferred Workspace Plans As Not The Current Queue -Status: done +Progress: + +- [x] Done. What the user or agent needs: @@ -117,7 +152,9 @@ How we know it worked: ### Reframe Local Agent Guidance Around OpenSpec Roots -Status: done +Progress: + +- [x] Done. What the user or agent needs: @@ -145,9 +182,24 @@ I can keep OpenSpec work in its own Git repo and still use normal OpenSpec commands. ``` +Phase checklist: + +- [x] Create or register a standalone OpenSpec repo. + Implemented in draft PR #1190. +- [ ] Let normal commands use a named standalone OpenSpec repo. + This is the next slice. +- [ ] Prove the standalone repo lifecycle end to end. + Do this after normal commands can use the selected repo. + ### Create Or Register A Standalone OpenSpec Repo -Status: implemented in draft PR #1190 +Progress: + +- [x] Spec written. +- [x] Plan written. +- [x] Implementation done in draft PR #1190. +- [x] Tests pass in draft PR #1190. +- [ ] Merged to `main`. Slice: `slices/store-root-parity/spec.md` @@ -193,7 +245,13 @@ How the user or agent knows it worked: ### Let Normal Commands Use A Named Standalone OpenSpec Repo -Status: next, research first +Progress: + +- [ ] Spec written. +- [ ] Plan written. +- [ ] Implementation done. +- [ ] Tests pass. +- [ ] Merged to `main`. Plain-English version of the next slice: @@ -257,7 +315,13 @@ How the user or agent knows it worked: ### Prove The Standalone Repo Lifecycle End To End -Status: candidate +Progress: + +- [ ] Spec written. +- [ ] Plan written. +- [ ] Smoke flow implemented. +- [ ] Tests pass. +- [ ] Merged to `main`. Plain-English version: @@ -304,9 +368,21 @@ Normal OpenSpec work should not require an initiative. Old initiative data can remain readable as legacy history, but the simpler path should stop attaching new work to initiatives. +Phase checklist: + +- [ ] Stop creating new initiative links in normal change flows. +- [ ] Hide or move initiative commands out of the main path. +- [ ] Make workspace opening stop depending on initiatives. + ### Stop Creating New Initiative Links In Normal Change Flows -Status: candidate +Progress: + +- [ ] Spec written. +- [ ] Plan written. +- [ ] Implementation done. +- [ ] Tests pass. +- [ ] Merged to `main`. What the user can do: @@ -334,7 +410,13 @@ How the user or agent knows it worked: ### Hide Or Demote Initiative Commands From The Main Path -Status: candidate +Progress: + +- [ ] Spec written. +- [ ] Plan written. +- [ ] Implementation done. +- [ ] Tests pass. +- [ ] Merged to `main`. What the user can do: @@ -361,7 +443,13 @@ How the user or agent knows it worked: ### Make Workspace Opening Stop Depending On Initiatives -Status: candidate +Progress: + +- [ ] Spec written. +- [ ] Plan written. +- [ ] Implementation done. +- [ ] Tests pass. +- [ ] Merged to `main`. What the user can do: @@ -392,9 +480,21 @@ The user-facing goal of this phase: This OpenSpec work lives here, and it targets these project repos. ``` +Phase checklist: + +- [ ] Let work declare its target project repos. +- [ ] Map target repo names to local checkout paths. +- [ ] Report whether target repos are available locally. + ### Let Work Declare Its Target Project Repos -Status: candidate +Progress: + +- [ ] Spec written. +- [ ] Plan written. +- [ ] Implementation done. +- [ ] Tests pass. +- [ ] Merged to `main`. What the user can do: @@ -419,7 +519,13 @@ How the user or agent knows it worked: ### Map Target Repo Names To Local Checkout Paths -Status: candidate +Progress: + +- [ ] Spec written. +- [ ] Plan written. +- [ ] Implementation done. +- [ ] Tests pass. +- [ ] Merged to `main`. What the user can do: @@ -443,7 +549,13 @@ How the user or agent knows it worked: ### Report Whether Target Repos Are Available Locally -Status: candidate +Progress: + +- [ ] Spec written. +- [ ] Plan written. +- [ ] Implementation done. +- [ ] Tests pass. +- [ ] Merged to `main`. What the user can do: @@ -476,9 +588,19 @@ Open my standalone OpenSpec repo and the project repos it targets in one useful local view. ``` +Phase checklist: + +- [ ] Open the OpenSpec repo and target repos together. + ### Open The OpenSpec Repo And Target Repos Together -Status: candidate +Progress: + +- [ ] Spec written. +- [ ] Plan written. +- [ ] Implementation done. +- [ ] Tests pass. +- [ ] Merged to `main`. What the user can do: @@ -514,9 +636,20 @@ Remove or hide old beta surfaces only when they make the simple path harder to use or understand. ``` +Phase checklist: + +- [ ] Remove or hide old workspace and initiative paths when they block or + confuse the simple path. + ### Remove Or Hide Old Workspace And Initiative Paths -Status: later +Progress: + +- [ ] Criteria agreed. +- [ ] Cleanup plan written. +- [ ] Cleanup done. +- [ ] Tests or review checks pass. +- [ ] Merged to `main`. What the user can do: From adc6ade8cac405096211924e83b7a10081c6f6a9 Mon Sep 17 00:00:00 2001 From: TabishB Date: Wed, 10 Jun 2026 08:28:12 +1000 Subject: [PATCH 004/111] Number roadmap work items --- .../roadmap.md | 107 +++++++++--------- 1 file changed, 56 insertions(+), 51 deletions(-) diff --git a/openspec/work/simplify-context-and-workspace-model/roadmap.md b/openspec/work/simplify-context-and-workspace-model/roadmap.md index 344b878a5..454ca3104 100644 --- a/openspec/work/simplify-context-and-workspace-model/roadmap.md +++ b/openspec/work/simplify-context-and-workspace-model/roadmap.md @@ -74,39 +74,42 @@ workspace-owned planning, or collection state as the main model. Use this as the quick "where are we?" view. -- [x] **Phase 0: Make the active direction easy to find.** +Numbered labels are roadmap work item ids. Smaller `Progress` checkboxes inside +an item are status steps for that numbered work item. + +- [x] **Phase 0. Make the active direction easy to find.** Old beta plans were marked as history, and this `/work` roadmap became the active direction. -- [ ] **Phase 1: Make a standalone OpenSpec repo useful.** +- [ ] **Phase 1. Make a standalone OpenSpec repo useful.** One slice is implemented in draft PR #1190, but the full phase still needs normal commands to work against a named standalone repo. -- [ ] **Phase 2: Stop putting new work through initiatives.** +- [ ] **Phase 2. Stop putting new work through initiatives.** Not started. -- [ ] **Phase 3: Say which project repos the work is about.** +- [ ] **Phase 3. Say which project repos the work is about.** Not started. -- [ ] **Phase 4: Open the right files together.** +- [ ] **Phase 4. Open the right files together.** Not started. -- [ ] **Phase 5: Remove old surfaces only when they confuse the simple path.** +- [ ] **Phase 5. Remove old surfaces only when they confuse the simple path.** Later cleanup; not started. Next incomplete item: -- [ ] **Let normal commands use a named standalone OpenSpec repo.** +- [ ] **1.2 Let normal commands use a named standalone OpenSpec repo.** In plain English: when a user is in an app repo, they can tell OpenSpec to create or read work in a registered standalone OpenSpec repo. -## Phase 0: Make The Active Direction Easy To Find +## Phase 0. Make The Active Direction Easy To Find This phase is already done. It cleaned up old roadmap sources so agents and humans do not follow the wrong plan. Phase checklist: -- [x] Point people away from the old context-store beta plan. -- [x] Mark deferred workspace plans as not the current queue. -- [x] Reframe local agent guidance around OpenSpec roots. +- [x] **0.1** Point people away from the old context-store beta plan. +- [x] **0.2** Mark deferred workspace plans as not the current queue. +- [x] **0.3** Reframe local agent guidance around OpenSpec roots. -### Point People Away From The Old Context-Store Beta Plan +### 0.1 Point People Away From The Old Context-Store Beta Plan Progress: @@ -129,7 +132,7 @@ How we know it worked: - A new reader can start from this `/work` folder instead of chasing the old initiative roadmap. -### Mark Deferred Workspace Plans As Not The Current Queue +### 0.2 Mark Deferred Workspace Plans As Not The Current Queue Progress: @@ -150,7 +153,7 @@ How we know it worked: - The old workspace changes no longer look like the next thing to implement. -### Reframe Local Agent Guidance Around OpenSpec Roots +### 0.3 Reframe Local Agent Guidance Around OpenSpec Roots Progress: @@ -173,7 +176,7 @@ How we know it worked: promises about clone, sync, branch, worktree, dashboard, or edit-boundary behavior. -## Phase 1: Make A Standalone OpenSpec Repo Useful +## Phase 1. Make A Standalone OpenSpec Repo Useful The user-facing goal of this phase: @@ -184,14 +187,14 @@ commands. Phase checklist: -- [x] Create or register a standalone OpenSpec repo. +- [x] **1.1** Create or register a standalone OpenSpec repo. Implemented in draft PR #1190. -- [ ] Let normal commands use a named standalone OpenSpec repo. +- [ ] **1.2** Let normal commands use a named standalone OpenSpec repo. This is the next slice. -- [ ] Prove the standalone repo lifecycle end to end. +- [ ] **1.3** Prove the standalone repo lifecycle end to end. Do this after normal commands can use the selected repo. -### Create Or Register A Standalone OpenSpec Repo +### 1.1 Create Or Register A Standalone OpenSpec Repo Progress: @@ -243,7 +246,7 @@ How the user or agent knows it worked: - Existing config, specs, changes, archived changes, and old beta files are not overwritten. -### Let Normal Commands Use A Named Standalone OpenSpec Repo +### 1.2 Let Normal Commands Use A Named Standalone OpenSpec Repo Progress: @@ -313,7 +316,7 @@ How the user or agent knows it worked: - JSON output shows which OpenSpec root was used. - No new initiative link is created. -### Prove The Standalone Repo Lifecycle End To End +### 1.3 Prove The Standalone Repo Lifecycle End To End Progress: @@ -357,7 +360,7 @@ How the user or agent knows it worked: - The final files are normal `openspec/specs/`, `openspec/changes/`, and `openspec/changes/archive/` files in the standalone repo. -## Phase 2: Stop Putting New Work Through Initiatives +## Phase 2. Stop Putting New Work Through Initiatives The user-facing goal of this phase: @@ -370,11 +373,11 @@ should stop attaching new work to initiatives. Phase checklist: -- [ ] Stop creating new initiative links in normal change flows. -- [ ] Hide or move initiative commands out of the main path. -- [ ] Make workspace opening stop depending on initiatives. +- [ ] **2.1** Stop creating new initiative links in normal change flows. +- [ ] **2.2** Hide or move initiative commands out of the main path. +- [ ] **2.3** Make workspace opening stop depending on initiatives. -### Stop Creating New Initiative Links In Normal Change Flows +### 2.1 Stop Creating New Initiative Links In Normal Change Flows Progress: @@ -408,7 +411,7 @@ How the user or agent knows it worked: - New changes do not get initiative metadata by default. - Old initiative-linked changes can still be displayed or handled as legacy. -### Hide Or Demote Initiative Commands From The Main Path +### 2.2 Hide Or Move Initiative Commands Out Of The Main Path Progress: @@ -441,7 +444,7 @@ How the user or agent knows it worked: - Existing initiative data remains untouched unless an explicit cleanup slice says otherwise. -### Make Workspace Opening Stop Depending On Initiatives +### 2.3 Make Workspace Opening Stop Depending On Initiatives Progress: @@ -472,7 +475,7 @@ How the user or agent knows it worked: - Opening a view does not create or require initiative planning state. - Errors explain local view problems separately from OpenSpec-root problems. -## Phase 3: Say Which Project Repos The Work Is About +## Phase 3. Say Which Project Repos The Work Is About The user-facing goal of this phase: @@ -482,11 +485,11 @@ This OpenSpec work lives here, and it targets these project repos. Phase checklist: -- [ ] Let work declare its target project repos. -- [ ] Map target repo names to local checkout paths. -- [ ] Report whether target repos are available locally. +- [ ] **3.1** Let work declare its target project repos. +- [ ] **3.2** Map target repo names to local checkout paths. +- [ ] **3.3** Report whether target repos are available locally. -### Let Work Declare Its Target Project Repos +### 3.1 Let Work Declare Its Target Project Repos Progress: @@ -517,7 +520,7 @@ How the user or agent knows it worked: - A change can clearly say which project repo ids it targets. - The declaration is visible in normal OpenSpec files or metadata. -### Map Target Repo Names To Local Checkout Paths +### 3.2 Map Target Repo Names To Local Checkout Paths Progress: @@ -547,7 +550,7 @@ How the user or agent knows it worked: - Given a target repo id, OpenSpec can resolve the local checkout path. - If the path is missing or ambiguous, the error tells the user what to fix. -### Report Whether Target Repos Are Available Locally +### 3.3 Report Whether Target Repos Are Available Locally Progress: @@ -579,7 +582,7 @@ How the user or agent knows it worked: - The output does not attempt clone, pull, push, sync, branch, or worktree behavior. -## Phase 4: Open The Right Files Together +## Phase 4. Open The Right Files Together The user-facing goal of this phase: @@ -590,9 +593,9 @@ local view. Phase checklist: -- [ ] Open the OpenSpec repo and target repos together. +- [ ] **4.1** Open the OpenSpec repo and target repos together. -### Open The OpenSpec Repo And Target Repos Together +### 4.1 Open The OpenSpec Repo And Target Repos Together Progress: @@ -627,7 +630,7 @@ How the user or agent knows it worked: - The view does not imply clone, pull, push, sync, branch, worktree, dashboard, or edit-boundary enforcement. -## Phase 5: Remove Old Surfaces Only When They Confuse The Simple Path +## Phase 5. Remove Old Surfaces Only When They Confuse The Simple Path The user-facing goal of this phase: @@ -638,10 +641,10 @@ use or understand. Phase checklist: -- [ ] Remove or hide old workspace and initiative paths when they block or +- [ ] **5.1** Remove or hide old workspace and initiative paths when they block or confuse the simple path. -### Remove Or Hide Old Workspace And Initiative Paths +### 5.1 Remove Or Hide Old Workspace And Initiative Paths Progress: @@ -680,24 +683,24 @@ How the user or agent knows it worked: Keep these out of the main queue until the simpler standalone OpenSpec repo path is working: -- Rewrite public concept docs after behavior is solid. -- Decide how accepted workspace-planning specs should change once behavior has +- **L1** Rewrite public concept docs after behavior is solid. +- **L2** Decide how accepted workspace-planning specs should change once behavior has changed. -- Add richer cross-repo context and doctoring after target repo mapping works. -- Consider first-class `work/` only after the baseline and standalone repo flow +- **L3** Add richer cross-repo context and doctoring after target repo mapping works. +- **L4** Consider first-class `work/` only after the baseline and standalone repo flow are solid. -- Revisit whether `changes/` should evolve into change-shaped work under +- **L5** Revisit whether `changes/` should evolve into change-shaped work under `work/`. -- Add machine-readable `/work` metadata only after the manual shape proves +- **L6** Add machine-readable `/work` metadata only after the manual shape proves useful. -- Decide whether to keep, rename, or replace `context-store` terminology after +- **L7** Decide whether to keep, rename, or replace `context-store` terminology after the bridge behavior proves useful. -- Review local `use-openspec` skill guidance and decide whether it should be an +- **L8** Review local `use-openspec` skill guidance and decide whether it should be an ignored local skill, generated artifact, checked-in source, or productized default. -- Fix small baseline quirks, such as JSON support for `openspec list --specs`, +- **L9** Fix small baseline quirks, such as JSON support for `openspec list --specs`, only if they matter to the simple standalone repo flow. -- Reintroduce initiative-like behavior only as a Git-native work type if it +- **L10** Reintroduce initiative-like behavior only as a Git-native work type if it still proves useful later. ## Roadmap Change Log @@ -726,3 +729,5 @@ is working: - 2026-06-10: Rewrote this roadmap in user-facing language so each slice says what the user can do, why it matters, what changes, and how success is visible. +- 2026-06-10: Numbered phases, phase subitems, and later parking-lot ideas so + progress can be tracked unambiguously. From 245f6b84b5e57cc3a81fd7dec3621ef13aab6281 Mon Sep 17 00:00:00 2001 From: TabishB Date: Thu, 11 Jun 2026 02:27:00 +1000 Subject: [PATCH 005/111] Add --store root selection for normal commands Implements the store-root-selection slice (1.2, with 2.1 pulled forward): - Add a shared OpenSpec-root resolver (src/core/root-selection.ts) behind new change, status, instructions, list, show, validate, and archive. --store resolves a registered context store to an ordinary OpenSpec root; identity and root-health failures point to context-store doctor. - Leftover workspace view state never wins root resolution for these commands, and a no-root directory with registered stores errors with a store-selection hint instead of scaffolding an implicit root. - Selected-store runs print "Using OpenSpec root: ()" to stderr and JSON successes carry an additive shared root block. - --store-path is rejected deliberately with context-store register guidance, including on show despite allowUnknownOption. - new change is root selection only: initiative-link creation is removed, --initiative and --areas reject before any writes, --goal stays ordinary metadata. openspec set change is removed along with initiative-link.ts. - archive gains --json: non-interactive, machine-readable diagnostics for blocked paths, and no prose or blank lines on stdout. - list gains minimal --specs --json support so specs listing participates in the root reporting contract. - context-store setup/register next steps show --store usage. --- docs/cli.md | 37 +- src/cli/index.ts | 86 ++- src/commands/change.ts | 35 +- src/commands/context-store.ts | 3 +- src/commands/show.ts | 77 ++- src/commands/spec.ts | 19 +- src/commands/validate.ts | 70 ++- src/commands/workflow/index.ts | 3 - src/commands/workflow/initiative-link.ts | 81 --- src/commands/workflow/instructions.ts | 46 +- src/commands/workflow/new-change.ts | 167 ++---- src/commands/workflow/set-change.ts | 148 ----- src/commands/workflow/shared.ts | 25 + src/commands/workflow/status.ts | 36 +- src/core/archive.ts | 318 ++++++++-- src/core/completions/command-registry.ts | 65 +- src/core/completions/shared-flags.ts | 5 + src/core/list.ts | 26 +- src/core/root-selection.ts | 342 +++++++++++ src/core/specs-apply.ts | 5 +- test/commands/artifact-workflow.test.ts | 155 +---- test/commands/change-initiative-link.test.ts | 532 ++--------------- test/commands/store-root-selection.test.ts | 562 ++++++++++++++++++ test/core/archive.test.ts | 23 +- .../core/completions/command-registry.test.ts | 31 +- test/core/root-selection.test.ts | 272 +++++++++ 26 files changed, 1961 insertions(+), 1208 deletions(-) delete mode 100644 src/commands/workflow/initiative-link.ts delete mode 100644 src/commands/workflow/set-change.ts create mode 100644 src/core/root-selection.ts create mode 100644 test/commands/store-root-selection.test.ts create mode 100644 test/core/root-selection.test.ts diff --git a/docs/cli.md b/docs/cli.md index 103dd7d4f..c306467d4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -12,7 +12,7 @@ The OpenSpec CLI (`openspec`) provides terminal commands for project setup, vali | **Browsing** | `list`, `view`, `show` | Explore changes and specs | | **Validation** | `validate` | Check changes and specs for issues | | **Lifecycle** | `archive` | Finalize completed changes | -| **Workflow** | `new change`, `set change`, `status`, `instructions`, `templates`, `schemas` | Artifact-driven workflow support | +| **Workflow** | `new change`, `status`, `instructions`, `templates`, `schemas` | Artifact-driven workflow support | | **Schemas** | `schema init`, `schema fork`, `schema validate`, `schema which` | Create and manage custom workflows | | **Config** | `config` | View and modify settings | | **Utility** | `feedback`, `completion` | Feedback and shell integration | @@ -62,8 +62,7 @@ These commands support `--json` output for programmatic use by AI agents and scr | `openspec context-store doctor` | Check local store setup | `--json` for structured diagnostics | | `openspec initiative list` | Browse shared initiatives | `--json` for structured initiative records | | `openspec initiative show ` | Resolve an initiative | `--json` for canonical paths and metadata | -| `openspec new change ` | Create repo-local change scaffolding | `--json`, plus `--initiative` for shared coordination links | -| `openspec set change ` | Update checked-in change metadata | `--json`, plus `--initiative` for shared coordination links | +| `openspec new change ` | Create repo-local change scaffolding | `--json`, plus `--store ` to target a registered standalone OpenSpec repo | --- @@ -738,7 +737,7 @@ These commands support the artifact-driven OPSX workflow. They're useful for bot ### `openspec new change` -Create a repo-local change directory and optional checked-in metadata. +Create a change directory and optional checked-in metadata in the resolved OpenSpec root. ```bash openspec new change [options] @@ -749,40 +748,18 @@ openspec new change [options] | Option | Description | |--------|-------------| | `--description ` | Description to add to `README.md` | -| `--goal ` | Workspace product goal to store with the change | -| `--areas ` | Comma-separated affected workspace link names | -| `--initiative ` | Link the repo-local change to an initiative | -| `--store ` | Context store id for `--initiative` | -| `--store-path ` | Existing local context store root for `--initiative` | +| `--goal ` | Optional goal metadata to store with the change | | `--schema ` | Workflow schema to use | +| `--store ` | Registered context store id to use as the OpenSpec root | | `--json` | Output JSON | Examples: ```bash -openspec new change add-billing-api --initiative billing-launch --store platform -openspec new change add-billing-api --initiative platform/billing-launch --json +openspec new change add-billing-api +openspec new change add-billing-api --store team-context --json ``` -### `openspec set change` - -Update checked-in repo-local change metadata without recreating the change. - -```bash -openspec set change [options] -``` - -**Options:** - -| Option | Description | -|--------|-------------| -| `--initiative ` | Link the repo-local change to an initiative | -| `--store ` | Context store id for `--initiative` | -| `--store-path ` | Existing local context store root for `--initiative` | -| `--json` | Output JSON | - -`set change --initiative` is idempotent when the requested link already exists and refuses to replace a different existing initiative link. - ### `openspec status` Display artifact completion status for a change. diff --git a/src/cli/index.ts b/src/cli/index.ts index 0c42f43cb..4484ac6b5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,4 @@ -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { createRequire } from 'module'; import ora from 'ora'; import path from 'path'; @@ -7,8 +7,9 @@ import { promises as fs } from 'fs'; import { AI_TOOLS, OPENSPEC_DIR_NAME } from '../core/config.js'; import { UpdateCommand } from '../core/update.js'; import { ListCommand } from '../core/list.js'; -import { ArchiveCommand } from '../core/archive.js'; +import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js'; import { ViewCommand } from '../core/view.js'; +import { emitStoreRootBanner, resolveRootForCommand, toRootOutput } from '../core/root-selection.js'; import { registerSpecCommand } from '../commands/spec.js'; import { ChangeCommand } from '../commands/change.js'; import { ValidateCommand } from '../commands/validate.js'; @@ -28,17 +29,28 @@ import { templatesCommand, schemasCommand, newChangeCommand, - setChangeCommand, DEFAULT_SCHEMA, type StatusOptions, type InstructionsOptions, type TemplatesOptions, type SchemasOptions, type NewChangeOptions, - type SetChangeOptions, } from '../commands/workflow/index.js'; import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; +const STORE_OPTION_DESCRIPTION = 'Registered context store id to use as the OpenSpec root'; + +// Deliberate rejection path: --store-path stays registered (hidden) so the +// resolver can explain that registering the path is the supported route, +// instead of Commander emitting a generic unknown-option error (or, for +// `show`, silently ignoring it via allowUnknownOption). +function hiddenStorePathOption(): Option { + return new Option( + '--store-path ', + 'Not supported; register the path with context-store register and use --store ' + ).hideHelp(); +} + const program = new Command(); const require = createRequire(import.meta.url); const { version } = require('../../package.json'); @@ -211,12 +223,25 @@ program .option('--changes', 'List changes explicitly (default)') .option('--sort ', 'Sort order: "recent" (default) or "name"', 'recent') .option('--json', 'Output as JSON (for programmatic use)') - .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean }) => { + .option('--store ', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) + .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { try { + const root = await resolveRootForCommand(options ?? {}, { json: options?.json }); + if (!root) { + return; + } + if (!options?.json) { + emitStoreRootBanner(root); + } const listCommand = new ListCommand(); const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes'; const sort = options?.sort === 'name' ? 'name' : 'recent'; - await listCommand.execute('.', mode, { sort, json: options?.json }); + await listCommand.execute(root.path, mode, { + sort, + json: options?.json, + ...(options?.json ? { root: toRootOutput(root) } : {}), + }); } catch (error) { console.log(); // Empty line for spacing ora().fail(`Error: ${(error as Error).message}`); @@ -306,7 +331,10 @@ program .option('-y, --yes', 'Skip confirmation prompts') .option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)') .option('--no-validate', 'Skip validation (not recommended, requires confirmation)') - .action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean }) => { + .option('--json', 'Output as JSON (non-interactive)') + .option('--store ', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) + .action(async (changeName?: string, options?: ArchiveOptions) => { try { const archiveCommand = new ArchiveCommand(); await archiveCommand.execute(changeName, options); @@ -336,7 +364,9 @@ program .option('--json', 'Output validation results as JSON') .option('--concurrency ', 'Max concurrent validations (defaults to env OPENSPEC_CONCURRENCY or 6)') .option('--no-interactive', 'Disable interactive prompts') - .action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string }) => { + .option('--store ', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) + .action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string }) => { try { const validateCommand = new ValidateCommand(); await validateCommand.execute(itemName, options); @@ -361,6 +391,10 @@ program .option('--requirements', 'JSON only: Show only requirements (exclude scenarios)') .option('--no-scenarios', 'JSON only: Exclude scenario content') .option('-r, --requirement ', 'JSON only: Show specific requirement by ID (1-based)') + .option('--store ', STORE_OPTION_DESCRIPTION) + // Explicit registration required: allowUnknownOption would otherwise + // silently swallow --store-path instead of rejecting it deliberately. + .addOption(hiddenStorePathOption()) // allow unknown options to pass-through to underlying command implementation .allowUnknownOption(true) .action(async (itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any }) => { @@ -464,6 +498,8 @@ program .option('--change ', 'Change name to show status for') .option('--schema ', 'Schema override (auto-detected from config.yaml)') .option('--json', 'Output as JSON') + .option('--store ', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) .action(async (options: StatusOptions) => { try { await statusCommand(options); @@ -481,6 +517,8 @@ program .option('--change ', 'Change name') .option('--schema ', 'Schema override (auto-detected from config.yaml)') .option('--json', 'Output as JSON') + .option('--store ', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) .action(async (artifactId: string | undefined, options: InstructionsOptions) => { try { // Special case: "apply" is not an artifact, but a command to get apply instructions @@ -534,13 +572,15 @@ newCmd .command('change ') .description('Create a new change directory') .option('--description ', 'Description to add to README.md') - .option('--goal ', 'Workspace product goal to store with the change') - .option('--areas ', 'Comma-separated affected workspace link names') - .option('--initiative ', 'Link the repo-local change to an initiative') - .option('--store ', 'Context store id for --initiative') - .option('--store-path ', 'Existing local context store root for --initiative') + .option('--goal ', 'Optional goal metadata to store with the change') .option('--schema ', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) .option('--json', 'Output as JSON') + .option('--store ', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) + // Removed options kept registered (hidden) so users get a deliberate + // explanation instead of a generic unknown-option error. + .addOption(new Option('--initiative ', 'No longer supported').hideHelp()) + .addOption(new Option('--areas ', 'No longer supported').hideHelp()) .action(async (name: string, options: NewChangeOptions) => { try { await newChangeCommand(name, options); @@ -551,26 +591,6 @@ newCmd } }); -// Set command group -const setCmd = program.command('set').description('Set checked-in OpenSpec metadata'); - -setCmd - .command('change ') - .description('Set repo-local change metadata') - .option('--initiative ', 'Link the repo-local change to an initiative') - .option('--store ', 'Context store id for --initiative') - .option('--store-path ', 'Existing local context store root for --initiative') - .option('--json', 'Output as JSON') - .action(async (name: string, options: SetChangeOptions) => { - try { - await setChangeCommand(name, options); - } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); - process.exit(1); - } - }); - export { program }; export function runCli(argv = process.argv): void { diff --git a/src/commands/change.ts b/src/commands/change.ts index 051b4697c..eae9fffd4 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -4,6 +4,7 @@ import { JsonConverter } from '../core/converters/json-converter.js'; import { Validator } from '../core/validation/validator.js'; import { ChangeParser } from '../core/parsers/change-parser.js'; import { Change } from '../core/schemas/index.js'; +import type { RootOutput } from '../core/root-selection.js'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds } from '../utils/item-discovery.js'; @@ -14,9 +15,17 @@ const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i; export class ChangeCommand { private converter: JsonConverter; + private rootPath?: string; - constructor() { + // rootPath is set only by root-aware callers (top-level `show`); the + // deprecated noun-form commands stay cwd-based. + constructor(rootPath?: string) { this.converter = new JsonConverter(); + this.rootPath = rootPath; + } + + private getChangesPath(): string { + return path.join(this.rootPath ?? process.cwd(), 'openspec', 'changes'); } /** @@ -25,8 +34,8 @@ export class ChangeCommand { * - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas * Note: --requirements-only is deprecated alias for --deltas-only */ - async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }): Promise { - const changesPath = path.join(process.cwd(), 'openspec', 'changes'); + async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean; rootOutput?: RootOutput }): Promise { + const changesPath = this.getChangesPath(); if (!changeName) { const canPrompt = isInteractive(options); @@ -71,18 +80,14 @@ export class ChangeCommand { const id = parsed.name; const deltas = parsed.deltas || []; - if (options.requirementsOnly || options.deltasOnly) { - const output = { id, title, deltaCount: deltas.length, deltas }; - console.log(JSON.stringify(output, null, 2)); - } else { - const output = { - id, - title, - deltaCount: deltas.length, - deltas, - }; - console.log(JSON.stringify(output, null, 2)); - } + const output = { + id, + title, + deltaCount: deltas.length, + deltas, + ...(options.rootOutput ? { root: options.rootOutput } : {}), + }; + console.log(JSON.stringify(output, null, 2)); } else { const content = await fs.readFile(proposalPath, 'utf-8'); console.log(content); diff --git a/src/commands/context-store.ts b/src/commands/context-store.ts index baf1b39cd..36c5c64bb 100644 --- a/src/commands/context-store.ts +++ b/src/commands/context-store.ts @@ -421,7 +421,8 @@ function printMutationHuman(title: string, payload: ContextStoreMutationOutput): console.log(`${status.severity === 'error' ? 'Issue' : 'Note'}: ${status.message}`); } console.log(''); - console.log('Next: use normal OpenSpec specs and changes in this store.'); + console.log('Next: run normal OpenSpec commands against this store, for example:'); + console.log(` openspec new change --store ${payload.context_store.id}`); } function printCleanupHuman(title: string, payload: ContextStoreCleanupOutput): void { diff --git a/src/commands/show.ts b/src/commands/show.ts index 6413b5951..868e062d2 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -1,6 +1,12 @@ -import path from 'path'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js'; +import { + emitStoreRootBanner, + resolveRootForCommand, + toRootOutput, + type ResolvedOpenSpecRoot, + type RootOutput, +} from '../core/root-selection.js'; import { ChangeCommand } from './change.js'; import { SpecCommand } from './spec.js'; import { nearestMatches } from '../utils/match.js'; @@ -10,8 +16,26 @@ type ItemType = 'change' | 'spec'; const CHANGE_FLAG_KEYS = new Set(['deltasOnly', 'requirementsOnly']); const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']); +interface ShowExecuteOptions { + json?: boolean; + type?: string; + noInteractive?: boolean; + store?: string; + storePath?: string; + [k: string]: any; +} + export class ShowCommand { - async execute(itemName?: string, options: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any } = {}): Promise { + async execute(itemName?: string, options: ShowExecuteOptions = {}): Promise { + const root = await resolveRootForCommand(options, { json: options.json }); + if (!root) { + return; + } + + if (!options.json) { + emitStoreRootBanner(root); + } + const interactive = isInteractive(options); const typeOverride = this.normalizeType(options.type); @@ -25,7 +49,7 @@ export class ShowCommand { { name: 'Spec', value: 'spec' as const }, ], }); - await this.runInteractiveByType(type, options); + await this.runInteractiveByType(type, options, root); return; } this.printNonInteractiveHint(); @@ -33,7 +57,7 @@ export class ShowCommand { return; } - await this.showDirect(itemName, { typeOverride, options }); + await this.showDirect(itemName, { typeOverride, options, root }); } private normalizeType(value?: string): ItemType | undefined { @@ -43,46 +67,61 @@ export class ShowCommand { return undefined; } - private async runInteractiveByType(type: ItemType, options: { json?: boolean; noInteractive?: boolean; [k: string]: any }): Promise { + private delegateOptions(root: ResolvedOpenSpecRoot, options: ShowExecuteOptions): ShowExecuteOptions & { rootOutput?: RootOutput } { + return { + ...options, + ...(options.json ? { rootOutput: toRootOutput(root) } : {}), + }; + } + + private async runInteractiveByType( + type: ItemType, + options: ShowExecuteOptions, + root: ResolvedOpenSpecRoot + ): Promise { const { select } = await import('@inquirer/prompts'); if (type === 'change') { - const changes = await getActiveChangeIds(); + const changes = await getActiveChangeIds(root.path); if (changes.length === 0) { console.error('No changes found.'); process.exitCode = 1; return; } const picked = await select({ message: 'Pick a change', choices: changes.map(id => ({ name: id, value: id })) }); - const cmd = new ChangeCommand(); - await cmd.show(picked, options as any); + const cmd = new ChangeCommand(root.path); + await cmd.show(picked, this.delegateOptions(root, options) as any); return; } - const specs = await getSpecIds(); + const specs = await getSpecIds(root.path); if (specs.length === 0) { console.error('No specs found.'); process.exitCode = 1; return; } const picked = await select({ message: 'Pick a spec', choices: specs.map(id => ({ name: id, value: id })) }); - const cmd = new SpecCommand(); - await cmd.show(picked, options as any); + const cmd = new SpecCommand(root.path); + await cmd.show(picked, this.delegateOptions(root, options) as any); } - private async showDirect(itemName: string, params: { typeOverride?: ItemType; options: { json?: boolean; [k: string]: any } }): Promise { + private async showDirect( + itemName: string, + params: { typeOverride?: ItemType; options: ShowExecuteOptions; root: ResolvedOpenSpecRoot } + ): Promise { + const root = params.root; // Optimize lookups when type is pre-specified let isChange = false; let isSpec = false; let changes: string[] = []; let specs: string[] = []; if (params.typeOverride === 'change') { - changes = await getActiveChangeIds(); + changes = await getActiveChangeIds(root.path); isChange = changes.includes(itemName); } else if (params.typeOverride === 'spec') { - specs = await getSpecIds(); + specs = await getSpecIds(root.path); isSpec = specs.includes(itemName); } else { - [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]); + [changes, specs] = await Promise.all([getActiveChangeIds(root.path), getSpecIds(root.path)]); isChange = changes.includes(itemName); isSpec = specs.includes(itemName); } @@ -106,12 +145,12 @@ export class ShowCommand { this.warnIrrelevantFlags(resolvedType, params.options); if (resolvedType === 'change') { - const cmd = new ChangeCommand(); - await cmd.show(itemName, params.options as any); + const cmd = new ChangeCommand(root.path); + await cmd.show(itemName, this.delegateOptions(root, params.options) as any); return; } - const cmd = new SpecCommand(); - await cmd.show(itemName, params.options as any); + const cmd = new SpecCommand(root.path); + await cmd.show(itemName, this.delegateOptions(root, params.options) as any); } private printNonInteractiveHint(): void { diff --git a/src/commands/spec.ts b/src/commands/spec.ts index d28052f14..d158ea5ad 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import { MarkdownParser } from '../core/parsers/markdown-parser.js'; import { Validator } from '../core/validation/validator.js'; import type { Spec } from '../core/schemas/index.js'; +import type { RootOutput } from '../core/root-selection.js'; import { isInteractive } from '../utils/interactive.js'; import { getSpecIds } from '../utils/item-discovery.js'; @@ -16,6 +17,7 @@ interface ShowOptions { scenarios?: boolean; // --no-scenarios sets this to false (JSON only) requirement?: string; // JSON only noInteractive?: boolean; + rootOutput?: RootOutput; } function parseSpecFromFile(specPath: string, specId: string): Spec { @@ -65,12 +67,20 @@ function printSpecTextRaw(specPath: string): void { } export class SpecCommand { - private SPECS_DIR = 'openspec/specs'; + private specsDir: string; + private rootPath?: string; + + // rootPath is set only by root-aware callers (top-level `show`); the + // deprecated noun-form commands stay cwd-based. + constructor(rootPath?: string) { + this.rootPath = rootPath; + this.specsDir = rootPath ? join(rootPath, 'openspec', 'specs') : SPECS_DIR; + } async show(specId?: string, options: ShowOptions = {}): Promise { if (!specId) { const canPrompt = isInteractive(options); - const specIds = await getSpecIds(); + const specIds = await getSpecIds(this.rootPath ?? process.cwd()); if (canPrompt && specIds.length > 0) { const { select } = await import('@inquirer/prompts'); specId = await select({ @@ -82,9 +92,9 @@ export class SpecCommand { } } - const specPath = join(this.SPECS_DIR, specId, 'spec.md'); + const specPath = join(this.specsDir, specId, 'spec.md'); if (!existsSync(specPath)) { - throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`); + throw new Error(`Spec '${specId}' not found at ${specPath}`); } if (options.json) { @@ -100,6 +110,7 @@ export class SpecCommand { requirementCount: filtered.requirements.length, requirements: filtered.requirements, metadata: parsed.metadata ?? { version: '1.0.0', format: 'openspec' as const }, + ...(options.rootOutput ? { root: options.rootOutput } : {}), }; console.log(JSON.stringify(output, null, 2)); return; diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 9e59a4d48..3387f0261 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,6 +1,13 @@ import ora from 'ora'; import path from 'path'; import { Validator } from '../core/validation/validator.js'; +import { + emitStoreRootBanner, + resolveRootForCommand, + toRootOutput, + type ResolvedOpenSpecRoot, + type RootOutput, +} from '../core/root-selection.js'; import { isInteractive, resolveNoInteractive } from '../utils/interactive.js'; import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js'; import { nearestMatches } from '../utils/match.js'; @@ -17,6 +24,8 @@ interface ExecuteOptions { noInteractive?: boolean; interactive?: boolean; // Commander sets this to false when --no-interactive is used concurrency?: string; + store?: string; + storePath?: string; } interface BulkItemResult { @@ -29,11 +38,20 @@ interface BulkItemResult { export class ValidateCommand { async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise { + const root = await resolveRootForCommand(options, { json: options.json }); + if (!root) { + return; + } + + if (!options.json) { + emitStoreRootBanner(root); + } + const interactive = isInteractive(options); // Handle bulk flags first if (options.all || options.changes || options.specs) { - await this.runBulkValidation({ + await this.runBulkValidation(root, { changes: !!options.all || !!options.changes, specs: !!options.all || !!options.specs, }, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) }); @@ -43,7 +61,7 @@ export class ValidateCommand { // No item and no flags if (!itemName) { if (interactive) { - await this.runInteractiveSelector({ strict: !!options.strict, json: !!options.json, concurrency: options.concurrency }); + await this.runInteractiveSelector(root, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency }); return; } this.printNonInteractiveHint(); @@ -53,7 +71,7 @@ export class ValidateCommand { // Direct item validation with type detection or override const typeOverride = this.normalizeType(options.type); - await this.validateDirectItem(itemName, { typeOverride, strict: !!options.strict, json: !!options.json }); + await this.validateDirectItem(root, itemName, { typeOverride, strict: !!options.strict, json: !!options.json }); } private normalizeType(value?: string): ItemType | undefined { @@ -63,7 +81,7 @@ export class ValidateCommand { return undefined; } - private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string }): Promise { + private async runInteractiveSelector(root: ResolvedOpenSpecRoot, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise { const { select } = await import('@inquirer/prompts'); const choice = await select({ message: 'What would you like to validate?', @@ -75,12 +93,12 @@ export class ValidateCommand { ], }); - if (choice === 'all') return this.runBulkValidation({ changes: true, specs: true }, opts); - if (choice === 'changes') return this.runBulkValidation({ changes: true, specs: false }, opts); - if (choice === 'specs') return this.runBulkValidation({ changes: false, specs: true }, opts); + if (choice === 'all') return this.runBulkValidation(root, { changes: true, specs: true }, opts); + if (choice === 'changes') return this.runBulkValidation(root, { changes: true, specs: false }, opts); + if (choice === 'specs') return this.runBulkValidation(root, { changes: false, specs: true }, opts); // one - const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]); + const [changes, specs] = await Promise.all([getActiveChangeIds(root.path), getSpecIds(root.path)]); const items: { name: string; value: { type: ItemType; id: string } }[] = []; items.push(...changes.map(id => ({ name: `change/${id}`, value: { type: 'change' as const, id } }))); items.push(...specs.map(id => ({ name: `spec/${id}`, value: { type: 'spec' as const, id } }))); @@ -90,7 +108,7 @@ export class ValidateCommand { return; } const picked = await select<{ type: ItemType; id: string }>({ message: 'Pick an item', choices: items }); - await this.validateByType(picked.type, picked.id, opts); + await this.validateByType(root, picked.type, picked.id, opts); } private printNonInteractiveHint(): void { @@ -102,8 +120,8 @@ export class ValidateCommand { console.error('Or run in an interactive terminal.'); } - private async validateDirectItem(itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise { - const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]); + private async validateDirectItem(root: ResolvedOpenSpecRoot, itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise { + const [changes, specs] = await Promise.all([getActiveChangeIds(root.path), getSpecIds(root.path)]); const isChange = changes.includes(itemName); const isSpec = specs.includes(itemName); @@ -124,32 +142,32 @@ export class ValidateCommand { return; } - await this.validateByType(type, itemName, opts); + await this.validateByType(root, type, itemName, opts); } - private async validateByType(type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise { + private async validateByType(root: ResolvedOpenSpecRoot, type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise { const validator = new Validator(opts.strict); if (type === 'change') { - const changeDir = path.join(process.cwd(), 'openspec', 'changes', id); + const changeDir = path.join(root.changesDir, id); const start = Date.now(); const report = await validator.validateChangeDeltaSpecs(changeDir); const durationMs = Date.now() - start; - this.printReport('change', id, report, durationMs, opts.json); + this.printReport('change', id, report, durationMs, opts.json, toRootOutput(root)); // Non-zero exit if invalid (keeps enriched output test semantics) process.exitCode = report.valid ? 0 : 1; return; } - const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md'); + const file = path.join(root.specsDir, id, 'spec.md'); const start = Date.now(); const report = await validator.validateSpec(file); const durationMs = Date.now() - start; - this.printReport('spec', id, report, durationMs, opts.json); + this.printReport('spec', id, report, durationMs, opts.json, toRootOutput(root)); process.exitCode = report.valid ? 0 : 1; } - private printReport(type: ItemType, id: string, report: { valid: boolean; issues: any[] }, durationMs: number, json: boolean): void { + private printReport(type: ItemType, id: string, report: { valid: boolean; issues: any[] }, durationMs: number, json: boolean, root: RootOutput): void { if (json) { - const out = { items: [{ id, type, valid: report.valid, issues: report.issues, durationMs }], summary: { totals: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 }, byType: { [type]: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 } } }, version: '1.0' }; + const out = { items: [{ id, type, valid: report.valid, issues: report.issues, durationMs }], summary: { totals: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 }, byType: { [type]: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 } } }, version: '1.0', root }; console.log(JSON.stringify(out, null, 2)); return; } @@ -181,11 +199,11 @@ export class ValidateCommand { bullets.forEach(b => console.error(` ${b}`)); } - private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise { + private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise { const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined; const [changeIds, specIds] = await Promise.all([ - scope.changes ? getActiveChangeIds() : Promise.resolve([]), - scope.specs ? getSpecIds() : Promise.resolve([]), + scope.changes ? getActiveChangeIds(root.path) : Promise.resolve([]), + scope.specs ? getSpecIds(root.path) : Promise.resolve([]), ]); const DEFAULT_CONCURRENCY = 6; @@ -197,7 +215,7 @@ export class ValidateCommand { for (const id of changeIds) { queue.push(async () => { const start = Date.now(); - const changeDir = path.join(process.cwd(), 'openspec', 'changes', id); + const changeDir = path.join(root.changesDir, id); const report = await validator.validateChangeDeltaSpecs(changeDir); const durationMs = Date.now() - start; return { id, type: 'change' as const, valid: report.valid, issues: report.issues, durationMs }; @@ -206,7 +224,7 @@ export class ValidateCommand { for (const id of specIds) { queue.push(async () => { const start = Date.now(); - const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md'); + const file = path.join(root.specsDir, id, 'spec.md'); const report = await validator.validateSpec(file); const durationMs = Date.now() - start; return { id, type: 'spec' as const, valid: report.valid, issues: report.issues, durationMs }; @@ -225,7 +243,7 @@ export class ValidateCommand { } as const; if (opts.json) { - const out = { items: [] as BulkItemResult[], summary, version: '1.0' }; + const out = { items: [] as BulkItemResult[], summary, version: '1.0', root: toRootOutput(root) }; console.log(JSON.stringify(out, null, 2)); } else { console.log('No items found to validate.'); @@ -281,7 +299,7 @@ export class ValidateCommand { } as const; if (opts.json) { - const out = { items: results, summary, version: '1.0' }; + const out = { items: results, summary, version: '1.0', root: toRootOutput(root) }; console.log(JSON.stringify(out, null, 2)); } else { for (const res of results) { diff --git a/src/commands/workflow/index.ts b/src/commands/workflow/index.ts index 67b413a69..232b2dbe3 100644 --- a/src/commands/workflow/index.ts +++ b/src/commands/workflow/index.ts @@ -19,7 +19,4 @@ export type { SchemasOptions } from './schemas.js'; export { newChangeCommand } from './new-change.js'; export type { NewChangeOptions } from './new-change.js'; -export { setChangeCommand } from './set-change.js'; -export type { SetChangeOptions } from './set-change.js'; - export { DEFAULT_SCHEMA } from './shared.js'; diff --git a/src/commands/workflow/initiative-link.ts b/src/commands/workflow/initiative-link.ts deleted file mode 100644 index 56fd5d685..000000000 --- a/src/commands/workflow/initiative-link.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { PlanningHome } from '../../core/planning-home.js'; -import { - InitiativeResolutionError, - type InitiativeLinkReference, -} from '../../core/collections/initiatives/index.js'; - -export interface ChangeCommandStatus { - severity: 'error' | 'warning'; - code: string; - message: string; - target?: string; - fix?: string; - details?: unknown; -} - -export interface InitiativeSelectorOptions { - initiative?: string; - store?: string; - storePath?: string; -} - -export const REPO_LOCAL_INITIATIVE_LINK_ERROR = - 'Initiative links are supported only for repo-local changes. Run this command from the repo that owns the implementation plan.'; - -export function printJson(payload: unknown): void { - console.log(JSON.stringify(payload, null, 2)); -} - -export function statusFromError( - error: unknown -): ChangeCommandStatus { - if (error instanceof InitiativeResolutionError) { - return { - severity: 'error', - code: error.code, - message: error.message, - ...(error.target ? { target: error.target } : {}), - ...(error.fix ? { fix: error.fix } : {}), - ...(error.details ? { details: error.details } : {}), - }; - } - - return { - severity: 'error', - code: 'change_error', - message: error instanceof Error ? error.message : String(error), - }; -} - -export function assertInitiativeSelectorsHaveReference(options: InitiativeSelectorOptions): void { - if (!options.initiative && (options.store !== undefined || options.storePath !== undefined)) { - throw new Error('Pass --initiative when using --store or --store-path.'); - } - - if (options.initiative !== undefined && options.initiative.trim().length === 0) { - throw new Error('Pass --initiative to link a change to an initiative.'); - } -} - -export function assertInitiativeReference(value: string | undefined): asserts value is string { - if (value === undefined || value.trim().length === 0) { - throw new Error('Pass --initiative to set a change initiative link.'); - } -} - -export function assertRepoLocalInitiativeLinkPlanningHome(planningHome: PlanningHome): void { - if (planningHome.kind === 'workspace') { - throw new Error(REPO_LOCAL_INITIATIVE_LINK_ERROR); - } -} - -export function formatInitiativeLink(initiative: InitiativeLinkReference): string { - return `${initiative.store}/${initiative.id}`; -} - -export function sameInitiativeLink( - left: InitiativeLinkReference | undefined, - right: InitiativeLinkReference -): boolean { - return left?.store === right.store && left.id === right.id; -} diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 71f6918a2..cd9917d92 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -15,7 +15,17 @@ import { resolveArtifactOutputs, type ArtifactInstructions, } from '../../core/artifact-graph/index.js'; -import { getChangeDir, resolveCurrentPlanningHomeSync } from '../../core/planning-home.js'; +import { + getChangeDir, + resolveCurrentPlanningHomeSync, + type PlanningHome, +} from '../../core/planning-home.js'; +import { + emitStoreRootBanner, + resolveRootForCommand, + toPlanningHome, + toRootOutput, +} from '../../core/root-selection.js'; import { validateChangeExists, validateSchemaExists, @@ -30,12 +40,16 @@ import { export interface InstructionsOptions { change?: string; schema?: string; + store?: string; + storePath?: string; json?: boolean; } export interface ApplyInstructionsOptions { change?: string; schema?: string; + store?: string; + storePath?: string; json?: boolean; } @@ -50,12 +64,17 @@ export async function instructionsCommand( const spinner = options.json ? undefined : ora('Generating instructions...').start(); try { - const planningHome = resolveCurrentPlanningHomeSync(); - const projectRoot = planningHome.root; + const root = await resolveRootForCommand(options, { json: options.json }); + if (!root) { + return; + } + + const planningHome = toPlanningHome(root); + const projectRoot = root.path; const changeName = await validateChangeExists( options.change, projectRoot, - planningHome.changesDir + root.changesDir ); // Validate schema if explicitly provided @@ -93,10 +112,11 @@ export async function instructionsCommand( spinner?.stop(); if (options.json) { - console.log(JSON.stringify(instructions, null, 2)); + console.log(JSON.stringify({ ...instructions, root: toRootOutput(root) }, null, 2)); return; } + emitStoreRootBanner(root); printInstructionsText(instructions, isBlocked); } catch (error) { spinner?.stop(); @@ -262,7 +282,7 @@ export async function generateApplyInstructions( projectRoot: string, changeName: string, schemaName?: string, - planningHome = resolveCurrentPlanningHomeSync({ startPath: projectRoot }) + planningHome: PlanningHome = resolveCurrentPlanningHomeSync({ startPath: projectRoot }) ): Promise { // loadChangeContext will auto-detect schema from metadata if not provided const context = loadChangeContext(projectRoot, changeName, schemaName, { @@ -363,12 +383,17 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions const spinner = options.json ? undefined : ora('Generating apply instructions...').start(); try { - const planningHome = resolveCurrentPlanningHomeSync(); - const projectRoot = planningHome.root; + const root = await resolveRootForCommand(options, { json: options.json }); + if (!root) { + return; + } + + const planningHome = toPlanningHome(root); + const projectRoot = root.path; const changeName = await validateChangeExists( options.change, projectRoot, - planningHome.changesDir + root.changesDir ); // Validate schema if explicitly provided @@ -387,10 +412,11 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions spinner?.stop(); if (options.json) { - console.log(JSON.stringify(instructions, null, 2)); + console.log(JSON.stringify({ ...instructions, root: toRootOutput(root) }, null, 2)); return; } + emitStoreRootBanner(root); printApplyInstructionsText(instructions); } catch (error) { spinner?.stop(); diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index b41555243..66a330599 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -1,29 +1,26 @@ /** * New Change Command * - * Creates a new change directory with optional description and schema. + * Creates a new change directory with optional description and schema in the + * resolved OpenSpec root. `--store ` selects a registered context store's + * root; initiative linking and workspace affected areas are no longer part of + * this command. */ import ora from 'ora'; import path from 'path'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; +import { formatChangeLocation } from '../../core/planning-home.js'; import { - formatChangeLocation, - resolveCurrentPlanningHomeSync, - type PlanningHome, -} from '../../core/planning-home.js'; -import { validateSchemaExists } from './shared.js'; -import { - resolveInitiativeLinkReference, - type InitiativeLinkReference, -} from '../../core/collections/initiatives/index.js'; -import { - assertInitiativeSelectorsHaveReference, - assertRepoLocalInitiativeLinkPlanningHome, - formatInitiativeLink, - printJson, - statusFromError, -} from './initiative-link.js'; + emitStoreRootBanner, + resolveRootForCommand, + RootSelectionError, + toPlanningHome, + toRootOutput, + type ResolvedOpenSpecRoot, + type RootOutput, +} from '../../core/root-selection.js'; +import { printJson, statusFromError, validateSchemaExists } from './shared.js'; // ----------------------------------------------------------------------------- // Types @@ -32,11 +29,11 @@ import { export interface NewChangeOptions { description?: string; goal?: string; - areas?: string; schema?: string; - initiative?: string; store?: string; storePath?: string; + initiative?: string; + areas?: string; json?: boolean; } @@ -47,71 +44,41 @@ interface NewChangeOutput { metadataPath: string; schema: string; }; - initiative?: InitiativeLinkReference; + root: RootOutput; } // ----------------------------------------------------------------------------- // Command Implementation // ----------------------------------------------------------------------------- -function parseAffectedAreas(value: string | undefined): string[] { - return (value ?? '') - .split(',') - .map((area) => area.trim()) - .filter((area) => area.length > 0); -} - -function validateWorkspaceAffectedAreas(planningHome: PlanningHome, affectedAreas: string[]): void { - if (affectedAreas.length === 0) { - return; - } - - if (planningHome.kind !== 'workspace') { - throw new Error('--areas can only be used when creating a workspace-scoped change'); +function assertRemovedOptionsAbsent(options: NewChangeOptions): void { + if (options.initiative !== undefined) { + throw new RootSelectionError( + '--initiative is no longer supported. Normal changes no longer attach to initiatives; --store selects the OpenSpec root.', + 'initiative_option_removed', + { target: 'change.options' } + ); } - const validAreas = new Set(planningHome.workspace?.links ?? []); - const invalidAreas = affectedAreas.filter((area) => !validAreas.has(area)); - - if (invalidAreas.length > 0) { - const validList = [...validAreas].sort((a, b) => a.localeCompare(b)); - const validMessage = validList.length > 0 ? validList.join(', ') : '(no registered links)'; - throw new Error( - `Invalid affected area${invalidAreas.length === 1 ? '' : 's'}: ${invalidAreas.join(', ')}. ` + - `Valid workspace link names: ${validMessage}` + if (options.areas !== undefined) { + throw new RootSelectionError( + '--areas is no longer supported. Workspace affected areas are not part of the normal OpenSpec root path.', + 'areas_option_removed', + { target: 'change.options' } ); } } -function outputForCreatedChange( - id: string, - changeDir: string, - schema: string, - initiative: InitiativeLinkReference | undefined -): NewChangeOutput { - return { - change: { - id, - path: changeDir, - metadataPath: path.join(changeDir, '.openspec.yaml'), - schema, - }, - ...(initiative ? { initiative } : {}), - }; -} - -function printCreatedChangeHuman(payload: NewChangeOutput, planningHome: PlanningHome): void { - if (!payload.change) { - return; - } - - const location = formatChangeLocation(planningHome, payload.change.id); - const scope = planningHome.kind === 'workspace' ? 'workspace change' : 'change'; - console.log(`Created ${scope} '${payload.change.id}' at ${location}/`); +function printCreatedChangeHuman( + payload: NewChangeOutput, + root: ResolvedOpenSpecRoot +): void { + const location = + root.source === 'store' + ? payload.change.path + : formatChangeLocation(toPlanningHome(root), payload.change.id); + console.log(`Created change '${payload.change.id}' at ${location}/`); console.log(`Schema: ${payload.change.schema}`); - if (payload.initiative) { - console.log(`Initiative: ${formatInitiativeLink(payload.initiative)}`); - } } export async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise { @@ -127,44 +94,34 @@ export async function newChangeCommand(name: string | undefined, options: NewCha throw new Error(validation.error); } - assertInitiativeSelectorsHaveReference(options); - - const planningHome = resolveCurrentPlanningHomeSync(); - const projectRoot = planningHome.root; - const affectedAreas = parseAffectedAreas(options.areas); - validateWorkspaceAffectedAreas(planningHome, affectedAreas); - - let initiative: InitiativeLinkReference | undefined; - if (options.initiative !== undefined) { - assertRepoLocalInitiativeLinkPlanningHome(planningHome); + assertRemovedOptionsAbsent(options); - initiative = await resolveInitiativeLinkReference(options.initiative, { - store: options.store, - storePath: options.storePath, - }); + const root = await resolveRootForCommand(options, { + json: options.json, + failurePayload: { change: null }, + }); + if (!root) { + return; } + const projectRoot = root.path; + // Validate schema if provided if (options.schema) { validateSchemaExists(options.schema, projectRoot); } - const resolvedSchema = options.schema ?? planningHome.defaultSchema; + const resolvedSchema = options.schema ?? root.defaultSchema; if (spinner) { spinner.start(`Creating change '${name}' with schema '${resolvedSchema}'...`); } - const workspaceGoal = planningHome.kind === 'workspace' - ? options.goal ?? options.description - : options.goal; const result = await createChange(projectRoot, name, { schema: options.schema, - defaultSchema: planningHome.defaultSchema, - changesDir: planningHome.changesDir, + defaultSchema: root.defaultSchema, + changesDir: root.changesDir, metadata: { - ...(workspaceGoal ? { goal: workspaceGoal } : {}), - ...(affectedAreas.length > 0 ? { affected_areas: affectedAreas } : {}), - ...(initiative ? { initiative } : {}), + ...(options.goal ? { goal: options.goal } : {}), }, }); @@ -175,7 +132,15 @@ export async function newChangeCommand(name: string | undefined, options: NewCha await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8'); } - const payload = outputForCreatedChange(name, result.changeDir, result.schema, initiative); + const payload: NewChangeOutput = { + change: { + id: name, + path: result.changeDir, + metadataPath: path.join(result.changeDir, '.openspec.yaml'), + schema: result.schema, + }, + root: toRootOutput(root), + }; if (options.json) { printJson(payload); @@ -183,16 +148,8 @@ export async function newChangeCommand(name: string | undefined, options: NewCha } spinner?.stop(); - printCreatedChangeHuman(payload, planningHome); - - if (planningHome.kind === 'workspace' && !initiative) { - if (affectedAreas.length > 0) { - console.log(`Affected areas: ${affectedAreas.join(', ')}`); - } else { - console.log('Affected areas: unresolved; identify them in change metadata or coordination tasks as planning continues.'); - } - console.log('Next: run openspec status --change "' + name + '" to inspect workspace planning artifacts.'); - } + emitStoreRootBanner(root); + printCreatedChangeHuman(payload, root); } catch (error) { spinner?.stop(); if (options.json) { diff --git a/src/commands/workflow/set-change.ts b/src/commands/workflow/set-change.ts deleted file mode 100644 index edf97bfc4..000000000 --- a/src/commands/workflow/set-change.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Set Change Command - * - * Mutates checked-in repo-local change metadata. - */ - -import path from 'node:path'; -import { - getChangeDir, - resolveCurrentPlanningHomeSync, -} from '../../core/planning-home.js'; -import { - readChangeMetadata, - resolveSchemaForChange, - writeChangeMetadata, -} from '../../utils/change-metadata.js'; -import { validateChangeExists } from './shared.js'; -import { - resolveInitiativeLinkReference, - type InitiativeLinkReference, -} from '../../core/collections/initiatives/index.js'; -import { - assertInitiativeReference, - assertRepoLocalInitiativeLinkPlanningHome, - formatInitiativeLink, - printJson, - sameInitiativeLink, - statusFromError, -} from './initiative-link.js'; - -export interface SetChangeOptions { - initiative?: string; - store?: string; - storePath?: string; - json?: boolean; -} - -interface SetChangeOutput { - change: { - id: string; - path: string; - metadataPath: string; - schema: string; - }; - initiative?: InitiativeLinkReference; - updated?: boolean; -} - -function outputForSetChange( - id: string, - changeDir: string, - schema: string, - initiative: InitiativeLinkReference, - updated: boolean -): SetChangeOutput { - return { - change: { - id, - path: changeDir, - metadataPath: path.join(changeDir, '.openspec.yaml'), - schema, - }, - initiative, - updated, - }; -} - -function printSetChangeHuman(payload: SetChangeOutput): void { - if (!payload.change || !payload.initiative) { - return; - } - - const verb = payload.updated ? 'Linked' : 'Change already linked'; - console.log(`${verb}: ${payload.change.id}`); - console.log(`Initiative: ${formatInitiativeLink(payload.initiative)}`); - console.log(`Metadata: ${payload.change.metadataPath}`); -} - -export async function setChangeCommand( - name: string | undefined, - options: SetChangeOptions -): Promise { - try { - if (!name) { - throw new Error('Missing required argument '); - } - - assertInitiativeReference(options.initiative); - - const planningHome = resolveCurrentPlanningHomeSync(); - assertRepoLocalInitiativeLinkPlanningHome(planningHome); - - const projectRoot = planningHome.root; - const changeName = await validateChangeExists(name, projectRoot, planningHome.changesDir); - const changeDir = getChangeDir(planningHome, changeName); - - const initiative = await resolveInitiativeLinkReference(options.initiative, { - store: options.store, - storePath: options.storePath, - }); - - const existingMetadata = readChangeMetadata(changeDir, projectRoot); - const metadata = existingMetadata ?? { - schema: resolveSchemaForChange(changeDir, undefined, projectRoot, { metadata: null }), - }; - - if (sameInitiativeLink(metadata.initiative, initiative)) { - const payload = outputForSetChange(changeName, changeDir, metadata.schema, initiative, false); - if (options.json) { - printJson(payload); - return; - } - - printSetChangeHuman(payload); - return; - } - - if (metadata.initiative) { - throw new Error( - `Change '${changeName}' is already linked to initiative ${formatInitiativeLink(metadata.initiative)}.` - ); - } - - writeChangeMetadata(changeDir, { - ...metadata, - initiative, - }, projectRoot); - - const payload = outputForSetChange(changeName, changeDir, metadata.schema, initiative, true); - if (options.json) { - printJson(payload); - return; - } - - printSetChangeHuman(payload); - } catch (error) { - if (options.json) { - printJson({ - change: null, - status: [statusFromError(error)], - }); - process.exitCode = 1; - return; - } - - throw error; - } -} diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index b7d2a995c..193729263 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -10,12 +10,21 @@ import path from 'path'; import * as fs from 'fs'; import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js'; import type { InitiativeLink } from '../../core/change-metadata/index.js'; +import { isRootSelectionError } from '../../core/root-selection.js'; import { validateChangeName } from '../../utils/change-utils.js'; // ----------------------------------------------------------------------------- // Types // ----------------------------------------------------------------------------- +export interface ChangeCommandStatus { + severity: 'error' | 'warning'; + code: string; + message: string; + target?: string; + fix?: string; +} + export interface TaskItem { id: string; description: string; @@ -49,6 +58,22 @@ export const DEFAULT_SCHEMA = 'spec-driven'; // Utility Functions // ----------------------------------------------------------------------------- +export function printJson(payload: unknown): void { + console.log(JSON.stringify(payload, null, 2)); +} + +export function statusFromError(error: unknown): ChangeCommandStatus { + if (isRootSelectionError(error)) { + return { ...error.diagnostic }; + } + + return { + severity: 'error', + code: 'change_error', + message: error instanceof Error ? error.message : String(error), + }; +} + /** * Checks if color output is disabled via NO_COLOR env or --no-color flag. */ diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 7e21bd1b2..29ef90cfc 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -6,7 +6,13 @@ import ora from 'ora'; import chalk from 'chalk'; -import { resolveCurrentPlanningHomeSync, getChangeDir } from '../../core/planning-home.js'; +import { getChangeDir } from '../../core/planning-home.js'; +import { + emitStoreRootBanner, + resolveRootForCommand, + toPlanningHome, + toRootOutput, +} from '../../core/root-selection.js'; import { loadChangeContext, formatChangeStatus, @@ -27,6 +33,8 @@ import { export interface StatusOptions { change?: string; schema?: string; + store?: string; + storePath?: string; json?: boolean; } @@ -38,19 +46,32 @@ export async function statusCommand(options: StatusOptions): Promise { const spinner = options.json ? undefined : ora('Loading change status...').start(); try { - const planningHome = resolveCurrentPlanningHomeSync(); - const projectRoot = planningHome.root; + const root = await resolveRootForCommand(options, { json: options.json }); + if (!root) { + return; + } + + const planningHome = toPlanningHome(root); + const projectRoot = root.path; + const rootOutput = toRootOutput(root); // Handle no-changes case gracefully — status is informational, // so "no changes" is a valid state, not an error. if (!options.change) { - const available = await getAvailableChanges(projectRoot, planningHome.changesDir); + const available = await getAvailableChanges(projectRoot, root.changesDir); if (available.length === 0) { spinner?.stop(); if (options.json) { - console.log(JSON.stringify({ changes: [], message: 'No active changes.' }, null, 2)); + console.log( + JSON.stringify( + { changes: [], message: 'No active changes.', root: rootOutput }, + null, + 2 + ) + ); return; } + emitStoreRootBanner(root); console.log('No active changes. Create one with: openspec new change '); return; } @@ -64,7 +85,7 @@ export async function statusCommand(options: StatusOptions): Promise { const changeName = await validateChangeExists( options.change, projectRoot, - planningHome.changesDir + root.changesDir ); // Validate schema if explicitly provided @@ -82,10 +103,11 @@ export async function statusCommand(options: StatusOptions): Promise { spinner?.stop(); if (options.json) { - console.log(JSON.stringify(status, null, 2)); + console.log(JSON.stringify({ ...status, root: rootOutput }, null, 2)); return; } + emitStoreRootBanner(root); printStatusText(status); } catch (error) { spinner?.stop(); diff --git a/src/core/archive.ts b/src/core/archive.ts index 5af7181fc..0e67e24d0 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -3,6 +3,13 @@ import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { Validator } from './validation/validator.js'; import chalk from 'chalk'; +import { + emitStoreRootBanner, + isRootSelectionError, + resolveOpenSpecRoot, + toRootOutput, + type ResolvedOpenSpecRoot, +} from './root-selection.js'; import { findSpecUpdates, buildUpdatedSpec, @@ -10,6 +17,65 @@ import { type SpecUpdate, } from './specs-apply.js'; +export interface ArchiveOptions { + yes?: boolean; + skipSpecs?: boolean; + noValidate?: boolean; + validate?: boolean; + json?: boolean; + store?: string; + storePath?: string; +} + +interface ArchiveDiagnostic { + severity: 'error'; + code: string; + message: string; + fix?: string; +} + +interface ArchiveResult { + change: string; + archivedAs: string; + path: string; + specsUpdated: boolean; + totals?: { added: number; modified: number; removed: number; renamed: number }; +} + +/** + * JSON mode is non-interactive: any point where the human flow would prompt or + * print prose instead throws this error, which becomes a machine-readable + * status entry with a non-zero exit code. + */ +class ArchiveBlockedError extends Error { + readonly diagnostic: ArchiveDiagnostic; + + constructor(code: string, message: string, fix?: string) { + super(message); + this.name = 'ArchiveBlockedError'; + this.diagnostic = { + severity: 'error', + code, + message, + ...(fix ? { fix } : {}), + }; + } +} + +function toArchiveDiagnostic(error: unknown): ArchiveDiagnostic { + if (error instanceof ArchiveBlockedError) { + return error.diagnostic; + } + if (isRootSelectionError(error)) { + return error.diagnostic; + } + return { + severity: 'error', + code: 'archive_error', + message: error instanceof Error ? error.message : String(error), + }; +} + /** * Recursively copy a directory. Used when fs.rename fails (e.g. EPERM on Windows). */ @@ -48,14 +114,69 @@ async function moveDirectory(src: string, dest: string): Promise { } export class ArchiveCommand { - async execute( - changeName?: string, - options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean } = {} - ): Promise { - const targetPath = '.'; - const changesDir = path.join(targetPath, 'openspec', 'changes'); - const archiveDir = path.join(changesDir, 'archive'); - const mainSpecsDir = path.join(targetPath, 'openspec', 'specs'); + async execute(changeName?: string, options: ArchiveOptions = {}): Promise { + const json = !!options.json; + + let root: ResolvedOpenSpecRoot; + try { + root = await resolveOpenSpecRoot({ + ...(options.store !== undefined ? { store: options.store } : {}), + ...(options.storePath !== undefined ? { storePath: options.storePath } : {}), + }); + } catch (error) { + if (json && isRootSelectionError(error)) { + this.printJsonFailure(undefined, toArchiveDiagnostic(error)); + return; + } + throw error; + } + + if (json) { + try { + const result = await this.run(changeName, options, root, true); + if (!result) { + return; + } + console.log(JSON.stringify({ archive: result, root: toRootOutput(root) }, null, 2)); + } catch (error) { + this.printJsonFailure(root, toArchiveDiagnostic(error)); + } + return; + } + + emitStoreRootBanner(root); + await this.run(changeName, options, root, false); + } + + private printJsonFailure(root: ResolvedOpenSpecRoot | undefined, diagnostic: ArchiveDiagnostic): void { + console.log( + JSON.stringify( + { + archive: null, + ...(root ? { root: toRootOutput(root) } : {}), + status: [diagnostic], + }, + null, + 2 + ) + ); + process.exitCode = 1; + } + + /** + * Shared archive flow. In human mode (json=false) prompts and prose match + * the historical behavior and cancellations return null. In JSON mode no + * prose reaches stdout and every blocked path throws. + */ + private async run( + changeName: string | undefined, + options: ArchiveOptions, + root: ResolvedOpenSpecRoot, + json: boolean + ): Promise { + const changesDir = root.changesDir; + const archiveDir = root.archiveDir; + const mainSpecsDir = root.specsDir; // Check if changes directory exists try { @@ -66,10 +187,17 @@ export class ArchiveCommand { // Get change name interactively if not provided if (!changeName) { + if (json) { + throw new ArchiveBlockedError( + 'archive_change_name_required', + 'A change name is required: archive --json is non-interactive.', + 'openspec archive --json' + ); + } const selectedChange = await this.selectChange(changesDir); if (!selectedChange) { console.log('No change selected. Aborting.'); - return; + return null; } changeName = selectedChange; } @@ -83,7 +211,7 @@ export class ArchiveCommand { throw new Error(`Change '${changeName}' not found.`); } } catch { - throw new Error(`Change '${changeName}' not found.`); + throw new ArchiveBlockedError('archive_change_not_found', `Change '${changeName}' not found.`); } const skipValidation = options.validate === false || options.noValidate === true; @@ -93,21 +221,23 @@ export class ArchiveCommand { const validator = new Validator(); let hasValidationErrors = false; - // Validate proposal.md (non-blocking unless strict mode desired in future) - const changeFile = path.join(changeDir, 'proposal.md'); - try { - await fs.access(changeFile); - const changeReport = await validator.validateChange(changeFile); - // Proposal validation is informative only (do not block archive) - if (!changeReport.valid) { - console.log(chalk.yellow(`\nProposal warnings in proposal.md (non-blocking):`)); - for (const issue of changeReport.issues) { - const symbol = issue.level === 'ERROR' ? '⚠' : (issue.level === 'WARNING' ? '⚠' : 'ℹ'); - console.log(chalk.yellow(` ${symbol} ${issue.message}`)); + // Validate proposal.md (informative only; human mode prints warnings) + if (!json) { + const changeFile = path.join(changeDir, 'proposal.md'); + try { + await fs.access(changeFile); + const changeReport = await validator.validateChange(changeFile); + // Proposal validation is informative only (do not block archive) + if (!changeReport.valid) { + console.log(chalk.yellow(`\nProposal warnings in proposal.md (non-blocking):`)); + for (const issue of changeReport.issues) { + const symbol = issue.level === 'ERROR' ? '⚠' : (issue.level === 'WARNING' ? '⚠' : 'ℹ'); + console.log(chalk.yellow(` ${symbol} ${issue.message}`)); + } } + } catch { + // Change file doesn't exist, skip validation } - } catch { - // Change file doesn't exist, skip validation } // Validate delta-formatted spec files under the change directory if present @@ -133,26 +263,43 @@ export class ArchiveCommand { const deltaReport = await validator.validateChangeDeltaSpecs(changeDir); if (!deltaReport.valid) { hasValidationErrors = true; - console.log(chalk.red(`\nValidation errors in change delta specs:`)); - for (const issue of deltaReport.issues) { - if (issue.level === 'ERROR') { - console.log(chalk.red(` ✗ ${issue.message}`)); - } else if (issue.level === 'WARNING') { - console.log(chalk.yellow(` ⚠ ${issue.message}`)); + if (!json) { + console.log(chalk.red(`\nValidation errors in change delta specs:`)); + for (const issue of deltaReport.issues) { + if (issue.level === 'ERROR') { + console.log(chalk.red(` ✗ ${issue.message}`)); + } else if (issue.level === 'WARNING') { + console.log(chalk.yellow(` ⚠ ${issue.message}`)); + } } } } } if (hasValidationErrors) { + if (json) { + throw new ArchiveBlockedError( + 'archive_validation_failed', + `Validation failed for change '${changeName}'.`, + `Run openspec validate ${changeName} for details, fix the errors, or rerun with --no-validate.` + ); + } console.log(chalk.red('\nValidation failed. Please fix the errors before archiving.')); console.log(chalk.yellow('To skip validation (not recommended), use --no-validate flag.')); - return; + return null; + } + } else if (json) { + if (!options.yes) { + throw new ArchiveBlockedError( + 'archive_confirmation_required', + 'Skipping validation requires confirmation: rerun with --yes.', + 'openspec archive --json --no-validate --yes' + ); } } else { // Log warning when validation is skipped const timestamp = new Date().toISOString(); - + if (!options.yes) { const { confirm } = await import('@inquirer/prompts'); const proceed = await confirm({ @@ -161,24 +308,34 @@ export class ArchiveCommand { }); if (!proceed) { console.log('Archive cancelled.'); - return; + return null; } } else { console.log(chalk.yellow(`\n⚠️ WARNING: Skipping validation may archive invalid specs.`)); } - + console.log(chalk.yellow(`[${timestamp}] Validation skipped for change: ${changeName}`)); console.log(chalk.yellow(`Affected files: ${changeDir}`)); } // Show progress and check for incomplete tasks const progress = await getTaskProgressForChange(changesDir, changeName); - const status = formatTaskStatus(progress); - console.log(`Task status: ${status}`); + if (!json) { + const status = formatTaskStatus(progress); + console.log(`Task status: ${status}`); + } const incompleteTasks = Math.max(progress.total - progress.completed, 0); if (incompleteTasks > 0) { - if (!options.yes) { + if (json) { + if (!options.yes) { + throw new ArchiveBlockedError( + 'archive_tasks_incomplete', + `${incompleteTasks} incomplete task(s) found for change '${changeName}'.`, + 'Complete the tasks or rerun with --yes.' + ); + } + } else if (!options.yes) { const { confirm } = await import('@inquirer/prompts'); const proceed = await confirm({ message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`, @@ -186,7 +343,7 @@ export class ArchiveCommand { }); if (!proceed) { console.log('Archive cancelled.'); - return; + return null; } } else { console.log(`Warning: ${incompleteTasks} incomplete task(s) found. Continuing due to --yes flag.`); @@ -194,22 +351,35 @@ export class ArchiveCommand { } // Handle spec updates unless skipSpecs flag is set + let specsUpdated = false; + let totals: ArchiveResult['totals']; if (options.skipSpecs) { - console.log('Skipping spec updates (--skip-specs flag provided).'); + if (!json) { + console.log('Skipping spec updates (--skip-specs flag provided).'); + } } else { // Find specs to update const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir); - + if (specUpdates.length > 0) { - console.log('\nSpecs to update:'); - for (const update of specUpdates) { - const status = update.exists ? 'update' : 'create'; - const capability = path.basename(path.dirname(update.target)); - console.log(` ${capability}: ${status}`); + if (!json) { + console.log('\nSpecs to update:'); + for (const update of specUpdates) { + const status = update.exists ? 'update' : 'create'; + const capability = path.basename(path.dirname(update.target)); + console.log(` ${capability}: ${status}`); + } } let shouldUpdateSpecs = true; if (!options.yes) { + if (json) { + throw new ArchiveBlockedError( + 'archive_confirmation_required', + `Updating ${specUpdates.length} spec(s) requires confirmation: rerun with --yes.`, + 'openspec archive --json --yes' + ); + } const { confirm } = await import('@inquirer/prompts'); shouldUpdateSpecs = await confirm({ message: 'Proceed with spec updates?', @@ -229,37 +399,55 @@ export class ArchiveCommand { prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts }); } } catch (err: any) { + if (json) { + throw new ArchiveBlockedError( + 'archive_spec_update_failed', + String(err.message || err), + 'Fix the change delta specs and rerun. No files were changed.' + ); + } console.log(String(err.message || err)); console.log('Aborted. No files were changed.'); - return; + return null; } // All validations passed; pre-validate rebuilt full spec and then write files and display counts - let totals = { added: 0, modified: 0, removed: 0, renamed: 0 }; + const writeTotals = { added: 0, modified: 0, removed: 0, renamed: 0 }; for (const p of prepared) { const specName = path.basename(path.dirname(p.update.target)); if (!skipValidation) { const report = await new Validator().validateSpecContent(specName, p.rebuilt); if (!report.valid) { + if (json) { + throw new ArchiveBlockedError( + 'archive_spec_validation_failed', + `Rebuilt spec for '${specName}' failed validation. No files were changed.`, + `Run openspec validate ${specName} after fixing the change deltas.` + ); + } console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`)); for (const issue of report.issues) { if (issue.level === 'ERROR') console.log(chalk.red(` ✗ ${issue.message}`)); else if (issue.level === 'WARNING') console.log(chalk.yellow(` ⚠ ${issue.message}`)); } console.log('Aborted. No files were changed.'); - return; + return null; } } - await writeUpdatedSpec(p.update, p.rebuilt, p.counts); - totals.added += p.counts.added; - totals.modified += p.counts.modified; - totals.removed += p.counts.removed; - totals.renamed += p.counts.renamed; + await writeUpdatedSpec(p.update, p.rebuilt, p.counts, { silent: json }); + writeTotals.added += p.counts.added; + writeTotals.modified += p.counts.modified; + writeTotals.removed += p.counts.removed; + writeTotals.renamed += p.counts.renamed; + } + specsUpdated = true; + totals = writeTotals; + if (!json) { + console.log( + `Totals: + ${writeTotals.added}, ~ ${writeTotals.modified}, - ${writeTotals.removed}, → ${writeTotals.renamed}` + ); + console.log('Specs updated successfully.'); } - console.log( - `Totals: + ${totals.added}, ~ ${totals.modified}, - ${totals.removed}, → ${totals.renamed}` - ); - console.log('Specs updated successfully.'); } } } @@ -269,14 +457,18 @@ export class ArchiveCommand { const archivePath = path.join(archiveDir, archiveName); // Check if archive already exists + let archiveExists = false; try { await fs.access(archivePath); - throw new Error(`Archive '${archiveName}' already exists.`); + archiveExists = true; } catch (error: any) { if (error.code !== 'ENOENT') { throw error; } } + if (archiveExists) { + throw new ArchiveBlockedError('archive_target_exists', `Archive '${archiveName}' already exists.`); + } // Create archive directory if needed await fs.mkdir(archiveDir, { recursive: true }); @@ -284,7 +476,17 @@ export class ArchiveCommand { // Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows) await moveDirectory(changeDir, archivePath); - console.log(`Change '${changeName}' archived as '${archiveName}'.`); + if (!json) { + console.log(`Change '${changeName}' archived as '${archiveName}'.`); + } + + return { + change: changeName, + archivedAs: archiveName, + path: archivePath, + specsUpdated, + ...(totals ? { totals } : {}), + }; } private async selectChange(changesDir: string): Promise { diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index a15f28adf..64d57760a 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -57,6 +57,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ values: ['recent', 'name'], }, COMMON_FLAGS.json, + COMMON_FLAGS.store, ], }, { @@ -92,6 +93,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, COMMON_FLAGS.noInteractive, + COMMON_FLAGS.store, ], }, { @@ -126,6 +128,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Show specific requirement by ID (JSON only, spec-specific)', takesValue: true, }, + COMMON_FLAGS.store, ], }, { @@ -148,6 +151,11 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'no-validate', description: 'Skip validation (not recommended)', }, + { + name: 'json', + description: 'Output as JSON (non-interactive)', + }, + COMMON_FLAGS.store, ], }, { @@ -165,6 +173,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, COMMON_FLAGS.json, + COMMON_FLAGS.store, ], }, { @@ -184,6 +193,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, COMMON_FLAGS.json, + COMMON_FLAGS.store, ], }, { @@ -223,27 +233,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, { name: 'goal', - description: 'Workspace product goal to store with the change', - takesValue: true, - }, - { - name: 'areas', - description: 'Comma-separated affected workspace link names', - takesValue: true, - }, - { - name: 'initiative', - description: 'Link the repo-local change to an initiative', - takesValue: true, - }, - { - name: 'store', - description: 'Context store id for --initiative', - takesValue: true, - }, - { - name: 'store-path', - description: 'Existing local context store root for --initiative', + description: 'Optional goal metadata to store with the change', takesValue: true, }, { @@ -252,38 +242,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, COMMON_FLAGS.json, - ], - }, - ], - }, - { - name: 'set', - description: 'Set checked-in OpenSpec metadata', - flags: [], - subcommands: [ - { - name: 'change', - description: 'Set repo-local change metadata', - acceptsPositional: true, - positionalType: 'change-id', - positionals: [{ name: 'name', type: 'change-id' }], - flags: [ - { - name: 'initiative', - description: 'Link the repo-local change to an initiative', - takesValue: true, - }, - { - name: 'store', - description: 'Context store id for --initiative', - takesValue: true, - }, - { - name: 'store-path', - description: 'Existing local context store root for --initiative', - takesValue: true, - }, - COMMON_FLAGS.json, + COMMON_FLAGS.store, ], }, ], diff --git a/src/core/completions/shared-flags.ts b/src/core/completions/shared-flags.ts index 1ff64b297..1c9922b6b 100644 --- a/src/core/completions/shared-flags.ts +++ b/src/core/completions/shared-flags.ts @@ -26,4 +26,9 @@ export const COMMON_FLAGS = { takesValue: true, values: ['change', 'spec'], } as FlagDefinition, + store: { + name: 'store', + description: 'Registered context store id to use as the OpenSpec root', + takesValue: true, + } as FlagDefinition, } as const; diff --git a/src/core/list.ts b/src/core/list.ts index 3f40829a6..7b22e4774 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -4,6 +4,7 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre import { readFileSync } from 'fs'; import { join } from 'path'; import { MarkdownParser } from './parsers/markdown-parser.js'; +import type { RootOutput } from './root-selection.js'; interface ChangeInfo { name: string; @@ -15,6 +16,7 @@ interface ChangeInfo { interface ListOptions { sort?: 'recent' | 'name'; json?: boolean; + root?: RootOutput; } /** @@ -76,7 +78,7 @@ function formatRelativeTime(date: Date): string { export class ListCommand { async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise { - const { sort = 'recent', json = false } = options; + const { sort = 'recent', json = false, root } = options; if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); @@ -96,7 +98,7 @@ export class ListCommand { if (changeDirs.length === 0) { if (json) { - console.log(JSON.stringify({ changes: [] })); + console.log(JSON.stringify({ changes: [], ...(root ? { root } : {}) })); } else { console.log('No active changes found.'); } @@ -134,7 +136,7 @@ export class ListCommand { lastModified: c.lastModified.toISOString(), status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress' })); - console.log(JSON.stringify({ changes: jsonOutput }, null, 2)); + console.log(JSON.stringify({ changes: jsonOutput, ...(root ? { root } : {}) }, null, 2)); return; } @@ -156,14 +158,22 @@ export class ListCommand { try { await fs.access(specsDir); } catch { - console.log('No specs found.'); + if (json) { + console.log(JSON.stringify({ specs: [], ...(root ? { root } : {}) }, null, 2)); + } else { + console.log('No specs found.'); + } return; } const entries = await fs.readdir(specsDir, { withFileTypes: true }); const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name); if (specDirs.length === 0) { - console.log('No specs found.'); + if (json) { + console.log(JSON.stringify({ specs: [], ...(root ? { root } : {}) }, null, 2)); + } else { + console.log('No specs found.'); + } return; } @@ -183,6 +193,12 @@ export class ListCommand { } specs.sort((a, b) => a.id.localeCompare(b.id)); + + if (json) { + console.log(JSON.stringify({ specs, ...(root ? { root } : {}) }, null, 2)); + return; + } + console.log('Specs:'); const padding = ' '; const nameWidth = Math.max(...specs.map(s => s.id.length)); diff --git a/src/core/root-selection.ts b/src/core/root-selection.ts new file mode 100644 index 000000000..9e2921a0b --- /dev/null +++ b/src/core/root-selection.ts @@ -0,0 +1,342 @@ +/** + * Shared OpenSpec root resolution for normal commands. + * + * Normal commands (`new change`, `status`, `instructions`, `list`, `show`, + * `validate`, `archive`) resolve one OpenSpec root through this module: + * + * - `--store ` selects a registered context store's root. + * - Without `--store`, the nearest ancestor containing `openspec/` wins. + * Leftover workspace view state is never considered a root here. + * - With no nearest root, registered stores produce a selection hint error; + * otherwise commands may treat the current directory as an implicit root. + * + * Diagnostic codes reuse the context-store taxonomy where an error passes + * through unchanged (`invalid_context_store_id`, metadata parse failures); + * resolver-specific failures use the normal-command codes below + * (`unknown_store`, `no_registered_stores`, `store_identity_mismatch`, + * `unhealthy_store_root`, `store_path_not_supported`, + * `no_root_with_registered_stores`, `no_openspec_root`). + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { ContextStoreError } from './context-store/errors.js'; +import { + getContextStoreMetadataPath, + listContextStoreRegistryEntries, + readContextStoreRegistryState, + readOptionalContextStoreMetadataState, + validateContextStoreId, +} from './context-store/foundation.js'; +import { getStoreRootForBackend } from './context-store/registry.js'; +import { inspectOpenSpecRoot } from './openspec-root.js'; +import { findRepoPlanningRootSync, type PlanningHome } from './planning-home.js'; +import { FileSystemUtils } from '../utils/file-system.js'; + +export type OpenSpecRootSource = 'store' | 'nearest' | 'implicit'; + +export interface StoreSelectorOptions { + store?: string; + storePath?: string; +} + +export interface ResolveOpenSpecRootOptions extends StoreSelectorOptions { + startPath?: string; + allowImplicitRoot?: boolean; + globalDataDir?: string; +} + +export interface ResolvedOpenSpecRoot { + path: string; + changesDir: string; + specsDir: string; + archiveDir: string; + defaultSchema: 'spec-driven'; + source: OpenSpecRootSource; + storeId?: string; +} + +export interface RootSelectionDiagnostic { + severity: 'error'; + code: string; + message: string; + target?: string; + fix?: string; +} + +export class RootSelectionError extends Error { + readonly diagnostic: RootSelectionDiagnostic; + + constructor( + message: string, + code: string, + options: { target?: string; fix?: string } = {} + ) { + super(message); + this.name = 'RootSelectionError'; + this.diagnostic = { + severity: 'error', + code, + message, + ...options, + }; + } +} + +export function isRootSelectionError(error: unknown): error is RootSelectionError { + return error instanceof RootSelectionError; +} + +function fromContextStoreError(error: unknown): never { + if (error instanceof ContextStoreError) { + throw new RootSelectionError(error.message, error.diagnostic.code, { + ...(error.diagnostic.target ? { target: error.diagnostic.target } : {}), + ...(error.diagnostic.fix ? { fix: error.diagnostic.fix } : {}), + }); + } + + throw error; +} + +function doctorFix(id: string): string { + return `Run openspec context-store doctor ${id} to inspect it.`; +} + +function makeRoot( + rootPath: string, + source: OpenSpecRootSource, + storeId?: string +): ResolvedOpenSpecRoot { + return { + path: rootPath, + changesDir: path.join(rootPath, 'openspec', 'changes'), + specsDir: path.join(rootPath, 'openspec', 'specs'), + archiveDir: path.join(rootPath, 'openspec', 'changes', 'archive'), + defaultSchema: 'spec-driven', + source, + ...(storeId ? { storeId } : {}), + }; +} + +function canonicalDirectory(startPath: string): string { + const resolved = path.resolve(startPath); + + try { + const stats = fs.statSync(resolved); + const dir = stats.isDirectory() ? resolved : path.dirname(resolved); + return FileSystemUtils.canonicalizeExistingPath(dir); + } catch { + return resolved; + } +} + +async function resolveStoreRoot( + id: string, + globalDataDir?: string +): Promise { + try { + validateContextStoreId(id); + } catch (error) { + fromContextStoreError(error); + } + + const registry = await readContextStoreRegistryState( + globalDataDir ? { globalDataDir } : {} + ); + const entries = registry ? listContextStoreRegistryEntries(registry) : []; + const entry = entries.find((candidate) => candidate.id === id); + + if (!entry) { + if (entries.length === 0) { + throw new RootSelectionError( + `Unknown context store '${id}'. No context stores are registered.`, + 'no_registered_stores', + { + target: 'context_store.id', + fix: `Run openspec context-store setup ${id} or openspec context-store register first.`, + } + ); + } + + throw new RootSelectionError( + `Unknown context store '${id}'. Registered stores: ${entries + .map((candidate) => candidate.id) + .join(', ')}.`, + 'unknown_store', + { + target: 'context_store.id', + fix: 'Pass a registered store id, or run openspec context-store list.', + } + ); + } + + const storeRoot = getStoreRootForBackend(entry.backend); + + // Identity (metadata) failures win before root-health diagnostics. + let metadata; + try { + metadata = await readOptionalContextStoreMetadataState(storeRoot); + } catch (error) { + fromContextStoreError(error); + } + + if (!metadata) { + // The doctor pointer lives in the message because human-mode command + // wrappers print only the message, not the fix field. + throw new RootSelectionError( + `Context store '${id}' is missing identity metadata at ${getContextStoreMetadataPath(storeRoot)}. ${doctorFix(id)}`, + 'store_identity_mismatch', + { target: 'context_store.metadata', fix: doctorFix(id) } + ); + } + + if (metadata.id !== id) { + throw new RootSelectionError( + `Context store '${id}' metadata id '${metadata.id}' does not match its registered id. ${doctorFix(id)}`, + 'store_identity_mismatch', + { target: 'context_store.metadata', fix: doctorFix(id) } + ); + } + + const inspection = await inspectOpenSpecRoot(storeRoot); + if (!inspection.healthy) { + const problems = + inspection.diagnostics.map((diagnostic) => diagnostic.message).join(' ') || + 'OpenSpec root is missing or incomplete.'; + throw new RootSelectionError( + `Context store '${id}' does not have a healthy OpenSpec root at ${storeRoot}: ${problems} ${doctorFix(id)}`, + 'unhealthy_store_root', + { target: 'openspec.root', fix: doctorFix(id) } + ); + } + + return makeRoot(FileSystemUtils.canonicalizeExistingPath(storeRoot), 'store', id); +} + +export async function resolveOpenSpecRoot( + options: ResolveOpenSpecRootOptions = {} +): Promise { + if (options.storePath !== undefined) { + throw new RootSelectionError( + '--store-path is not supported. Register the path with openspec context-store register , then select it with --store .', + 'store_path_not_supported', + { + target: 'context_store.id', + fix: 'openspec context-store register , then rerun with --store .', + } + ); + } + + if (options.store !== undefined) { + return resolveStoreRoot(options.store, options.globalDataDir); + } + + const startPath = options.startPath ?? process.cwd(); + const nearestRoot = findRepoPlanningRootSync(startPath); + if (nearestRoot) { + return makeRoot(nearestRoot, 'nearest'); + } + + const registry = await readContextStoreRegistryState( + options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} + ); + const registeredIds = registry + ? listContextStoreRegistryEntries(registry).map((entry) => entry.id) + : []; + + if (registeredIds.length > 0) { + throw new RootSelectionError( + `No OpenSpec root found in the current directory or its ancestors. Registered context stores: ${registeredIds.join(', ')}. Pass --store to use one, or run openspec init to create a local root.`, + 'no_root_with_registered_stores', + { + target: 'openspec.root', + fix: `Rerun with --store (registered: ${registeredIds.join(', ')}) or run openspec init.`, + } + ); + } + + if (options.allowImplicitRoot === false) { + throw new RootSelectionError( + 'No OpenSpec root found from the current directory.', + 'no_openspec_root', + { target: 'openspec.root', fix: 'Run openspec init to create a root here.' } + ); + } + + return makeRoot(canonicalDirectory(startPath), 'implicit'); +} + +// ----------------------------------------------------------------------------- +// Output helpers +// ----------------------------------------------------------------------------- + +export interface RootOutput { + path: string; + source: OpenSpecRootSource; + store_id?: string; +} + +export function toRootOutput(root: ResolvedOpenSpecRoot): RootOutput { + return { + path: root.path, + source: root.source, + ...(root.storeId ? { store_id: root.storeId } : {}), + }; +} + +/** + * Human-mode verification signal for a selected store. Written to stderr so + * raw-Markdown and agent-consumed stdout payloads stay clean. + */ +export function emitStoreRootBanner(root: ResolvedOpenSpecRoot): void { + if (root.source === 'store' && root.storeId) { + console.error(`Using OpenSpec root: ${root.storeId} (${root.path})`); + } +} + +/** + * Compatibility bridge for workflow code that still expects a PlanningHome. + * Normal commands never produce `kind: 'workspace'`. + */ +export function toPlanningHome(root: ResolvedOpenSpecRoot): PlanningHome { + return { + kind: 'repo', + root: root.path, + changesDir: root.changesDir, + defaultSchema: root.defaultSchema, + }; +} + +/** + * CLI adapter shared by the supported commands. In JSON mode a resolution + * failure is reported as a machine-readable payload on stdout (no human prose + * or blank lines) with a non-zero exit code; the caller must return when this + * resolves to null. In human mode the error propagates to the command's + * standard error handling so message text and exit behavior stay consistent. + */ +export async function resolveRootForCommand( + selector: StoreSelectorOptions, + output: { json?: boolean; failurePayload?: Record } = {} +): Promise { + try { + return await resolveOpenSpecRoot({ + ...(selector.store !== undefined ? { store: selector.store } : {}), + ...(selector.storePath !== undefined ? { storePath: selector.storePath } : {}), + }); + } catch (error) { + if (output.json && isRootSelectionError(error)) { + console.log( + JSON.stringify( + { ...(output.failurePayload ?? {}), status: [error.diagnostic] }, + null, + 2 + ) + ); + process.exitCode = 1; + return null; + } + + throw error; + } +} diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index 88142ec00..e8a3472b1 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -353,13 +353,16 @@ export async function buildUpdatedSpec( export async function writeUpdatedSpec( update: SpecUpdate, rebuilt: string, - counts: { added: number; modified: number; removed: number; renamed: number } + counts: { added: number; modified: number; removed: number; renamed: number }, + options: { silent?: boolean } = {} ): Promise { // Create target directory if needed const targetDir = path.dirname(update.target); await fs.mkdir(targetDir, { recursive: true }); await fs.writeFile(update.target, rebuilt); + if (options.silent) return; + const specName = path.basename(path.dirname(update.target)); console.log(`Applying changes to openspec/specs/${specName}/spec.md:`); if (counts.added) console.log(` + ${counts.added} added`); diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 14ed07866..a286422a8 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -342,139 +342,46 @@ describe('artifact-workflow CLI commands', () => { expect(stat.isDirectory()).toBe(true); }); - it('creates workspace-planning changes under the workspace root without touching linked repos', async () => { - const workspaceEnv = { - XDG_DATA_HOME: path.join(tempDir, 'data'), - XDG_CONFIG_HOME: path.join(tempDir, 'config'), - OPEN_SPEC_INTERACTIVE: '0', - OPENSPEC_TELEMETRY: '0', - }; - const api = path.join(tempDir, 'linked-api'); - await fs.mkdir(path.join(api, 'openspec', 'specs'), { recursive: true }); - const apiEntriesBefore = (await fs.readdir(api)).sort(); - - const setup = await runCLI( - [ - 'workspace', - 'setup', - '--no-interactive', - '--json', - '--name', - 'platform', - '--link', - `api=${api}`, - ], - { cwd: tempDir, env: workspaceEnv } - ); - expect(setup.exitCode).toBe(0); - const workspaceRoot = JSON.parse(setup.stdout).workspace.root; - - const create = await runCLI( - [ - 'new', - 'change', - 'cross-repo-login', - '--goal', - 'Unify login across API and web', - '--areas', - 'api', - ], - { cwd: workspaceRoot, env: workspaceEnv } + it('rejects --initiative and writes no change', async () => { + const result = await runCLI( + ['new', 'change', 'linked-change', '--initiative', 'billing-launch'], + { cwd: tempDir } ); - expect(create.exitCode).toBe(0); - const createOutput = getOutput(create); - expect(createOutput).toContain('workspace change'); - expect(normalizePaths(createOutput)).toContain('changes/cross-repo-login'); - - const changeDir = path.join(workspaceRoot, 'changes', 'cross-repo-login'); - const metadata = await fs.readFile(path.join(changeDir, '.openspec.yaml'), 'utf-8'); - expect(metadata).toContain('schema: workspace-planning'); - expect(metadata).toContain('goal: Unify login across API and web'); - expect(metadata).toContain('affected_areas:'); - expect(metadata).toContain('- api'); - expect((await fs.readdir(api)).sort()).toEqual(apiEntriesBefore); - await expect(fs.stat(path.join(api, 'openspec', 'changes'))).rejects.toMatchObject({ + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain('--initiative is no longer supported'); + await expect(fs.stat(path.join(changesDir, 'linked-change'))).rejects.toMatchObject({ code: 'ENOENT', }); }); - it('resolves nested workspace-planning specs as workspace-scoped paths', async () => { - const workspaceEnv = { - XDG_DATA_HOME: path.join(tempDir, 'data'), - XDG_CONFIG_HOME: path.join(tempDir, 'config'), - OPEN_SPEC_INTERACTIVE: '0', - OPENSPEC_TELEMETRY: '0', - }; - const api = path.join(tempDir, 'linked-api'); - await fs.mkdir(api, { recursive: true }); - - const setup = await runCLI( - [ - 'workspace', - 'setup', - '--no-interactive', - '--json', - '--name', - 'platform', - '--link', - `api=${api}`, - ], - { cwd: tempDir, env: workspaceEnv } - ); - expect(setup.exitCode).toBe(0); - const workspaceRoot = JSON.parse(setup.stdout).workspace.root; - - const create = await runCLI( - ['new', 'change', 'nested-workspace-spec', '--goal', 'Plan API login', '--areas', 'api'], - { cwd: workspaceRoot, env: workspaceEnv } - ); - expect(create.exitCode).toBe(0); - - const changeDir = path.join(workspaceRoot, 'changes', 'nested-workspace-spec'); - const specPath = path.join(changeDir, 'specs', 'api', 'login', 'spec.md'); - await fs.mkdir(path.dirname(specPath), { recursive: true }); - await fs.writeFile( - specPath, - '## ADDED Requirements\n\n### Requirement: API login\n\n#### Scenario: Valid login\n- **WHEN** credentials are valid\n- **THEN** login succeeds\n' - ); - - const status = await runCLI(['status', '--change', 'nested-workspace-spec', '--json'], { - cwd: workspaceRoot, - env: workspaceEnv, + it('rejects --areas and writes no affected-area metadata', async () => { + const result = await runCLI(['new', 'change', 'area-change', '--areas', 'api'], { + cwd: tempDir, }); - expect(status.exitCode).toBe(0); - const statusJson = JSON.parse(status.stdout); - expect(statusJson.schemaName).toBe('workspace-planning'); - expect(statusJson.planningHome.kind).toBe('workspace'); - expect(statusJson.affectedAreas.known).toEqual(['api']); - expect(statusJson.actionContext).toEqual( - expect.objectContaining({ - mode: 'workspace-planning', - sourceOfTruth: 'workspace-local', - allowedEditRoots: [], - constraints: expect.arrayContaining([ - 'Treat workspace-local planning artifacts as compatibility context for this local view.', - 'Use initiatives for durable coordination when initiative context exists.', - 'Treat linked repos and folders as context until an explicit edit root is selected.', - ]), - }) - ); - expect(statusJson.actionContext.constraints).not.toContain( - 'Use workspace-level planning artifacts as the source of truth.' - ); - expect(statusJson.artifactPaths.specs.existingOutputPaths).toEqual([canonical(specPath)]); + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain('--areas is no longer supported'); + await expect(fs.stat(path.join(changesDir, 'area-change'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); - const instructions = await runCLI( - ['instructions', 'specs', '--change', 'nested-workspace-spec', '--json'], - { cwd: workspaceRoot, env: workspaceEnv } + it('keeps --goal as ordinary metadata without switching schema', async () => { + const result = await runCLI( + ['new', 'change', 'goal-change', '--goal', 'Improve billing'], + { cwd: tempDir } ); - expect(instructions.exitCode).toBe(0); - const instructionsJson = JSON.parse(instructions.stdout); - expect(instructionsJson.planningHome.kind).toBe('workspace'); - expect(normalizePaths(instructionsJson.resolvedOutputPath)).toContain( - 'changes/nested-workspace-spec/specs/**/*.md' + expect(result.exitCode).toBe(0); + + const metadata = await fs.readFile( + path.join(changesDir, 'goal-change', '.openspec.yaml'), + 'utf-8' ); - expect(instructionsJson.existingOutputPaths).toEqual([canonical(specPath)]); + expect(metadata).toContain('schema: spec-driven'); + expect(metadata).toContain('goal: Improve billing'); + expect(metadata).not.toContain('affected_areas'); + expect(metadata).not.toContain('initiative'); }); it('creates README.md when --description is provided', async () => { diff --git a/test/commands/change-initiative-link.test.ts b/test/commands/change-initiative-link.test.ts index 7ce62c237..0fa0fd244 100644 --- a/test/commands/change-initiative-link.test.ts +++ b/test/commands/change-initiative-link.test.ts @@ -3,34 +3,30 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - getGlobalDataDir, - registerContextStore, - writeContextStoreMetadataState, - writeContextStoreRegistryState, -} from '../../src/core/index.js'; import { readChangeMetadata } from '../../src/utils/change-metadata.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; -describe('repo-local change initiative links', () => { +/** + * Initiative-link creation was removed from normal change flows in the + * store-root-selection slice: `new change` no longer accepts `--initiative` + * and `openspec set change` is gone. Existing initiative metadata from the + * beta remains readable and untouched; this suite covers that legacy + * behavior. + */ +describe('legacy repo-local change initiative metadata', () => { let tempDir: string; - let dataHome: string; - let configHome: string; - let globalDataDir: string; let env: NodeJS.ProcessEnv; - beforeEach(async () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-change-initiative-link-')); - tempDir = fs.realpathSync.native(tempDir); - dataHome = path.join(tempDir, 'data'); - configHome = path.join(tempDir, 'config'); + beforeEach(() => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-change-initiative-link-')) + ); env = { - XDG_DATA_HOME: dataHome, - XDG_CONFIG_HOME: configHome, + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), OPEN_SPEC_INTERACTIVE: '0', OPENSPEC_TELEMETRY: '0', }; - globalDataDir = getGlobalDataDir({ env }); fs.mkdirSync(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); }); @@ -48,485 +44,87 @@ describe('repo-local change initiative links', () => { } } - function mkdir(relativePath: string): string { - const dir = path.join(tempDir, relativePath); - fs.mkdirSync(dir, { recursive: true }); - return dir; - } - - function canonicalPath(existingPath: string): string { - return fs.realpathSync.native(existingPath); - } - - function expectSameExistingPath(actualPath: string, expectedPath: string): void { - expect(canonicalPath(actualPath)).toBe(canonicalPath(expectedPath)); - } - - async function setupRegisteredStore(store = 'platform'): Promise { - const storeRoot = mkdir(`stores/${store}`); - await registerContextStore({ - id: store, - localPath: storeRoot, - globalDataDir, - }); - return storeRoot; - } - - async function setupUnregisteredStore(store = 'scratch-context'): Promise { - const storeRoot = mkdir(`stores/${store}`); - await writeContextStoreMetadataState(storeRoot, { - version: 1, - id: store, - }); - return storeRoot; - } - - async function createInitiative( - id = 'billing-launch', - selector: ['--store' | '--store-path', string] = ['--store', 'platform'] - ): Promise { - const result = await runCLI( - [ - 'initiative', - 'create', - id, - selector[0], - selector[1], - '--title', - id, - '--summary', - `Coordinate ${id}.`, - '--json', - ], - { cwd: tempDir, env } - ); - expect(result.exitCode).toBe(0); - } - function changeDir(id: string): string { return path.join(tempDir, 'openspec', 'changes', id); } - function metadataPath(id: string): string { - return path.join(changeDir(id), '.openspec.yaml'); - } - - function expectStoredLinkOnly(changeId: string, store: string, initiativeId: string, storeRoot: string): void { - const metadata = readChangeMetadata(changeDir(changeId), tempDir); - expect(metadata?.initiative).toEqual({ - store, - id: initiativeId, - }); - - const raw = fs.readFileSync(metadataPath(changeId), 'utf-8'); - expect(raw).toContain('initiative:'); - expect(raw).toContain(`store: ${store}`); - expect(raw).toContain(`id: ${initiativeId}`); - expect(raw).not.toContain(storeRoot); - expect(raw).not.toContain('store_path'); - expect(raw).not.toContain('metadata_path'); - expect(raw).not.toContain('summary:'); - } - - it('creates a repo-local change linked to a uniquely found initiative', async () => { - const storeRoot = await setupRegisteredStore('platform'); - await createInitiative('billing-launch'); - - const result = await runCLI( - ['new', 'change', 'add-billing-api', '--initiative', 'billing-launch', '--json'], - { cwd: tempDir, env } + function createLegacyLinkedChange(id: string): string { + const dir = changeDir(id); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'proposal.md'), + '## Why\nLegacy change.\n\n## What Changes\n- **billing:** Something\n' ); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(''); - const payload = parseJson(result); - expect(payload).toEqual({ - change: { - id: 'add-billing-api', - path: expect.any(String), - metadataPath: expect.any(String), - schema: 'spec-driven', - }, - initiative: { - store: 'platform', - id: 'billing-launch', - }, - }); - expectSameExistingPath(payload.change.path, changeDir('add-billing-api')); - expectSameExistingPath(payload.change.metadataPath, metadataPath('add-billing-api')); - expect(JSON.stringify(payload).toLowerCase()).not.toContain('next'); - expectStoredLinkOnly('add-billing-api', 'platform', 'billing-launch', storeRoot); - expect(fs.existsSync(path.join(storeRoot, 'initiatives', 'billing-launch', 'links.yaml'))).toBe(false); - }); - - it('prints factual human output for initiative-linked creation', async () => { - await setupRegisteredStore('platform'); - await createInitiative('billing-launch'); - - const result = await runCLI( - ['new', 'change', 'add-billing-ui', '--initiative', 'platform/billing-launch'], - { cwd: tempDir, env } + fs.writeFileSync( + path.join(dir, '.openspec.yaml'), + 'schema: spec-driven\ninitiative:\n store: platform\n id: billing-launch\n' ); + return dir; + } - expect(result.exitCode).toBe(0); - const output = result.stdout + result.stderr; - expect(output).toContain("Created change 'add-billing-ui'"); - expect(output).toContain('Schema: spec-driven'); - expect(output).toContain('Initiative: platform/billing-launch'); - expect(output).not.toContain('Next:'); - }); + it('keeps reading existing initiative metadata without modifying it', async () => { + const dir = createLegacyLinkedChange('legacy-change'); + const metadataPath = path.join(dir, '.openspec.yaml'); + const before = fs.readFileSync(metadataPath, 'utf-8'); - it('creates a linked change with an explicit context store selector', async () => { - const storeRoot = await setupRegisteredStore('platform'); - await createInitiative('billing-launch'); + const status = await runCLI(['status', '--change', 'legacy-change', '--json'], { + cwd: tempDir, + env, + }); + expect(status.exitCode).toBe(0); + const statusJson = parseJson(status); + expect(statusJson.initiative).toEqual({ store: 'platform', id: 'billing-launch' }); - const result = await runCLI( - ['new', 'change', 'store-selected-link', '--initiative', 'billing-launch', '--store', 'platform', '--json'], - { cwd: tempDir, env } - ); + const list = await runCLI(['list', '--json'], { cwd: tempDir, env }); + expect(list.exitCode).toBe(0); + expect(parseJson(list).changes.map((c: any) => c.name)).toContain('legacy-change'); - expect(result.exitCode).toBe(0); - expect(parseJson(result).initiative).toEqual({ + expect(fs.readFileSync(metadataPath, 'utf-8')).toBe(before); + expect(readChangeMetadata(changeDir('legacy-change'), tempDir)?.initiative).toEqual({ store: 'platform', id: 'billing-launch', }); - expectStoredLinkOnly('store-selected-link', 'platform', 'billing-launch', storeRoot); - }); - - it('rejects a blank create-time initiative selector without writing a change', async () => { - const result = await runCLI( - ['new', 'change', 'blank-linked-change', '--initiative', '', '--json'], - { cwd: tempDir, env } - ); - - expect(result.exitCode).toBe(1); - const payload = parseJson(result); - expect(payload.change).toBeNull(); - expect(payload.status[0].message).toContain('Pass --initiative '); - expect(fs.existsSync(changeDir('blank-linked-change'))).toBe(false); }); - it('creates a linked change with an explicit context store path selector', async () => { - const storeRoot = await setupUnregisteredStore('scratch-context'); - await createInitiative('scratch-launch', ['--store-path', storeRoot]); - - const result = await runCLI( - [ - 'new', - 'change', - 'path-selected-link', - '--initiative', - 'scratch-launch', - '--store-path', - storeRoot, - '--json', - ], - { cwd: tempDir, env } - ); - - expect(result.exitCode).toBe(0); - expect(parseJson(result).initiative).toEqual({ - store: 'scratch-context', - id: 'scratch-launch', - }); - expectStoredLinkOnly('path-selected-link', 'scratch-context', 'scratch-launch', storeRoot); - }); - - it('does not write a change when initiative lookup fails', async () => { - await setupRegisteredStore('platform'); - - const result = await runCLI( - ['new', 'change', 'missing-linked-change', '--initiative', 'missing-launch', '--json'], - { cwd: tempDir, env } - ); - - expect(result.exitCode).toBe(1); - const payload = parseJson(result); - expect(payload.change).toBeNull(); - expect(payload.status[0]).toEqual(expect.objectContaining({ code: 'initiative_not_found' })); - expect(payload.status[0].fix).toBe('openspec initiative list'); - expect(fs.existsSync(changeDir('missing-linked-change'))).toBe(false); - }); - - it('reuses initiative show ambiguity and incomplete lookup behavior before writing', async () => { - const platformRoot = await setupRegisteredStore('platform'); - await createInitiative('billing-launch', ['--store', 'platform']); - await setupRegisteredStore('finance'); - await createInitiative('billing-launch', ['--store', 'finance']); - - const ambiguous = await runCLI( - ['new', 'change', 'ambiguous-linked-change', '--initiative', 'billing-launch', '--json'], - { cwd: tempDir, env } - ); - expect(ambiguous.exitCode).toBe(1); - expect(parseJson(ambiguous).status[0]).toEqual( - expect.objectContaining({ code: 'initiative_ambiguous' }) - ); - expect(fs.existsSync(changeDir('ambiguous-linked-change'))).toBe(false); - - await writeContextStoreRegistryState( - { - version: 1, - stores: { - platform: { - backend: { - type: 'git', - local_path: platformRoot, - }, - }, - 'missing-context': { - backend: { - type: 'git', - local_path: path.join(tempDir, 'missing-context'), - }, - }, - }, - }, - { globalDataDir } - ); - - const incomplete = await runCLI( - ['new', 'change', 'incomplete-linked-change', '--initiative', 'billing-launch', '--json'], - { cwd: tempDir, env } - ); - expect(incomplete.exitCode).toBe(1); - expect(parseJson(incomplete).status[0]).toEqual( - expect.objectContaining({ code: 'initiative_lookup_incomplete' }) - ); - expect(fs.existsSync(changeDir('incomplete-linked-change'))).toBe(false); - }); - - it('does not write an existing change when set change initiative lookup fails', async () => { - const platformRoot = await setupRegisteredStore('platform'); - const create = await runCLI(['new', 'change', 'set-lookup-failure', '--json'], { + it('creates no initiative metadata for new changes', async () => { + const result = await runCLI(['new', 'change', 'fresh-change', '--json'], { cwd: tempDir, env, }); - expect(create.exitCode).toBe(0); - const before = fs.readFileSync(metadataPath('set-lookup-failure'), 'utf-8'); - - const missing = await runCLI( - ['set', 'change', 'set-lookup-failure', '--initiative', 'missing-launch', '--json'], - { cwd: tempDir, env } - ); - expect(missing.exitCode).toBe(1); - const missingPayload = parseJson(missing); - expect(missingPayload.status[0]).toEqual(expect.objectContaining({ code: 'initiative_not_found' })); - expect(missingPayload.status[0].fix).toBe('openspec initiative list'); - expect(fs.readFileSync(metadataPath('set-lookup-failure'), 'utf-8')).toBe(before); - - await createInitiative('billing-launch', ['--store', 'platform']); - await writeContextStoreRegistryState( - { - version: 1, - stores: { - platform: { - backend: { - type: 'git', - local_path: platformRoot, - }, - }, - 'missing-context': { - backend: { - type: 'git', - local_path: path.join(tempDir, 'missing-context'), - }, - }, - }, - }, - { globalDataDir } - ); - - const incomplete = await runCLI( - ['set', 'change', 'set-lookup-failure', '--initiative', 'billing-launch', '--json'], - { cwd: tempDir, env } - ); - expect(incomplete.exitCode).toBe(1); - expect(parseJson(incomplete).status[0]).toEqual( - expect.objectContaining({ code: 'initiative_lookup_incomplete' }) - ); - expect(fs.readFileSync(metadataPath('set-lookup-failure'), 'utf-8')).toBe(before); - - await writeContextStoreRegistryState( - { - version: 1, - stores: { - platform: { - backend: { - type: 'git', - local_path: platformRoot, - }, - }, - }, - }, - { globalDataDir } - ); - await setupRegisteredStore('finance'); - await createInitiative('billing-launch', ['--store', 'finance']); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.initiative).toBeUndefined(); - const ambiguous = await runCLI( - ['set', 'change', 'set-lookup-failure', '--initiative', 'billing-launch', '--json'], - { cwd: tempDir, env } - ); - expect(ambiguous.exitCode).toBe(1); - expect(parseJson(ambiguous).status[0]).toEqual( - expect.objectContaining({ code: 'initiative_ambiguous' }) - ); - expect(parseJson(ambiguous).status[0].fix).toBe( - 'openspec initiative show billing-launch --store ' - ); - expect(fs.readFileSync(metadataPath('set-lookup-failure'), 'utf-8')).toBe(before); + const metadata = readChangeMetadata(changeDir('fresh-change'), tempDir); + expect(metadata?.initiative).toBeUndefined(); }); - it('refuses initiative-linked creation from a workspace planning home', async () => { - await setupRegisteredStore('platform'); - await createInitiative('billing-launch'); - const api = mkdir('linked-api'); - - const setup = await runCLI( - ['workspace', 'setup', '--no-interactive', '--json', '--name', 'platform', '--link', `api=${api}`], - { cwd: tempDir, env } - ); - expect(setup.exitCode).toBe(0); - const workspaceRoot = parseJson(setup).workspace.root; - + it('rejects new change --initiative without writing files', async () => { const result = await runCLI( - ['new', 'change', 'workspace-linked-change', '--initiative', 'billing-launch', '--json'], - { cwd: workspaceRoot, env } - ); - - expect(result.exitCode).toBe(1); - const payload = parseJson(result); - expect(payload.status[0].message).toContain('repo-local changes'); - expect(fs.existsSync(path.join(workspaceRoot, 'changes', 'workspace-linked-change'))).toBe(false); - }); - - it('sets and surfaces initiative links without resolving the initiative during status or instructions', async () => { - const storeRoot = await setupUnregisteredStore('scratch-context'); - await createInitiative('scratch-launch', ['--store-path', storeRoot]); - const create = await runCLI(['new', 'change', 'recover-linked-change', '--json'], { - cwd: tempDir, - env, - }); - expect(create.exitCode).toBe(0); - - const set = await runCLI( - [ - 'set', - 'change', - 'recover-linked-change', - '--initiative', - 'scratch-launch', - '--store-path', - storeRoot, - '--json', - ], + ['new', 'change', 'linked-change', '--initiative', 'billing-launch', '--json'], { cwd: tempDir, env } ); - expect(set.exitCode).toBe(0); - expect(parseJson(set)).toEqual( - expect.objectContaining({ - initiative: { - store: 'scratch-context', - id: 'scratch-launch', - }, - updated: true, - }) - ); - expectStoredLinkOnly('recover-linked-change', 'scratch-context', 'scratch-launch', storeRoot); - expect(fs.existsSync(path.join(storeRoot, 'initiatives', 'scratch-launch', 'links.yaml'))).toBe(false); - - fs.rmSync(storeRoot, { recursive: true, force: true }); - - const status = await runCLI(['status', '--change', 'recover-linked-change', '--json'], { - cwd: tempDir, - env, - }); - expect(status.exitCode).toBe(0); - const statusPayload = parseJson(status); - expect(statusPayload.initiative).toEqual({ - store: 'scratch-context', - id: 'scratch-launch', - }); - expect(statusPayload.nextSteps).toEqual(expect.any(Array)); - expect(statusPayload.nextSteps.length).toBeGreaterThan(0); - - const humanStatus = await runCLI(['status', '--change', 'recover-linked-change'], { - cwd: tempDir, - env, - }); - expect(humanStatus.exitCode).toBe(0); - expect(humanStatus.stdout).toContain('Initiative: scratch-context/scratch-launch'); - - const instructions = await runCLI( - ['instructions', 'proposal', '--change', 'recover-linked-change'], - { cwd: tempDir, env } - ); - expect(instructions.exitCode).toBe(0); - expect(instructions.stdout).toContain(''); - - const applyInstructions = await runCLI( - ['instructions', 'apply', '--change', 'recover-linked-change', '--json'], - { cwd: tempDir, env } - ); - expect(applyInstructions.exitCode).toBe(0); - expect(parseJson(applyInstructions).initiative).toEqual({ - store: 'scratch-context', - id: 'scratch-launch', - }); + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.change).toBeNull(); + expect(json.status[0].code).toBe('initiative_option_removed'); + expect(fs.existsSync(changeDir('linked-change'))).toBe(false); }); - it('makes same-link set idempotent and rejects different-link conflicts without writing', async () => { - await setupRegisteredStore('platform'); - await createInitiative('billing-launch', ['--store', 'platform']); - await setupRegisteredStore('finance'); - await createInitiative('finance-launch', ['--store', 'finance']); + it('no longer provides openspec set change', async () => { + createLegacyLinkedChange('legacy-change'); - const create = await runCLI( - ['new', 'change', 'idempotent-link', '--initiative', 'platform/billing-launch', '--json'], - { cwd: tempDir, env } - ); - expect(create.exitCode).toBe(0); - const before = fs.readFileSync(metadataPath('idempotent-link'), 'utf-8'); - - const same = await runCLI( - ['set', 'change', 'idempotent-link', '--initiative', 'billing-launch', '--store', 'platform', '--json'], - { cwd: tempDir, env } - ); - expect(same.exitCode).toBe(0); - expect(parseJson(same).updated).toBe(false); - expect(fs.readFileSync(metadataPath('idempotent-link'), 'utf-8')).toBe(before); - - const conflict = await runCLI( - ['set', 'change', 'idempotent-link', '--initiative', 'finance/finance-launch', '--json'], - { cwd: tempDir, env } - ); - expect(conflict.exitCode).toBe(1); - expect(parseJson(conflict).status[0].message).toContain('already linked'); - expect(fs.readFileSync(metadataPath('idempotent-link'), 'utf-8')).toBe(before); - }); - - it('refuses set change from a workspace planning home', async () => { - const api = mkdir('linked-api'); - const setup = await runCLI( - ['workspace', 'setup', '--no-interactive', '--json', '--name', 'platform', '--link', `api=${api}`], + const result = await runCLI( + ['set', 'change', 'legacy-change', '--initiative', 'other-initiative'], { cwd: tempDir, env } ); - expect(setup.exitCode).toBe(0); - const workspaceRoot = parseJson(setup).workspace.root; + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain('unknown command'); - const create = await runCLI(['new', 'change', 'workspace-plan'], { - cwd: workspaceRoot, - env, + // Metadata untouched. + expect(readChangeMetadata(changeDir('legacy-change'), tempDir)?.initiative).toEqual({ + store: 'platform', + id: 'billing-launch', }); - expect(create.exitCode).toBe(0); - - const result = await runCLI( - ['set', 'change', 'workspace-plan', '--initiative', 'platform/billing-launch', '--json'], - { cwd: workspaceRoot, env } - ); - - expect(result.exitCode).toBe(1); - expect(parseJson(result).status[0].message).toContain('repo-local changes'); }); }); diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts new file mode 100644 index 000000000..94fe878aa --- /dev/null +++ b/test/commands/store-root-selection.test.ts @@ -0,0 +1,562 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + getGlobalDataDir, + registerContextStore, +} from '../../src/core/index.js'; +import { writeContextStoreMetadataState } from '../../src/core/context-store/foundation.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; + +const VALID_DELTA_SPEC = `## ADDED Requirements + +### Requirement: Billing SHALL work +The system SHALL create bills. + +#### Scenario: Creates bills +- **WHEN** a billing period ends +- **THEN** a bill is created +`; + +const INVALID_DELTA_SPEC = `## ADDED Requirements + +### Requirement: Billing SHALL work +The system SHALL create bills. +`; + +describe('store root selection for normal commands', () => { + let tempDir: string; + let appRepo: string; + let storeRoot: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(async () => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-store-root-selection-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + appRepo = path.join(tempDir, 'app-repo'); + fs.mkdirSync(appRepo, { recursive: true }); + storeRoot = await registerStore('team-context'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function createOpenSpecRoot(rootDir: string): void { + fs.mkdirSync(path.join(rootDir, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.writeFileSync(path.join(rootDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + } + + async function registerStore(id: string): Promise { + const root = path.join(tempDir, 'stores', id); + createOpenSpecRoot(root); + await registerContextStore({ id, localPath: root, globalDataDir }); + return fs.realpathSync.native(root); + } + + function createChange( + rootDir: string, + name: string, + options: { deltaSpec?: string | null; tasksDone?: boolean } = {} + ): string { + const changeDir = path.join(rootDir, 'openspec', 'changes', name); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync( + path.join(changeDir, 'proposal.md'), + '## Why\nBilling needs work.\n\n## What Changes\n- **billing:** Add billing\n' + ); + fs.writeFileSync( + path.join(changeDir, 'tasks.md'), + options.tasksDone === false ? '- [ ] Task 1\n' : '- [x] Task 1\n' + ); + if (options.deltaSpec !== null) { + const specDir = path.join(changeDir, 'specs', 'billing'); + fs.mkdirSync(specDir, { recursive: true }); + fs.writeFileSync(path.join(specDir, 'spec.md'), options.deltaSpec ?? VALID_DELTA_SPEC); + } + return changeDir; + } + + function parseJson(result: RunCLIResult): any { + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error( + `Could not parse JSON.\nCommand: ${result.command}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\n${String(error)}` + ); + } + } + + function expectNoLocalOpenSpec(): void { + expect(fs.existsSync(path.join(appRepo, 'openspec'))).toBe(false); + } + + describe('selecting a registered store by id', () => { + it('creates a change only in the store and names the root on stderr', async () => { + const result = await runCLI(['new', 'change', 'add-billing', '--store', 'team-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(`Using OpenSpec root: team-context (${storeRoot})`); + expect(result.stdout).toContain("Created change 'add-billing'"); + expect(result.stdout).toContain( + path.join(storeRoot, 'openspec', 'changes', 'add-billing') + ); + + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'add-billing')) + ).toBe(true); + expectNoLocalOpenSpec(); + }); + + it('includes the shared root block and absolute paths in new change JSON', async () => { + const result = await runCLI( + ['new', 'change', 'add-billing', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + + const json = parseJson(result); + expect(json.root).toEqual({ + path: storeRoot, + source: 'store', + store_id: 'team-context', + }); + expect(path.isAbsolute(json.change.path)).toBe(true); + expect(json.change.path).toBe( + path.join(storeRoot, 'openspec', 'changes', 'add-billing') + ); + expectNoLocalOpenSpec(); + }); + + it('wins over the nearest local root', async () => { + const localRepo = path.join(tempDir, 'local-repo'); + createOpenSpecRoot(localRepo); + createChange(localRepo, 'local-change'); + createChange(storeRoot, 'store-change'); + + const result = await runCLI(['list', '--json', '--store', 'team-context'], { + cwd: localRepo, + env, + }); + expect(result.exitCode).toBe(0); + + const json = parseJson(result); + const names = json.changes.map((change: any) => change.name); + expect(names).toContain('store-change'); + expect(names).not.toContain('local-change'); + expect(json.root.store_id).toBe('team-context'); + }); + + it('reads, validates, shows, and reports status in the selected store', async () => { + createChange(storeRoot, 'store-change'); + + const status = await runCLI( + ['status', '--change', 'store-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(status.exitCode).toBe(0); + const statusJson = parseJson(status); + expect(statusJson.changeName).toBe('store-change'); + expect(statusJson.schemaName).toBe('spec-driven'); + expect(statusJson.root).toEqual({ + path: storeRoot, + source: 'store', + store_id: 'team-context', + }); + + const instructions = await runCLI( + ['instructions', 'design', '--change', 'store-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(instructions.exitCode).toBe(0); + const instructionsJson = parseJson(instructions); + expect(instructionsJson.artifactId).toBe('design'); + expect(instructionsJson.root.store_id).toBe('team-context'); + expect(path.isAbsolute(instructionsJson.changeDir)).toBe(true); + expect(instructionsJson.changeDir).toContain(storeRoot); + + const show = await runCLI( + ['show', 'store-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(show.exitCode).toBe(0); + const showJson = parseJson(show); + expect(showJson.id).toBe('store-change'); + expect(showJson.root.store_id).toBe('team-context'); + + const validate = await runCLI( + ['validate', 'store-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(validate.exitCode).toBe(0); + const validateJson = parseJson(validate); + expect(validateJson.items[0]).toMatchObject({ id: 'store-change', valid: true }); + expect(validateJson.root.store_id).toBe('team-context'); + + expectNoLocalOpenSpec(); + }); + + it('lists specs from the store with minimal JSON support', async () => { + const specDir = path.join(storeRoot, 'openspec', 'specs', 'billing'); + fs.mkdirSync(specDir, { recursive: true }); + fs.writeFileSync( + path.join(specDir, 'spec.md'), + '# billing\n\n## Purpose\nBills.\n\n## Requirements\n\n### Requirement: Billing SHALL work\nThe system SHALL bill.\n\n#### Scenario: Bills\n- **WHEN** due\n- **THEN** billed\n' + ); + + const result = await runCLI(['list', '--specs', '--json', '--store', 'team-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.specs).toEqual([{ id: 'billing', requirementCount: 1 }]); + expect(json.root.store_id).toBe('team-context'); + }); + + it('runs bulk validation against the selected store', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI(['validate', '--all', '--store', 'team-context', '--json'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.items.map((item: any) => item.id)).toContain('store-change'); + expect(json.root.store_id).toBe('team-context'); + }); + + it('archives a change into the store archive with JSON output', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI( + ['archive', 'store-change', '--store', 'team-context', '--json', '--yes'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim().startsWith('{')).toBe(true); + + const json = parseJson(result); + expect(json.archive.change).toBe('store-change'); + expect(json.archive.archivedAs).toMatch(/^\d{4}-\d{2}-\d{2}-store-change$/); + expect(json.archive.path).toBe( + path.join(storeRoot, 'openspec', 'changes', 'archive', json.archive.archivedAs) + ); + expect(json.archive.specsUpdated).toBe(true); + expect(json.root.store_id).toBe('team-context'); + + expect(fs.existsSync(json.archive.path)).toBe(true); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'store-change')) + ).toBe(false); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'specs', 'billing', 'spec.md')) + ).toBe(true); + expectNoLocalOpenSpec(); + }); + }); + + describe('human output and stdout purity', () => { + it('keeps show stdout as the raw markdown payload', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI(['show', 'store-change', '--store', 'team-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout.startsWith('## Why')).toBe(true); + expect(result.stderr).toContain(`Using OpenSpec root: team-context (${storeRoot})`); + }); + + it('keeps instructions stdout as the artifact payload', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI( + ['instructions', 'design', '--change', 'store-change', '--store', 'team-context'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout.startsWith(' { + createChange(storeRoot, 'store-change'); + + const result = await runCLI( + ['status', '--change', 'store-change', '--store', 'team-context'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(`Using OpenSpec root: team-context (${storeRoot})`); + expect(result.stdout).toContain('Change: store-change'); + expect(result.stdout).not.toContain('Using OpenSpec root'); + }); + }); + + describe('selector errors', () => { + it('rejects --store-path with register guidance', async () => { + const result = await runCLI(['new', 'change', 'nope', '--store-path', '/x'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('context-store register'); + expect(output).toContain('--store '); + expectNoLocalOpenSpec(); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'nope'))).toBe(false); + }); + + it('rejects show --store-path despite allowUnknownOption', async () => { + const result = await runCLI(['show', '--store-path', '/x'], { cwd: appRepo, env }); + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('context-store register'); + }); + + it('reports unknown stores with the same message across commands', async () => { + const expected = + "Unknown context store 'team-contxt'. Registered stores: team-context."; + + const status = await runCLI(['status', '--store', 'team-contxt'], { cwd: appRepo, env }); + const list = await runCLI(['list', '--store', 'team-contxt'], { cwd: appRepo, env }); + + expect(status.exitCode).toBe(1); + expect(list.exitCode).toBe(1); + expect(status.stdout + status.stderr).toContain(expected); + expect(list.stdout + list.stderr).toContain(expected); + }); + + it('rejects an invalid store id format before registry lookup', async () => { + const result = await runCLI(['list', '--store', 'Bad_Id'], { cwd: appRepo, env }); + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('kebab-case'); + }); + + it('emits machine-readable resolver failures in JSON mode', async () => { + const result = await runCLI(['status', '--json', '--store', 'team-contxt'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.status[0].code).toBe('unknown_store'); + expect(json.status[0].message).toContain('team-contxt'); + }); + + it('fails on an unhealthy store root and points to doctor', async () => { + const brokenRoot = path.join(tempDir, 'stores', 'broken-context'); + fs.mkdirSync(brokenRoot, { recursive: true }); + await writeContextStoreMetadataState(brokenRoot, { version: 1, id: 'broken-context' }); + await registerContextStore({ + id: 'broken-context', + localPath: brokenRoot, + globalDataDir, + }); + + const result = await runCLI(['list', '--store', 'broken-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('context-store doctor'); + // No scaffolding or repair happened. + expect(fs.existsSync(path.join(brokenRoot, 'openspec'))).toBe(false); + }); + }); + + describe('default resolution without --store', () => { + it('fails with a store hint instead of scaffolding when no root exists', async () => { + const result = await runCLI(['new', 'change', 'foo'], { cwd: appRepo, env }); + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('team-context'); + expect(output).toContain('--store '); + expect(output).toContain('openspec init'); + expectNoLocalOpenSpec(); + }); + + it('treats leftover workspace state as no root at all', async () => { + fs.mkdirSync(path.join(appRepo, '.openspec-workspace'), { recursive: true }); + fs.writeFileSync( + path.join(appRepo, '.openspec-workspace', 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n' + ); + + const result = await runCLI(['status'], { cwd: appRepo, env }); + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('team-context'); + }); + + it('ignores leftover workspace state when a nearby root exists', async () => { + const localRepo = path.join(tempDir, 'workspace-repo'); + createOpenSpecRoot(localRepo); + fs.mkdirSync(path.join(localRepo, '.openspec-workspace'), { recursive: true }); + fs.writeFileSync( + path.join(localRepo, '.openspec-workspace', 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n' + ); + createChange(localRepo, 'local-change'); + + const result = await runCLI(['status', '--change', 'local-change', '--json'], { + cwd: localRepo, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.schemaName).toBe('spec-driven'); + expect(json.root.source).toBe('nearest'); + expect(json.root.store_id).toBeUndefined(); + }); + + it('keeps implicit-root behavior when no stores are registered', async () => { + const isolatedEnv = { + ...env, + XDG_DATA_HOME: path.join(tempDir, 'data-empty'), + }; + + const result = await runCLI(['status', '--json'], { cwd: appRepo, env: isolatedEnv }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.changes).toEqual([]); + expect(json.root.source).toBe('implicit'); + }); + }); + + describe('archive --json is non-interactive', () => { + it('fails without a change name instead of opening a picker', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI(['archive', '--store', 'team-context', '--json'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0].code).toBe('archive_change_name_required'); + }); + + it('reports validation failures as diagnostics without stdout prose', async () => { + createChange(storeRoot, 'bad-change', { deltaSpec: INVALID_DELTA_SPEC }); + + const result = await runCLI( + ['archive', 'bad-change', '--store', 'team-context', '--json', '--yes'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(1); + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0].code).toBe('archive_validation_failed'); + // The change was not archived. + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'bad-change')) + ).toBe(true); + }); + + it('refuses incomplete tasks without --yes', async () => { + createChange(storeRoot, 'wip-change', { tasksDone: false }); + + const result = await runCLI( + ['archive', 'wip-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.status[0].code).toMatch(/archive_tasks_incomplete|archive_confirmation_required/); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'wip-change')) + ).toBe(true); + }); + }); + + describe('initiative links are retired from normal change flows', () => { + it('rejects --initiative and creates no files', async () => { + const localRepo = path.join(tempDir, 'initiative-repo'); + createOpenSpecRoot(localRepo); + + const result = await runCLI( + ['new', 'change', 'linked-change', '--initiative', 'billing-launch'], + { cwd: localRepo, env } + ); + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('--initiative is no longer supported'); + expect( + fs.existsSync(path.join(localRepo, 'openspec', 'changes', 'linked-change')) + ).toBe(false); + }); + + it('removes openspec set change entirely', async () => { + const localRepo = path.join(tempDir, 'set-change-repo'); + createOpenSpecRoot(localRepo); + createChange(localRepo, 'existing-change'); + const metadataPath = path.join( + localRepo, + 'openspec', + 'changes', + 'existing-change', + '.openspec.yaml' + ); + + const result = await runCLI( + ['set', 'change', 'existing-change', '--initiative', 'billing-launch'], + { cwd: localRepo, env } + ); + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain('unknown command'); + expect(fs.existsSync(metadataPath)).toBe(false); + + const help = await runCLI(['--help'], { cwd: localRepo, env }); + expect(help.stdout).not.toContain('Set checked-in OpenSpec metadata'); + expect(help.stdout).not.toMatch(/^\s*set\s/m); + }); + }); + + describe('setup and register point to --store usage', () => { + it('shows --store usage after setup', async () => { + const result = await runCLI( + ['context-store', 'setup', 'fresh-context', '--path', path.join(tempDir, 'fresh-context'), '--no-init-git'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('openspec new change --store fresh-context'); + }); + + it('shows --store usage after register', async () => { + const registerRoot = path.join(tempDir, 'register-context'); + createOpenSpecRoot(registerRoot); + await writeContextStoreMetadataState(registerRoot, { + version: 1, + id: 'register-context', + }); + + const result = await runCLI(['context-store', 'register', registerRoot], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('openspec new change --store register-context'); + }); + }); +}); diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 1d0f75e14..2b0b781e3 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -15,34 +15,45 @@ describe('ArchiveCommand', () => { let tempDir: string; let archiveCommand: ArchiveCommand; const originalConsoleLog = console.log; + const originalXdgDataHome = process.env.XDG_DATA_HOME; beforeEach(async () => { // Create temp directory tempDir = path.join(os.tmpdir(), `openspec-archive-test-${Date.now()}`); await fs.mkdir(tempDir, { recursive: true }); - + // Change to temp directory process.chdir(tempDir); - + + // Isolate root resolution from any real context-store registry on the + // host machine so no-root behavior stays the implicit-root path. + process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg-data'); + // Create OpenSpec structure const openspecDir = path.join(tempDir, 'openspec'); await fs.mkdir(path.join(openspecDir, 'changes'), { recursive: true }); await fs.mkdir(path.join(openspecDir, 'specs'), { recursive: true }); await fs.mkdir(path.join(openspecDir, 'changes', 'archive'), { recursive: true }); - + // Suppress console.log during tests console.log = vi.fn(); - + archiveCommand = new ArchiveCommand(); }); afterEach(async () => { // Restore console.log console.log = originalConsoleLog; - + + if (originalXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = originalXdgDataHome; + } + // Clear mocks vi.clearAllMocks(); - + // Clean up temp directory try { await fs.rm(tempDir, { recursive: true, force: true }); diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index 2806eaa57..ba31a68b2 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -146,29 +146,36 @@ describe('command completion registry', () => { }); it('tracks top-level workflow commands', () => { - for (const name of ['status', 'instructions', 'templates', 'schemas', 'new', 'set']) { + for (const name of ['status', 'instructions', 'templates', 'schemas', 'new']) { expect(command(name), `${name} command`).toBeDefined(); } + expect(command('set'), 'set command should be removed').toBeUndefined(); + const newChange = command('new')?.subcommands?.find((entry) => entry.name === 'change'); expect(newChange?.flags.map((flag) => flag.name)).toEqual([ 'description', 'goal', - 'areas', - 'initiative', - 'store', - 'store-path', 'schema', 'json', - ]); - - const setChange = command('set')?.subcommands?.find((entry) => entry.name === 'change'); - expect(setChange?.flags.map((flag) => flag.name)).toEqual([ - 'initiative', 'store', - 'store-path', - 'json', ]); + + const storeFlag = newChange?.flags.find((flag) => flag.name === 'store'); + expect(storeFlag?.description).toContain('OpenSpec root'); + expect(newChange?.flags.map((flag) => flag.name)).not.toContain('initiative'); + expect(newChange?.flags.map((flag) => flag.name)).not.toContain('areas'); + expect(newChange?.flags.map((flag) => flag.name)).not.toContain('store-path'); + }); + + it('advertises --store on the supported root-selection commands', () => { + for (const name of ['list', 'show', 'validate', 'archive', 'status', 'instructions']) { + const entry = command(name); + const store = entry?.flags.find((flag) => flag.name === 'store'); + expect(store, `${name} --store flag`).toBeDefined(); + expect(store?.description).toContain('OpenSpec root'); + expect(entry?.flags.map((flag) => flag.name)).not.toContain('store-path'); + } }); it('tracks context-store commands and aliases', () => { diff --git a/test/core/root-selection.test.ts b/test/core/root-selection.test.ts new file mode 100644 index 000000000..d2e5fda49 --- /dev/null +++ b/test/core/root-selection.test.ts @@ -0,0 +1,272 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + resolveOpenSpecRoot, + RootSelectionError, +} from '../../src/core/root-selection.js'; +import { + writeContextStoreMetadataState, + writeContextStoreRegistryState, +} from '../../src/core/context-store/foundation.js'; + +describe('resolveOpenSpecRoot', () => { + let tempDir: string; + let globalDataDir: string; + + beforeEach(() => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-root-selection-')) + ); + globalDataDir = path.join(tempDir, 'global-data'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function mkdir(relativePath: string): string { + const dir = path.join(tempDir, relativePath); + fs.mkdirSync(dir, { recursive: true }); + return dir; + } + + function createOpenSpecRoot(rootDir: string): void { + fs.mkdirSync(path.join(rootDir, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.writeFileSync(path.join(rootDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + } + + async function registerStore( + id: string, + options: { healthyRoot?: boolean; metadataId?: string | null } = {} + ): Promise { + const storeRoot = mkdir(`stores/${id}`); + if (options.healthyRoot !== false) { + createOpenSpecRoot(storeRoot); + } + if (options.metadataId !== null) { + await writeContextStoreMetadataState(storeRoot, { + version: 1, + id: options.metadataId ?? id, + }); + } + + const existing = fs.existsSync(path.join(globalDataDir, 'context-stores', 'registry.yaml')); + const registryStores = existing + ? (await import('../../src/core/context-store/foundation.js').then((m) => + m.readContextStoreRegistryState({ globalDataDir }) + ))?.stores ?? {} + : {}; + + await writeContextStoreRegistryState( + { + version: 1, + stores: { + ...registryStores, + [id]: { backend: { type: 'git', local_path: storeRoot } }, + }, + }, + { globalDataDir } + ); + + return storeRoot; + } + + async function expectRootSelectionError( + promise: Promise, + code: string + ): Promise { + let caught: unknown; + try { + await promise; + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(RootSelectionError); + const error = caught as RootSelectionError; + expect(error.diagnostic.code).toBe(code); + return error; + } + + it('resolves a selected store to its healthy OpenSpec root', async () => { + const storeRoot = await registerStore('team-context'); + + const root = await resolveOpenSpecRoot({ store: 'team-context', globalDataDir }); + + expect(root.source).toBe('store'); + expect(root.storeId).toBe('team-context'); + expect(root.path).toBe(storeRoot); + expect(root.changesDir).toBe(path.join(storeRoot, 'openspec', 'changes')); + expect(root.specsDir).toBe(path.join(storeRoot, 'openspec', 'specs')); + expect(root.archiveDir).toBe(path.join(storeRoot, 'openspec', 'changes', 'archive')); + expect(root.defaultSchema).toBe('spec-driven'); + }); + + it('rejects an unknown store id and lists registered ids', async () => { + await registerStore('team-context'); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'team-contxt', globalDataDir }), + 'unknown_store' + ); + expect(error.message).toContain("'team-contxt'"); + expect(error.message).toContain('team-context'); + }); + + it('rejects --store when no stores are registered without suggesting --store-path', async () => { + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'team-context', globalDataDir }), + 'no_registered_stores' + ); + expect(error.message).not.toContain('--store-path'); + expect(error.diagnostic.fix).not.toContain('--store-path'); + }); + + it('rejects an invalid store id format before registry lookup', async () => { + // No registry exists at all; format validation must win. + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'Bad/Id', globalDataDir }), + 'invalid_context_store_id' + ); + expect(error.message).toContain('Context store id'); + }); + + it('rejects an unhealthy store root without repairing it', async () => { + const storeRoot = await registerStore('team-context', { healthyRoot: false }); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'team-context', globalDataDir }), + 'unhealthy_store_root' + ); + expect(error.diagnostic.fix).toContain('context-store doctor'); + // No scaffolding or repair happened. + expect(fs.existsSync(path.join(storeRoot, 'openspec'))).toBe(false); + }); + + it('rejects a store whose metadata id does not match the registry id', async () => { + await registerStore('team-context', { metadataId: 'other-context' }); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'team-context', globalDataDir }), + 'store_identity_mismatch' + ); + expect(error.message).toContain('other-context'); + expect(error.diagnostic.fix).toContain('context-store doctor'); + }); + + it('rejects a store with missing identity metadata before root-health checks', async () => { + // Root is also unhealthy; the identity failure must win. + await registerStore('team-context', { healthyRoot: false, metadataId: null }); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'team-context', globalDataDir }), + 'store_identity_mismatch' + ); + expect(error.diagnostic.fix).toContain('context-store doctor'); + }); + + it('rejects --store-path deliberately with register guidance', async () => { + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ storePath: '/somewhere', globalDataDir }), + 'store_path_not_supported' + ); + expect(error.message).toContain('context-store register'); + expect(error.message).toContain('--store '); + }); + + it('resolves the nearest openspec root without --store', async () => { + const repoRoot = mkdir('app-repo'); + createOpenSpecRoot(repoRoot); + const nested = mkdir('app-repo/src/deep'); + + const root = await resolveOpenSpecRoot({ startPath: nested, globalDataDir }); + + expect(root.source).toBe('nearest'); + expect(root.path).toBe(repoRoot); + }); + + it('ignores leftover workspace view state when a nearest root exists', async () => { + const workspaceDir = mkdir('workspace'); + fs.mkdirSync(path.join(workspaceDir, '.openspec-workspace'), { recursive: true }); + fs.writeFileSync( + path.join(workspaceDir, '.openspec-workspace', 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n' + ); + const repoRoot = mkdir('workspace/app-repo'); + createOpenSpecRoot(repoRoot); + const nested = mkdir('workspace/app-repo/src'); + + const root = await resolveOpenSpecRoot({ startPath: nested, globalDataDir }); + + expect(root.source).toBe('nearest'); + expect(root.path).toBe(repoRoot); + expect(root.changesDir).toBe(path.join(repoRoot, 'openspec', 'changes')); + expect(root.defaultSchema).toBe('spec-driven'); + }); + + it('treats workspace state alone as no root at all', async () => { + const workspaceDir = mkdir('workspace-only'); + fs.mkdirSync(path.join(workspaceDir, '.openspec-workspace'), { recursive: true }); + fs.writeFileSync( + path.join(workspaceDir, '.openspec-workspace', 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n' + ); + + const root = await resolveOpenSpecRoot({ startPath: workspaceDir, globalDataDir }); + + expect(root.source).toBe('implicit'); + expect(root.path).toBe(workspaceDir); + }); + + it('fails with a store-selection hint when no root exists but stores are registered', async () => { + await registerStore('team-context'); + const appRepo = mkdir('plain-app'); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: appRepo, globalDataDir }), + 'no_root_with_registered_stores' + ); + expect(error.message).toContain('team-context'); + expect(error.message).toContain('--store '); + expect(error.message).toContain('openspec init'); + // No scaffolding happened. + expect(fs.existsSync(path.join(appRepo, 'openspec'))).toBe(false); + }); + + it('allows an implicit root only when requested', async () => { + const appRepo = mkdir('implicit-app'); + + const implicitRoot = await resolveOpenSpecRoot({ startPath: appRepo, globalDataDir }); + expect(implicitRoot.source).toBe('implicit'); + expect(implicitRoot.path).toBe(appRepo); + + await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: appRepo, globalDataDir, allowImplicitRoot: false }), + 'no_openspec_root' + ); + }); + + it('prefers the selected store over a nearby root and leftover workspace state', async () => { + const storeRoot = await registerStore('team-context'); + const repoRoot = mkdir('local-repo'); + createOpenSpecRoot(repoRoot); + fs.mkdirSync(path.join(repoRoot, '.openspec-workspace'), { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, '.openspec-workspace', 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n' + ); + + const root = await resolveOpenSpecRoot({ + store: 'team-context', + startPath: repoRoot, + globalDataDir, + }); + + expect(root.source).toBe('store'); + expect(root.path).toBe(storeRoot); + }); +}); From 296e3841b3981ef53342841b306c4adf910281ef Mon Sep 17 00:00:00 2001 From: TabishB Date: Thu, 11 Jun 2026 02:52:49 +1000 Subject: [PATCH 006/111] Fix stream-purity and message bugs found in review - archive --json: silence the REMOVED-deltas-on-new-spec warning from buildUpdatedSpec so the JSON payload stays pure. - Resolver: wrap registry reads so a corrupt registry surfaces as a RootSelectionError; JSON mode now emits a machine-readable diagnostic instead of a blank stdout line. - archive --store (human): per-spec update lines use the absolute store path, matching the cross-root absolute-paths contract. - Noun-form spec show keeps its forward-slash relative not-found message on all platforms; root-aware show reports the absolute path. - Tests: archive --json purity for REMOVED-delta and spec-update-failure paths, corrupt-registry JSON diagnostics, and running inside the standalone store repo without --store. --- src/commands/spec.ts | 5 +- src/core/archive.ts | 8 ++- src/core/root-selection.ts | 20 ++++-- src/core/specs-apply.ts | 9 +-- test/commands/store-root-selection.test.ts | 81 ++++++++++++++++++++++ 5 files changed, 110 insertions(+), 13 deletions(-) diff --git a/src/commands/spec.ts b/src/commands/spec.ts index d158ea5ad..d3d176873 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -94,7 +94,10 @@ export class SpecCommand { const specPath = join(this.specsDir, specId, 'spec.md'); if (!existsSync(specPath)) { - throw new Error(`Spec '${specId}' not found at ${specPath}`); + // Root-aware callers get the absolute path; the cwd-based noun form + // keeps its historical forward-slash relative message on all platforms. + const displayPath = this.rootPath ? specPath : `openspec/specs/${specId}/spec.md`; + throw new Error(`Spec '${specId}' not found at ${displayPath}`); } if (options.json) { diff --git a/src/core/archive.ts b/src/core/archive.ts index 0e67e24d0..c2c745ab4 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -395,7 +395,7 @@ export class ArchiveCommand { const prepared: Array<{ update: SpecUpdate; rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> = []; try { for (const update of specUpdates) { - const built = await buildUpdatedSpec(update, changeName!); + const built = await buildUpdatedSpec(update, changeName!, { silent: json }); prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts }); } } catch (err: any) { @@ -434,7 +434,11 @@ export class ArchiveCommand { return null; } } - await writeUpdatedSpec(p.update, p.rebuilt, p.counts, { silent: json }); + await writeUpdatedSpec(p.update, p.rebuilt, p.counts, { + silent: json, + // Cross-root paths must be absolute when a store is selected. + ...(root.source === 'store' ? { displayPath: p.update.target } : {}), + }); writeTotals.added += p.counts.added; writeTotals.modified += p.counts.modified; writeTotals.removed += p.counts.removed; diff --git a/src/core/root-selection.ts b/src/core/root-selection.ts index 9e2921a0b..c39a961a2 100644 --- a/src/core/root-selection.ts +++ b/src/core/root-selection.ts @@ -141,9 +141,12 @@ async function resolveStoreRoot( fromContextStoreError(error); } - const registry = await readContextStoreRegistryState( - globalDataDir ? { globalDataDir } : {} - ); + let registry; + try { + registry = await readContextStoreRegistryState(globalDataDir ? { globalDataDir } : {}); + } catch (error) { + fromContextStoreError(error); + } const entries = registry ? listContextStoreRegistryEntries(registry) : []; const entry = entries.find((candidate) => candidate.id === id); @@ -238,9 +241,14 @@ export async function resolveOpenSpecRoot( return makeRoot(nearestRoot, 'nearest'); } - const registry = await readContextStoreRegistryState( - options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} - ); + let registry; + try { + registry = await readContextStoreRegistryState( + options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} + ); + } catch (error) { + fromContextStoreError(error); + } const registeredIds = registry ? listContextStoreRegistryEntries(registry).map((entry) => entry.id) : []; diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index e8a3472b1..ff399ec3e 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -101,7 +101,8 @@ export async function findSpecUpdates(changeDir: string, mainSpecsDir: string): */ export async function buildUpdatedSpec( update: SpecUpdate, - changeName: string + changeName: string, + options: { silent?: boolean } = {} ): Promise<{ rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> { // Read change spec content (delta-format expected) const changeContent = await fs.readFile(update.source, 'utf-8'); @@ -213,7 +214,7 @@ export async function buildUpdatedSpec( ); } // Warn about REMOVED requirements being ignored for new specs - if (plan.removed.length > 0) { + if (plan.removed.length > 0 && !options.silent) { console.log( chalk.yellow( `⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).` @@ -354,7 +355,7 @@ export async function writeUpdatedSpec( update: SpecUpdate, rebuilt: string, counts: { added: number; modified: number; removed: number; renamed: number }, - options: { silent?: boolean } = {} + options: { silent?: boolean; displayPath?: string } = {} ): Promise { // Create target directory if needed const targetDir = path.dirname(update.target); @@ -364,7 +365,7 @@ export async function writeUpdatedSpec( if (options.silent) return; const specName = path.basename(path.dirname(update.target)); - console.log(`Applying changes to openspec/specs/${specName}/spec.md:`); + console.log(`Applying changes to ${options.displayPath ?? `openspec/specs/${specName}/spec.md`}:`); if (counts.added) console.log(` + ${counts.added} added`); if (counts.modified) console.log(` ~ ${counts.modified} modified`); if (counts.removed) console.log(` - ${counts.removed} removed`); diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index 94fe878aa..16334d46a 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -26,6 +26,25 @@ const INVALID_DELTA_SPEC = `## ADDED Requirements The system SHALL create bills. `; +// Targets a spec that does not exist yet: REMOVED deltas are ignored with a +// human-mode warning, which must never leak into JSON stdout. +const REMOVED_ONLY_DELTA_SPEC = `## REMOVED Requirements + +### Requirement: Old billing SHALL go away +`; + +// MODIFIED deltas against a spec that does not exist make buildUpdatedSpec +// throw during the prepare pass. +const MODIFIED_ONLY_DELTA_SPEC = `## MODIFIED Requirements + +### Requirement: Billing SHALL work +The system SHALL create bills differently. + +#### Scenario: Creates bills +- **WHEN** a billing period ends +- **THEN** a bill is created +`; + describe('store root selection for normal commands', () => { let tempDir: string; let appRepo: string; @@ -362,6 +381,23 @@ describe('store root selection for normal commands', () => { expect(json.status[0].message).toContain('team-contxt'); }); + it('reports a corrupt registry as machine-readable JSON, not prose', async () => { + fs.writeFileSync( + path.join(globalDataDir, 'context-stores', 'registry.yaml'), + '{not yaml: [' + ); + + const result = await runCLI(['status', '--json', '--store', 'team-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.status[0].severity).toBe('error'); + expect(json.status[0].code).toBe('invalid_context_store_registry'); + }); + it('fails on an unhealthy store root and points to doctor', async () => { const brokenRoot = path.join(tempDir, 'stores', 'broken-context'); fs.mkdirSync(brokenRoot, { recursive: true }); @@ -427,6 +463,19 @@ describe('store root selection for normal commands', () => { expect(json.root.store_id).toBeUndefined(); }); + it('works inside the standalone repo itself without a flag', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI(['status', '--change', 'store-change', '--json'], { + cwd: storeRoot, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.changeName).toBe('store-change'); + expect(json.root).toEqual({ path: storeRoot, source: 'nearest' }); + }); + it('keeps implicit-root behavior when no stores are registered', async () => { const isolatedEnv = { ...env, @@ -474,6 +523,38 @@ describe('store root selection for normal commands', () => { ).toBe(true); }); + it('keeps stdout pure when REMOVED deltas target a new spec', async () => { + createChange(storeRoot, 'removed-change', { deltaSpec: REMOVED_ONLY_DELTA_SPEC }); + + const result = await runCLI( + ['archive', 'removed-change', '--store', 'team-context', '--json', '--yes', '--no-validate'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + // The "REMOVED requirement(s) ignored for new spec" warning must not + // precede or pollute the JSON payload. + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.archive.change).toBe('removed-change'); + }); + + it('reports spec-update failures as diagnostics without stdout prose', async () => { + createChange(storeRoot, 'modified-change', { deltaSpec: MODIFIED_ONLY_DELTA_SPEC }); + + const result = await runCLI( + ['archive', 'modified-change', '--store', 'team-context', '--json', '--yes', '--no-validate'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(1); + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0].code).toBe('archive_spec_update_failed'); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'modified-change')) + ).toBe(true); + }); + it('refuses incomplete tasks without --yes', async () => { createChange(storeRoot, 'wip-change', { tasksDone: false }); From d00c95521987728bf975a6ec8f9f2a5614c3b04b Mon Sep 17 00:00:00 2001 From: TabishB Date: Thu, 11 Jun 2026 03:03:19 +1000 Subject: [PATCH 007/111] Validate all rebuilt specs before writing any The archive spec-update phase validated and wrote each rebuilt spec in a single loop, so a later validation failure could leave earlier specs already modified while reporting "No files were changed". Split it into two passes: validate every rebuilt spec first, then write only after all pass. Regression test covers a two-spec change where one rebuilt spec fails validation and asserts no target spec was created or modified. --- src/core/archive.ts | 15 +++++--- test/commands/store-root-selection.test.ts | 40 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/core/archive.ts b/src/core/archive.ts index c2c745ab4..a997f119a 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -411,11 +411,11 @@ export class ArchiveCommand { return null; } - // All validations passed; pre-validate rebuilt full spec and then write files and display counts - const writeTotals = { added: 0, modified: 0, removed: 0, renamed: 0 }; - for (const p of prepared) { - const specName = path.basename(path.dirname(p.update.target)); - if (!skipValidation) { + // Validate every rebuilt spec before writing any of them, so a + // late validation failure really does leave all targets unchanged. + if (!skipValidation) { + for (const p of prepared) { + const specName = path.basename(path.dirname(p.update.target)); const report = await new Validator().validateSpecContent(specName, p.rebuilt); if (!report.valid) { if (json) { @@ -434,6 +434,11 @@ export class ArchiveCommand { return null; } } + } + + // All validations passed; write files and display counts + const writeTotals = { added: 0, modified: 0, removed: 0, renamed: 0 }; + for (const p of prepared) { await writeUpdatedSpec(p.update, p.rebuilt, p.counts, { silent: json, // Cross-root paths must be absolute when a store is selected. diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index 16334d46a..10361117f 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -538,6 +538,46 @@ describe('store root selection for normal commands', () => { expect(json.archive.change).toBe('removed-change'); }); + it('writes no spec when any rebuilt spec fails validation', async () => { + // Two delta specs in one change: 'aaa-good' targets a new spec and + // rebuilds cleanly; 'zzz-bad' targets an existing spec whose current + // requirement has no scenarios, so its rebuilt content fails the + // validator only at the late rebuilt-validation pass (the prepare-time + // structure check does not catch missing scenarios). + const changeDir = createChange(storeRoot, 'two-spec-change', { deltaSpec: null }); + for (const capability of ['aaa-good', 'zzz-bad']) { + const specDir = path.join(changeDir, 'specs', capability); + fs.mkdirSync(specDir, { recursive: true }); + fs.writeFileSync(path.join(specDir, 'spec.md'), VALID_DELTA_SPEC); + } + const badTargetDir = path.join(storeRoot, 'openspec', 'specs', 'zzz-bad'); + fs.mkdirSync(badTargetDir, { recursive: true }); + const badTargetContent = + '# zzz-bad\n\n## Purpose\nLegacy.\n\n## Requirements\n\n### Requirement: Old rule SHALL hold\nThe system SHALL hold.\n'; + fs.writeFileSync(path.join(badTargetDir, 'spec.md'), badTargetContent); + + const result = await runCLI( + ['archive', 'two-spec-change', '--store', 'team-context', '--json', '--yes'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0].code).toBe('archive_spec_validation_failed'); + + // "No files were changed" must be true: the good spec was not created + // and the bad target is byte-identical. + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'specs', 'aaa-good', 'spec.md')) + ).toBe(false); + expect(fs.readFileSync(path.join(badTargetDir, 'spec.md'), 'utf-8')).toBe( + badTargetContent + ); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'two-spec-change')) + ).toBe(true); + }); + it('reports spec-update failures as diagnostics without stdout prose', async () => { createChange(storeRoot, 'modified-change', { deltaSpec: MODIFIED_ONLY_DELTA_SPEC }); From 31a5cf4304a7d98fa13b15435307ec6b135eb963 Mon Sep 17 00:00:00 2001 From: TabishB Date: Thu, 11 Jun 2026 03:20:59 +1000 Subject: [PATCH 008/111] Mark beta context-store and workspace docs as transition history Rewrites the opening sections of the old initiative and workspace reimplementation artifacts as transition evidence and beta history, and adds the direction-git-native-work transition note. Readers are pointed to openspec/work/simplify-context-and-workspace-model/ for the active direction. --- .../workspace-agent-guidance/proposal.md | 18 +- .../workspace-apply-repo-slice/proposal.md | 18 +- .../HISTORICAL_DIRECTION.md | 26 +- .../POC_REFERENCE_GUIDE.md | 17 +- .../README.md | 46 +- .../START_HERE.md | 39 +- .../proposal.md | 21 +- .../workspace-verify-and-archive/proposal.md | 19 +- .../context-store-and-initiatives/README.md | 49 +- .../decisions.md | 21 + .../direction-git-native-work.md | 472 ++++++++++++++++++ .../direction.md | 25 +- .../context-store-and-initiatives/roadmap.md | 70 ++- .../context-store-and-initiatives/tasks.md | 41 +- .../evidence.md | 16 + .../plan.md | 86 ++-- .../tasks.md | 13 + 17 files changed, 818 insertions(+), 179 deletions(-) create mode 100644 openspec/initiatives/context-store-and-initiatives/direction-git-native-work.md diff --git a/openspec/changes/workspace-agent-guidance/proposal.md b/openspec/changes/workspace-agent-guidance/proposal.md index b9cad3407..447438960 100644 --- a/openspec/changes/workspace-agent-guidance/proposal.md +++ b/openspec/changes/workspace-agent-guidance/proposal.md @@ -1,13 +1,19 @@ ## Why -Status: deferred by the context-store-and-initiatives direction. Generated -workspace guidance remains important, but the durable handoff should be designed -around initiatives linked to repo-local OpenSpec changes, not around a -workspace-owned cross-repo planning home. +Status: obsolete / pending deletion review. + +This change is no longer active and should not be used as an implementation +queue. Current product authority lives in +`openspec/work/simplify-context-and-workspace-model/goal.md` and +`openspec/work/simplify-context-and-workspace-model/roadmap.md`. + +Keep this artifact temporarily only to review whether it contains unique agent +guidance evidence worth promoting, linking, or deliberately discarding before +deletion. Do not revive the workspace-owned cross-repo planning model from this +proposal. The remaining sections preserve the original workspace-agent-guidance direction -for later reference. This work is still expected to matter after initiatives and -initiative-linked repo-local changes exist; it is not the immediate next focus. +for historical review. OpenSpec workspaces let users create a planning home and link repos or folders for cross-area exploration. After setup, the next user expectation is simple: diff --git a/openspec/changes/workspace-apply-repo-slice/proposal.md b/openspec/changes/workspace-apply-repo-slice/proposal.md index 3b98089d6..f52d575d7 100644 --- a/openspec/changes/workspace-apply-repo-slice/proposal.md +++ b/openspec/changes/workspace-apply-repo-slice/proposal.md @@ -1,14 +1,18 @@ ## Why -Status: deferred by the context-store-and-initiatives direction. The principle -that apply means implementation is still useful, but the durable handoff should -be designed around initiatives linked to repo-local OpenSpec changes, not around -a workspace-owned cross-repo plan. Do not implement this as a first-class -workspace lifecycle command until that linkage exists. +Status: obsolete / pending deletion review. + +This change is no longer active and should not be used as an implementation +queue. Current product authority lives in +`openspec/work/simplify-context-and-workspace-model/goal.md` and +`openspec/work/simplify-context-and-workspace-model/roadmap.md`. + +Keep this artifact temporarily only to review whether it contains unique apply +or handoff evidence worth promoting, linking, or deliberately discarding before +deletion. Do not implement this as a first-class workspace lifecycle command. The remaining sections preserve the original workspace apply direction for -later reference. This work is still expected to matter after initiatives and -initiative-linked repo-local changes exist; it is not the immediate next focus. +historical review. After a workspace proposal exists, users need a practical way to implement one repo slice at a time. diff --git a/openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md b/openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md index d6319d293..970b2002c 100644 --- a/openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md +++ b/openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md @@ -4,13 +4,19 @@ Date: 2026-04-30 ## Status +Status: obsolete / pending deletion review. + This document is historical product direction from the workspace POC follow-up. -It remains useful for preserved workspace setup, link, open, update, doctor, and -agent-visibility decisions. +Keep it temporarily only to review whether it contains unique lessons worth +promoting, linking, or deliberately discarding before deletion. + +It no longer defines the durable coordination model. Current product authority +lives in: + +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` -It no longer defines the durable coordination model. The current authority is -`openspec/initiatives/context-store-and-initiatives/direction.md`, which locks -this boundary: +The old beta boundary was: ```text Context stores sync truth. @@ -24,9 +30,9 @@ Superseded here: workspace as the durable planning home, workspace-level planning artifacts as the canonical shared cross-repo plan, and workspace apply/verify/archive as the next first-class lifecycle commands. -Deferred here: apply, verify, archive, branch/worktree orchestration, -cross-repo validation, dependency graph enforcement, and governance flows until -initiative-linked repo-local changes exist. +Obsolete here: workspace apply, verify, archive, branch/worktree orchestration, +cross-repo validation, dependency graph enforcement, and governance flows as a +workspace-owned lifecycle. Fresh-agent entry point: read `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` first, then return to this document for the full product direction. @@ -476,8 +482,8 @@ Those may matter later, but they should not define the first reimplementation pa ## Historical Product Shape This was the older workspace product shape. It is preserved here so POC lessons -remain understandable, but it is superseded by the context-store-and-initiatives -direction for durable coordination. +remain understandable, but it is superseded by the +`simplify-context-and-workspace-model` goal and roadmap. The historical durable product model was: diff --git a/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md b/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md index 5a3ed6836..344b331e4 100644 --- a/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md +++ b/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md @@ -1,17 +1,20 @@ # Workspace POC Reference Guide -This guide is for a fresh agent starting a new session with no prior context about the workspace POC. +Status: obsolete / pending deletion review. + +This guide is for reviewing whether the workspace POC contains unique +historical evidence worth preserving elsewhere before this artifact is deleted. +It is not an implementation guide for current work. Root entry point: `START_HERE.md`. -The goal is not to continue the POC. The goal is to use it as research material -before preserving or replacing specific behavior from the current base. +The goal is not to continue the POC. The goal is to use it only as research +material while deciding what, if anything, should be promoted, linked, or +deliberately discarded before deletion. Current product authority lives in -`openspec/initiatives/context-store-and-initiatives/`. Under that direction, -workspace setup/open/update/doctor behavior remains useful local-view -infrastructure. Workspace-level apply, verify, and archive research is deferred -until initiative-linked repo-local changes exist. +`openspec/work/simplify-context-and-workspace-model/goal.md` and +`openspec/work/simplify-context-and-workspace-model/roadmap.md`. ## Reference Point diff --git a/openspec/changes/workspace-reimplementation-roadmap/README.md b/openspec/changes/workspace-reimplementation-roadmap/README.md index 65716de54..8ad50e17d 100644 --- a/openspec/changes/workspace-reimplementation-roadmap/README.md +++ b/openspec/changes/workspace-reimplementation-roadmap/README.md @@ -4,10 +4,17 @@ This change is the continuity layer for reimplementing workspace support across ## Current Status -This roadmap is historical and has been reframed by -`openspec/initiatives/context-store-and-initiatives/`. Fresh agents should use -the initiative direction as product authority and this roadmap as reference for -POC lessons and preserved local-view behavior. +Status: obsolete / pending deletion review. + +This roadmap is no longer active and should not be used as an implementation +queue. Current product authority lives in: + +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` + +Keep this folder temporarily only to review whether it contains unique POC +lessons or historical evidence that should be promoted, linked, or deliberately +discarded before deletion. Keep: @@ -28,8 +35,9 @@ Defer: - branch/worktree orchestration, strong cross-repo validation, and dependency graph enforcement -Do not pick up the next unfinished flat sibling change from this roadmap unless -a later initiative-linked repo-change design explicitly reactivates it. +Do not pick up the next unfinished flat sibling change from this roadmap. The +workspace-owned planning/apply/verify/archive model is obsolete for current +roadmap purposes. Root entry point for fresh agents: `START_HERE.md`. @@ -50,7 +58,10 @@ The POC branch is reference material only: workspace-poc @ 79a45ac043f414e63d13e08b9da83b135cb20a39 ``` -Use it to understand behavior, tests, and lessons learned. Do not merge it or preserve its architecture by default. The full source direction document from that branch is captured in `HISTORICAL_DIRECTION.md`. +Use it only while reviewing whether behavior, tests, or lessons learned should +be preserved elsewhere. Do not merge it or preserve its architecture by +default. The full source direction document from that branch is captured in +`HISTORICAL_DIRECTION.md`. Fresh agents should read `POC_REFERENCE_GUIDE.md` before implementing any slice. That guide explains how to inspect the pinned POC commit, which files to read for each slice, and what findings to bring back into the OpenSpec artifacts. @@ -80,26 +91,27 @@ OpenSpec currently discovers active changes as immediate directories under `open `workspace-agent-guidance` makes workspace-local workflow skills use the planning model deliberately: inspect linked context, seed workspace changes with goal and known affected areas, and preserve linked repos as read-only planning context until apply selects an edit root. -`workspace-apply-repo-slice` is deferred until initiative-linked repo-local changes define the implementation handoff. +`workspace-apply-repo-slice` is obsolete for current roadmap purposes. Review +it only for unique handoff evidence before deletion. -`workspace-verify-and-archive` is deferred until initiative status and linked repo-local change lifecycle exist. +`workspace-verify-and-archive` is obsolete for current roadmap purposes. Review +it only for unique progress or archive evidence before deletion. -## Session Handoff Prompt +## Deletion Review Prompt -Use this prompt at the start of future implementation sessions: +Use this prompt only when reviewing this artifact before deletion: ```text -Continue the context-store-and-initiatives direction. Read -openspec/initiatives/context-store-and-initiatives/direction.md and -openspec/initiatives/context-store-and-initiatives/roadmap.md first. Use +Review obsolete workspace roadmap artifacts before deletion. Read +openspec/work/simplify-context-and-workspace-model/goal.md and +openspec/work/simplify-context-and-workspace-model/roadmap.md first. Then use openspec/changes/workspace-reimplementation-roadmap/START_HERE.md, openspec/changes/workspace-reimplementation-roadmap/README.md, openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md, openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md, and workspace-poc at 79a45ac043f414e63d13e08b9da83b135cb20a39 as historical -reference material only. Preserve useful local-view workspace behavior, but do -not implement workspace apply, verify, or archive until initiative-linked -repo-local changes exist. +reference material only. Promote or link unique evidence worth keeping, then +delete or deliberately retain the reviewed artifacts. ``` ## Branching Guidance diff --git a/openspec/changes/workspace-reimplementation-roadmap/START_HERE.md b/openspec/changes/workspace-reimplementation-roadmap/START_HERE.md index 9ffedc440..e4974b66b 100644 --- a/openspec/changes/workspace-reimplementation-roadmap/START_HERE.md +++ b/openspec/changes/workspace-reimplementation-roadmap/START_HERE.md @@ -5,17 +5,22 @@ workspace reimplementation. ## Current Status -The original workspace lifecycle roadmap has been reframed by the context store -and initiatives direction. Fresh agents should treat this document and the POC -materials as reference for preserved local-view infrastructure, not as the next -implementation queue. +Status: obsolete / pending deletion review. + +The original workspace lifecycle roadmap is no longer active. Fresh agents +should not use this document, the POC materials, or the flat workspace sibling +changes as an implementation queue. Current product authority lives in: -1. `openspec/initiatives/context-store-and-initiatives/direction.md` -2. `openspec/initiatives/context-store-and-initiatives/roadmap.md` +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` + +This folder is kept temporarily only until any unique historical evidence is +promoted, linked, or deliberately discarded. The old context-store initiative is +transition evidence / beta history, not current authority. -The locked boundary is: +The old beta boundary was: ```text Context stores sync truth. @@ -39,10 +44,10 @@ start here ## Start Here -Read these files in order: +If you are reviewing this artifact before deletion, read these files in order: -1. `openspec/initiatives/context-store-and-initiatives/direction.md` -2. `openspec/initiatives/context-store-and-initiatives/roadmap.md` +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` 3. `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` 4. `openspec/changes/workspace-reimplementation-roadmap/README.md` 5. `openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md` @@ -71,15 +76,13 @@ The original flat OpenSpec order was: Current disposition: -- Keep setup, link, relink, list, open, update, and doctor as beta local-view - infrastructure. -- Treat workspace planning as legacy or transitional behavior, not the durable - cross-repo source of truth. +- Do not implement this roadmap. +- Keep only long enough to review whether the POC or historical notes contain + unique evidence worth preserving elsewhere. +- Treat workspace planning as legacy or transitional beta behavior, not the + durable cross-repo source of truth. - Do not implement `workspace-apply-repo-slice` or - `workspace-verify-and-archive` as first-class workspace lifecycle commands - until initiative-linked repo-local changes exist. -- Use `workspace-reimplementation-roadmap` as continuity and reference, not as - the active shipping sequence. + `workspace-verify-and-archive` as first-class workspace lifecycle commands. ## Before Editing diff --git a/openspec/changes/workspace-reimplementation-roadmap/proposal.md b/openspec/changes/workspace-reimplementation-roadmap/proposal.md index 99daf917d..b48fdb499 100644 --- a/openspec/changes/workspace-reimplementation-roadmap/proposal.md +++ b/openspec/changes/workspace-reimplementation-roadmap/proposal.md @@ -1,13 +1,18 @@ ## Why -Workspace support needs to be reimplemented as a user-facing workflow, not carried forward as a direct port of the proof of concept. - -Status: this roadmap is now historical reference. The active product direction is -the context-store-and-initiatives initiative, where initiatives coordinate -durable cross-repo work, workspaces open local views, and repo-local changes own -implementation. Keep workspace setup/open/update/doctor infrastructure, but do -not treat workspace apply, verify, or archive as the next shipping sequence -until initiative-linked repo-local changes exist. +Status: obsolete / pending deletion review. + +This roadmap is no longer active and should not be used as an implementation +queue. Current product authority lives in +`openspec/work/simplify-context-and-workspace-model/goal.md` and +`openspec/work/simplify-context-and-workspace-model/roadmap.md`. + +Keep this artifact temporarily only to review whether it contains unique POC +lessons or historical evidence that should be promoted, linked, or deliberately +discarded before deletion. + +The original purpose was to reimplement workspace support as a user-facing +workflow, not carry forward the proof of concept as-is. A user should be able to say they have a multi-repo product goal, create a workspace, add the relevant repos, open that workspace with an agent, plan the change, implement one repo slice at a time, verify it, and archive it. The POC branch captured useful behavior and discovery, but its implementation should remain reference material rather than the base architecture. diff --git a/openspec/changes/workspace-verify-and-archive/proposal.md b/openspec/changes/workspace-verify-and-archive/proposal.md index 8856a9583..a056998bd 100644 --- a/openspec/changes/workspace-verify-and-archive/proposal.md +++ b/openspec/changes/workspace-verify-and-archive/proposal.md @@ -1,14 +1,19 @@ ## Why -Status: deferred by the context-store-and-initiatives direction. Per-repo -progress visibility remains important, but verify/archive should be redesigned -around initiative status and linked repo-local OpenSpec changes, not around -workspace-owned final archive state. Do not implement this as a first-class -workspace lifecycle command until that linkage exists. +Status: obsolete / pending deletion review. + +This change is no longer active and should not be used as an implementation +queue. Current product authority lives in +`openspec/work/simplify-context-and-workspace-model/goal.md` and +`openspec/work/simplify-context-and-workspace-model/roadmap.md`. + +Keep this artifact temporarily only to review whether it contains unique +verify/archive or progress-visibility evidence worth promoting, linking, or +deliberately discarding before deletion. Do not implement this as a first-class +workspace lifecycle command. The remaining sections preserve the original workspace verify/archive direction -for later reference. This work is still expected to matter after initiatives and -initiative-linked repo-local changes exist; it is not the immediate next focus. +for historical review. Users need to know whether a cross-repo workspace change is complete without flattening all repo progress into one ambiguous done state. diff --git a/openspec/initiatives/context-store-and-initiatives/README.md b/openspec/initiatives/context-store-and-initiatives/README.md index a31c8faf1..6574a7d4c 100644 --- a/openspec/initiatives/context-store-and-initiatives/README.md +++ b/openspec/initiatives/context-store-and-initiatives/README.md @@ -1,28 +1,44 @@ # Context Store And Initiatives -This initiative is the source of product intent for context stores, -collections, initiatives, workspaces, and repo-local changes. +Status: transition evidence / beta history. -Start here before continuing workspace or initiative work. +This folder preserves the beta context-store and workspace direction, the +decisions made while exploring it, and the evidence that led to the simpler +Git-native model. + +It is not the active product roadmap or implementation queue. For current +direction, start with: + +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` + +The `direction-git-native-work.md` note is the transition note that led to the +current goal. If it conflicts with the current `goal.md`, the current `goal.md` +wins. ## Reading Order -1. `direction.md` explains the product model and principles. -2. `roadmap.md` lists the ordered roadmap. -3. `tasks.md` shows initiative-wide progress. -4. `decisions.md` records accepted decisions. -5. `questions.md` tracks unresolved questions. -6. `work-items//` contains execution notes for one roadmap item. +Use this reading order when researching the beta history: + +1. `direction-git-native-work.md` explains the transition from the old beta + model toward Git-native specs and work. +2. `direction.md` preserves the earlier context-store and initiative direction. +3. `roadmap.md` preserves the historical beta roadmap snapshot. +4. `tasks.md` preserves historical initiative-wide progress. +5. `decisions.md` records accepted decisions made during the beta. +6. `questions.md` tracks questions that were open at the time. +7. `work-items//` contains execution notes for one historical roadmap item. ## Boundary -Initiative artifacts carry product intent and roadmap decisions. OpenSpec specs -describe the current behavioral contract behind the code. +These artifacts preserve product intent, roadmap decisions, and beta evidence +from the old model. OpenSpec specs describe the current behavioral contract +behind the code. Do not rewrite specs for future intent until behavior changes with an implementation slice. -The current product boundary is: +The earlier product boundary was: ```text Context stores sync truth. @@ -31,3 +47,12 @@ Initiatives coordinate work. Workspaces open local views. Changes implement repo-owned slices. ``` + +The newer direction is: + +```text +OpenSpec is a Git-native artifact format for specs and work. + +Specs are what is true. +Work is what is in motion. +``` diff --git a/openspec/initiatives/context-store-and-initiatives/decisions.md b/openspec/initiatives/context-store-and-initiatives/decisions.md index d04e6726b..e2aa873d6 100644 --- a/openspec/initiatives/context-store-and-initiatives/decisions.md +++ b/openspec/initiatives/context-store-and-initiatives/decisions.md @@ -202,3 +202,24 @@ Implications: - Emit advisory edit boundaries only; do not enforce write restrictions. - Continue to open known existing local paths only. Do not clone, branch, create worktrees, use submodules, or infer local repos in Item 10. + +## 2026-05-30: Defer Hardcoded Agent Handoff Guidance + +Decision: Skip Item 13, agent handoff output and delivery polish, as an +implementation item for now. + +Why: The underlying beta pain is real: users and agents need better receipts +after setup, initiative creation, workspace opening, and repo-local change +creation. However, fixed "Next for your agent" guidance assumes a linear +workflow path and may not fit dynamic agentic work, where the agent should +inspect current state and choose the next move. + +Implications: + +- Do not implement hardcoded next-step blocks yet. +- Preserve Item 13 as research context for a future receipt or affordance model. +- Prefer future output that reports what exists, where it lives, and what + actions are available, rather than prescribing one next command. +- Deterministic receipt improvements such as direct `created_paths` fields may + be split into a smaller implementation slice if they remain clearly useful. +- Delivery terminology concerns may be handled separately from handoff output. diff --git a/openspec/initiatives/context-store-and-initiatives/direction-git-native-work.md b/openspec/initiatives/context-store-and-initiatives/direction-git-native-work.md new file mode 100644 index 000000000..a24b346d0 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/direction-git-native-work.md @@ -0,0 +1,472 @@ +# Git-Native Specs And Work Direction + +This note captures the current product direction after the initiative, +workspace, context-store, and multi-repo planning discussion. + +The positive shape is: + +```text +OpenSpec is a Git-native artifact format for specs and work. + +Specs are what is true. +Work is what is in motion. +``` + +OpenSpec artifacts live as files in Git. That Git repo may be the code repo, a +planning repo, or a contracts repo. OpenSpec should not introduce a separate +authoritative state system outside those files. + +## Core Shape + +The preferred future shape is: + +```text +openspec/ + README.md + openspec.yml + specs/ + work/ +``` + +- `specs/` describes accepted behavior. +- `work/` describes intended effort in motion. + +This shape should be the same whether the OpenSpec root lives beside code or in +a dedicated planning or contracts repo. + +```text +app-repo/ + openspec/ + specs/ + work/ + +planning-repo/ + openspec/ + specs/ + work/ +``` + +There is no separate product mode for "repo-local", "external", "workspace", +"context store", or "multi-repo" artifacts. The placement choice is simply +which Git repo contains the OpenSpec files. + +## Vocabulary + +Use a small vocabulary first: + +```text +Spec current accepted behavior +Work intended effort in motion +Change work that applies concrete deltas to targets +Initiative work that coordinates or decomposes other work +Target repo, service, package, path, or system where work lands +``` + +Users should not need to learn `context store`, `project`, `workspace`, +`artifact home`, or `index` as primary product nouns. + +## Domain Terms + +Use these terms when explaining the near-term product: + +```text +OpenSpec root + The `openspec/` directory that contains specs, changes, work, and config. + +In-project OpenSpec + OpenSpec initialized inside the project repo it helps describe. + +Standalone OpenSpec repo + A separate Git repo whose main purpose is to hold OpenSpec artifacts. + +Target project repo + A code repo that a change or work item applies to. + +Local repo map + Private local resolution from a target repo id to a checkout path. + +Workspace view + Legacy or beta local-view language. In the new direction, this should reduce + to a local repo map plus an optional focused OpenSpec root or work item. +``` + +Examples: + +```text +In-project OpenSpec: + +app-repo/ + openspec/ + specs/ + changes/ + +Standalone OpenSpec repo: + +app-openspec-repo/ + openspec/ + specs/ + changes/ + +Target project repo: + +app-repo/ + src/ + tests/ +``` + +The product should avoid the term `repo-local` for this distinction. It is too +easy to confuse "OpenSpec lives in this project repo" with "this work targets +this repo." + +The product should also avoid making `workspace` a primary user-facing noun. +The job that remains is simpler: map target repo ids to local checkout paths so +agents and commands can assemble the relevant Git repos on this machine. + +## Work Is The Primitive + +`work/` is one canonical area for units of work at different scales. + +```text +openspec/ + specs/ + auth/session-limits.md + work/ + add-login-rate-limit/ + work.yaml + proposal.md + tasks.md + deltas/ + checkout-modernization/ + work.yaml + README.md +``` + +A change is work with change capabilities: + +```yaml +id: add-login-rate-limit +kind: change +status: proposed +targets: + - repo: app +``` + +An initiative is also work: + +```yaml +id: checkout-modernization +kind: initiative +status: active +children: + - work: add-login-rate-limit + - work: add-checkout-tax +``` + +The distinction between a change and an initiative should not come from which +top-level folder the artifact lives in. It should come from metadata and +capabilities: + +- Work with targets and deltas can validate and archive those deltas into + `specs/`. +- Work with children, dependencies, and context can coordinate and roll up other + work. +- Some work may be both change-shaped and coordination-shaped. + +## Git Is The Source Of Truth + +OpenSpec should stay Git-native: + +- History comes from Git. +- Review uses normal Git and forge workflows. +- Diffs are normal file diffs. +- External planning means another Git repo, not another state system. +- Indexes, dashboards, status rollups, and orchestration are derived views. + +Forge-specific status such as pull request state, CI, review approvals, or +merge status may be read by adapters. That status should not become a competing +OpenSpec truth. + +## Targets + +Filesystem location should not imply implementation target. Work declares where +it lands. + +```yaml +targets: + - repo: api + - repo: web +``` + +Targets may later address repos, services, packages, paths, external systems, +or monorepo subtrees. Use plural `targets` in the format early, even if some MVP +lifecycle commands only support one target. + +## Nesting And References + +The rule is: + +```text +Nest within a repo. +Reference across repos. +``` + +Within one Git repo, work can nest when that is the real relationship: + +```text +app-repo/ + openspec/ + work/ + checkout-modernization/ + work.yaml + work/ + add-login-rate-limit/ +``` + +Across Git repo boundaries, work references other work by stable identity: + +```yaml +id: checkout-modernization +kind: initiative +children: + - repo: api + work: add-tax-api + - repo: web + work: update-checkout-ui +``` + +This keeps each repo's executable work close to the code it affects while still +allowing a planning or contracts repo to coordinate the larger effort. + +Work identity must come from metadata, not from the path. Folder paths can help +humans browse; they should not be the durable identity of the work. + +## Dependency And Sequencing + +Multi-repo complexity is mostly about sequencing, not folder placement. + +OpenSpec should be able to record dependency intent in Git: + +```yaml +depends_on: + - work: publish-tax-contract +``` + +Future views can answer: + +- How does this large effort decompose? +- What has to happen first? +- Which targets are affected? +- Which teams own the slices? +- What surrounding context does an agent need? + +The free artifact format should be able to describe ordering and dependencies. +Automation that enforces sequencing, gates merges, or rolls up live forge status +can remain a derived orchestration layer. + +## MVP Implication + +The immediate release path should keep the current OpenSpec baseline working: + +```text +openspec/ + README.md + openspec.yml + specs/ + changes/ +``` + +The first mental model is: + +```text +Specs = what is true. +Changes = what should change. +``` + +Near-term work should not require the future `work/` layout. `change` remains +important because a change applies deltas. The `work/` model is the future +layout direction, not a prerequisite for making standalone OpenSpec repos +useful. + +## Roadmap + +### 1. Preserve The Current Baseline + +Keep the existing in-project OpenSpec flow working and understandable: + +```text +app-repo/ + openspec/ + specs/ + changes/ +``` + +The first release goal is not to rename everything. It is to make the current +model boring and reliable. + +### 2. Make The Placement Choice Explicit + +Teach the product language: + +```text +OpenSpec can live inside your project repo, +or in its own Git repo. +``` + +Use: + +- `in-project OpenSpec` for `app-repo/openspec/` +- `standalone OpenSpec repo` for `app-openspec-repo/openspec/` + +Avoid `repo-local` as the user-facing term for this split. + +### 3. Support Standalone OpenSpec Repos + +Allow OpenSpec to be initialized and validated in a Git repo that does not hold +application code: + +```text +app-openspec-repo/ + openspec/ + specs/ + changes/ +``` + +This should use the same parser, templates, validation, and archive concepts as +in-project OpenSpec. A standalone repo is not a new state system. + +### 4. Add Target Project Repo Resolution + +Standalone OpenSpec repos need to describe where changes land: + +```yaml +targets: + - repo: app +``` + +The first slice can keep target resolution simple: + +- register local target repos +- validate that referenced targets exist +- report unresolved targets clearly +- let agents know which OpenSpec repo and target repos are involved + +Do not clone, branch, sync, orchestrate, or infer complex repo state yet. + +This is the simplified successor to the larger workspace-view concept. Existing +workspace beta behavior may remain as compatibility, but new direction should +use local repo mapping as the product shape. + +### 5. Add Cross-Repo Context And Doctoring + +Once standalone OpenSpec repos can target project repos, add read-oriented +support for relevant context: + +- doctor checks for missing target repo mappings +- local path mapping for agents +- read-only references to other OpenSpec repos when needed +- clear output showing which Git repo owns each artifact + +Remote Git URL support, pull/push helpers, status dashboards, and sequencing +enforcement can come later. + +### 6. Evolve Toward `work/` + +After the baseline and standalone repo flow are solid, introduce the future +layout direction: + +```text +openspec/ + specs/ + work/ +``` + +At that point: + +- existing `changes/` can be supported as legacy or migrated +- changes become change-shaped work +- initiatives become coordination-shaped work +- dependency and sequencing views can build on stable work identity + +Do not make `/work` block the standalone OpenSpec repo release. + +## Decisions Considered + +### Separate `changes/` And `initiatives/` + +Rejected as the preferred future shape: + +```text +openspec/ + changes/ + initiatives/ +``` + +This uses folders as the type system and makes changes and initiatives feel +artificially unrelated. The cleaner model is one `work/` tree where change and +initiative are shapes of work. + +### Initiative-Owned Change Folders + +Rejected as canonical storage: + +```text +openspec/ + initiatives/ + checkout-modernization/ + changes/ + add-tax-api/ +``` + +This makes initiative ownership look like lifecycle ownership. A larger unit of +work may coordinate a smaller one, but the smaller unit still has its own +identity, targets, deltas, and lifecycle. + +### Project Or Repo Buckets As Lifecycle Roots + +Rejected as the default: + +```text +projects/ + api/ + openspec/ + changes/ + web/ + openspec/ + changes/ +``` + +Repo buckets work when each artifact cleanly belongs to one repo, but they get +awkward for cross-repo work, shared contracts, monorepos, and initiatives that +span several targets. Repos should be targets, not mandatory lifecycle roots. + +### Stateful Context Store As Core Primitive + +Rejected as the core framing. + +A dedicated planning or contracts repo may hold OpenSpec artifacts, but it is +still a Git repo. OpenSpec should not create a separate authoritative store that +can disagree with Git. + +### Configurable Layout Modes + +Rejected as an MVP product shape. + +Custom layout modes force every tool, doc, and agent instruction to branch. +Prefer one opinionated layout and let users choose which Git repo contains it. + +### Workspace As A Primary Product Object + +Rejected as the new user-facing shape. + +The useful part of workspace-view behavior is local resolution: knowing where +the OpenSpec repo and target project repos are checked out on this machine. That +should be treated as a local repo map, not as a planning container, lifecycle +owner, or durable source of truth. + +## Supersession Note + +This direction supersedes the older product boundary that centered context +stores, collections, initiatives, workspaces, and repo-local changes as separate +primary nouns. Those artifacts remain useful historical context and describe +implemented beta behavior, but new product direction should start from the +Git-native `specs/` and `work/` shape. diff --git a/openspec/initiatives/context-store-and-initiatives/direction.md b/openspec/initiatives/context-store-and-initiatives/direction.md index ba863a7e8..c7bf117d0 100644 --- a/openspec/initiatives/context-store-and-initiatives/direction.md +++ b/openspec/initiatives/context-store-and-initiatives/direction.md @@ -1,11 +1,22 @@ # Context Store And Initiatives Direction -This document captures the suggested direction from the workspace/initiative -discussion. The main shift is that "workspace" should not be the durable shared -planning object. The durable shared object is a synced context store, and -initiatives are one opinionated collection inside it. +Status: historical beta direction. -## Core Model +This document preserves the earlier context-store and workspace direction from +the workspace/initiative discussion. It is useful transition evidence, but it +is not the current product authority for the simplification work. + +For current direction, start with: + +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` + +The main historical shift captured here was that "workspace" should not be the +durable shared planning object. In this earlier model, the durable shared +object was a synced context store, and initiatives were one opinionated +collection inside it. + +## Historical Core Model ```text Context Store @@ -34,9 +45,9 @@ Workspaces open local views. Changes implement repo-owned slices. ``` -## Locked Product Boundary +## Historical Locked Product Boundary -The workspace-to-initiative pivot is now the product boundary for future +The workspace-to-initiative pivot was the product boundary for this beta coordination work: - A workspace is a regenerable, machine-local working view. It maps context diff --git a/openspec/initiatives/context-store-and-initiatives/roadmap.md b/openspec/initiatives/context-store-and-initiatives/roadmap.md index 1ac93f8d5..1f8d345ce 100644 --- a/openspec/initiatives/context-store-and-initiatives/roadmap.md +++ b/openspec/initiatives/context-store-and-initiatives/roadmap.md @@ -1,8 +1,17 @@ # Context Store And Initiatives Roadmap -This roadmap turns the direction in `direction.md` into shippable chunks. +Status: historical beta roadmap snapshot. -The product decision underneath every step is: +This roadmap preserves the implementation queue that existed while the +context-store and workspace model was being explored. It is not the active +roadmap for current simplification work. + +For current direction, start with: + +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` + +The historical product decision underneath this roadmap was: ```text Context stores sync truth. @@ -12,17 +21,18 @@ Workspaces open local views. Changes implement repo-owned slices. ``` -## Current Beta Priority +## Historical Beta Priority Snapshot -The manual beta pass should pull first-run friction forward. Work in this order -before investing in deeper schema or lifecycle machinery: +At the time, the manual beta pass pulled first-run friction forward. This was +the historical working order before investing in deeper schema or lifecycle +machinery: 1. Finish the manual beta reality pass enough to keep the next slices grounded. 2. Item 12, context-store first-run and cleanup UX: interactive no-argument setup, target-path safety, and a supported unregister/remove path. -3. Item 13, agent handoff output and delivery polish: "Next for your agent" blocks, - direct JSON paths, and baseline OpenSpec guidance even when workflow - entrypoints are commands-oriented. +3. Skip Item 13 as an implementation item for now. Preserve the handoff + findings, but avoid hardcoding linear "next step" guidance until the agent + handoff model is clearer. 4. Item 14, workspaces beta guide split: make user docs match the interactive setup path and keep exact flags in the agent playbook. 5. Item 15, context store project roots and schema-led initiatives: sparse initiative @@ -72,10 +82,13 @@ Locked disposition: - Defer branch/worktree orchestration, strong cross-repo validation, dependency graph enforcement, and shared contract governance. -Fresh-agent rule: +Fresh-agent historical reading rule: -- Start from `openspec/initiatives/context-store-and-initiatives/direction.md` - for product authority. +- Start from `openspec/work/simplify-context-and-workspace-model/goal.md` and + `openspec/work/simplify-context-and-workspace-model/roadmap.md` for current + product authority. +- Use `openspec/initiatives/context-store-and-initiatives/direction.md` as + historical beta direction, not as the current product authority. - Treat `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` and `openspec/changes/workspace-reimplementation-roadmap/` as historical reference material for preserved local-view behavior and POC lessons. @@ -492,27 +505,29 @@ Done when: ## 13. Agent Handoff Output And Delivery Polish -Goal: make existing command output and delivery choices enough for a fresh -agent to continue safely, before adding any broader `initiative next` command. +Status: deferred as an implementation item. + +Goal, if revisited: define an agent handoff receipt model that reports what +exists, where it lives, and which affordances are available without prescribing +one linear next step. Work item: `work-items/13-agent-handoff-output-and-delivery-polish/` Ship: -- "Next for your agent" handoff guidance in the command outputs where first-run - flow otherwise depends on pasted beta knowledge. -- JSON output with direct created artifact paths where agents need to write - files, while preserving existing relative fields for compatibility. -- Clear delivery wording that separates baseline OpenSpec guidance from - workflow entrypoints such as skills or slash commands. -- Warnings when a selected tool cannot receive workflow slash commands. +Do not ship fixed "Next for your agent" guidance yet. The current shape assumes +that users and agents move through the beta flow linearly, but real agentic +workflows may inspect, branch, skip steps, or start from existing context. -Done when: +Preserve for future exploration: -- A coding agent can continue from setup or initiative creation output without - guessing command names, reconstructing writable paths, or losing baseline - OpenSpec guidance because the user chose commands-oriented delivery. +- Whether command output should include context receipts, available affordances, + or nothing beyond deterministic paths. +- Whether direct path fields like `created_paths` are a small standalone receipt + improvement rather than part of a broader handoff model. +- How delivery wording should distinguish baseline OpenSpec guidance from + workflow entrypoints without coupling it to this handoff item. ## 14. Workspaces Beta Guide Split @@ -747,7 +762,8 @@ These are important, but should wait until the initiative model has real usage: 10. Let workspaces open initiatives. 11. Manual beta reality pass. 12. Context store first-run and cleanup UX. -13. Agent handoff output and delivery polish. +13. Skip agent handoff output and delivery polish until the handoff model is + clearer. 14. Workspaces beta guide split. 15. Context store project roots and schema-led initiatives. 16. Add local-to-initiative escalation UX. @@ -755,5 +771,5 @@ These are important, but should wait until the initiative model has real usage: 18. Explore initiative-hosted target-bound change artifacts. 19. Review workspace beta compatibility before public release. -Pending discussion: optionally add initiative next / agent handoff UX before or -alongside the handoff polish work. +Pending discussion: revisit handoff receipts after the beta guide and sparse +initiative model clarify what context agents actually need. diff --git a/openspec/initiatives/context-store-and-initiatives/tasks.md b/openspec/initiatives/context-store-and-initiatives/tasks.md index f49c4068d..1acbd6f60 100644 --- a/openspec/initiatives/context-store-and-initiatives/tasks.md +++ b/openspec/initiatives/context-store-and-initiatives/tasks.md @@ -1,18 +1,30 @@ # Context Store And Initiatives Tasks -This tracks roadmap execution for the initiative. Roadmap items live in -`roadmap.md`; detailed working notes live under `work-items/`. +Status: historical beta progress snapshot. -## Current Beta Priority +This file preserves the task state from the old context-store and workspace +initiative. It is not the active implementation queue for current +simplification work. -After the manual beta pass, prioritize the things a fresh user hits while +For current direction, start with: + +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` + +Historical roadmap items live in `roadmap.md`; detailed working notes live +under `work-items/`. + +## Historical Beta Priority Snapshot + +At the time, the manual beta pass prioritized the things a fresh user hit while getting started before deeper model work: 1. Finish Item 11 observations enough to keep implementation grounded. 2. Item 12: no-argument context-store setup, path safety, and cleanup. -3. Item 13: "Next for your agent" output, direct JSON paths, - and baseline guidance/delivery polish. +3. Skip Item 13 as an implementation item for now. Preserve the findings, but + do not hardcode linear "next step" guidance until the agent handoff shape is + better understood. 4. Item 14: update the beta guide so it matches the improved first-run flow. 5. Item 15: context-store project roots and sparse schema-led initiatives. @@ -199,14 +211,15 @@ Work item: `work-items/12-context-store-first-run-and-cleanup-ux/` Work item: `work-items/13-agent-handoff-output-and-delivery-polish/` -- [ ] Decide which commands should print "Next for your agent" handoff guidance. -- [ ] Add direct created-path JSON fields where agents currently have to - reconstruct artifact paths. -- [ ] Clarify commands-oriented delivery so workflow slash commands are separate - from baseline OpenSpec guidance. -- [ ] Warn when a selected tool cannot receive workflow slash commands. -- [ ] Update docs, generated agent guidance, and tests for the polished handoff - and delivery output. +Status: deferred. Do not implement fixed "Next for your agent" output from this +item yet. + +- [ ] Revisit the handoff model after Item 14/15 clarify the beta guide and + sparse initiative flow. +- [ ] If needed, split deterministic receipt improvements such as direct + `created_paths` into a smaller future implementation slice. +- [ ] Avoid prescribing one linear workflow path; future handoff output should + report state, paths, and possible affordances that agents can compose. ## 14. Workspaces Beta Guide Split diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md index f71d8dcc0..40b880851 100644 --- a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md +++ b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md @@ -23,3 +23,19 @@ Treat this as output polish, not a new workflow engine: - keep baseline OpenSpec literacy separate from workflow entrypoints; - leave the broader "what should I do next?" command to the proposed handoff work item. + +## Reassessment + +After reviewing practical examples, the proposed "Next for your agent" shape +looked too prescriptive. It assumes a fixed linear beta path, but agents may +inspect state, branch, skip setup, continue an existing change, or use context in +a different order. + +Conclusion on 2026-05-30: + +- Skip Item 13 as an implementation item for now. +- Preserve the evidence because the handoff pain is real. +- Do not hardcode fixed next-step guidance until the product has a clearer + receipt or affordance model. +- Consider splitting deterministic `created_paths` style receipt fields into a + smaller future slice if they remain useful. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md index 4aa24a8be..af578e2de 100644 --- a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md +++ b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md @@ -2,11 +2,14 @@ ## Status -Proposed from the manual beta reality pass. +Deferred as an implementation item. -This work item captures the remaining agent-handoff and delivery-output gaps -that are smaller than the broader `initiative next` discussion but still matter -for the beta flow. +This work item captures a real beta pain, but the current "Next for your agent" +shape should not be built yet. It assumes a linear workflow path and risks +hardcoding guidance that does not fit dynamic agentic work. + +Decision on 2026-05-30: skip this item for now. Keep the notes as research +input for a future handoff receipt model. ## Source Of Truth @@ -22,27 +25,31 @@ Related work: - `../14-workspaces-beta-guide-split/` - `../15-context-store-project-roots-and-schema-led-initiatives/` -## Why This Exists +## Why This Was Proposed The beta pass showed that agents can succeed if they know which command to run, but the first handoff is still too implicit. Setup output, JSON receipts, docs, and generated delivery artifacts should make the next move obvious without requiring the user to paste tribal knowledge. -This work item is deliberately narrower than an `initiative next` command. It -polishes existing command outputs and delivery semantics so a fresh agent can -continue safely. +That pain is still valid. The uncertain part is the product shape. A fixed next +step may be wrong when the agent can inspect current state, discover existing +initiatives, skip workspace setup, continue from a repo-local change, or choose +a different planning route. + +## Future Direction -## Goals +If this is revisited, frame it as a receipt or affordance model: -- Make setup and initiative creation output point to the next useful agent - action. -- Ensure agent-readable JSON returns paths that can be used directly without - path reconstruction when practical. -- Clarify commands-oriented delivery so "workflow commands" does not mean "the - agent receives no OpenSpec guidance." -- Warn clearly when the selected tool cannot receive workflow slash commands. -- Keep baseline OpenSpec literacy separate from workflow entrypoints. +- report what now exists; +- report where canonical context and created artifacts live; +- report relevant state and selected local bindings; +- optionally report available actions, not a required next command; +- avoid a single `next_command` unless the next action is genuinely + deterministic. + +Small deterministic output improvements, such as absolute `created_paths`, may +still be worth splitting into a narrower implementation slice. ## Non-Goals @@ -52,47 +59,48 @@ continue safely. setup output. - Do not make every relative path field disappear if existing compatibility requires it; add direct absolute path fields instead. +- Do not hardcode a single user or agent journey. -## Output Direction +## Deferred Output Sketch -Commands that create or prepare OpenSpec shared context should include a small -handoff block in human output: +Avoid this prescriptive shape for now: ```text Next for your agent: Ask your coding agent to create or update an initiative in team-context. ``` -JSON output should prefer both stable relative names and direct absolute paths -where agents need to write files: +If a future model exists, prefer contextual receipts: ```json { - "created_files": ["initiative.yaml", "brief.md"], + "created_files": ["brief.md"], "created_paths": [ - "/path/to/store/initiatives/billing-launch/initiative.yaml", "/path/to/store/initiatives/billing-launch/brief.md" ], - "next_commands": {} + "handoff_context": { + "store": "team-context", + "initiative": "billing-launch", + "workspace": null + }, + "available_actions": [ + "inspect_initiative", + "open_workspace_view", + "create_repo_local_change" + ] } ``` -Delivery copy should distinguish: +Delivery copy may still need separate work to distinguish: - baseline OpenSpec guidance or literacy; - workflow entrypoints such as skills or slash commands. -If a user selects commands-oriented delivery for a tool that has no command -adapter, output should warn that workflow slash commands are unavailable while -still installing or recommending baseline guidance when the tool supports it. - -## Done When +## Revisit When -- A fresh agent can continue after context-store setup or initiative creation - using command output and docs, without guessing paths or beta command names. -- JSON receipts expose direct paths for created initiative artifacts or explain - why only relative names are available. -- Commands-oriented delivery output clearly reports what guidance and workflow - entrypoints were installed, skipped, or unavailable. -- The broader `initiative next` proposal can build on these outputs instead of - solving first-run handoff from scratch. +- Item 14 clarifies the human guide versus agent playbook split. +- Item 15 clarifies sparse initiative artifacts and context-store project-root + behavior. +- There is enough beta evidence to decide whether command output should expose + state receipts, available affordances, direct paths only, or no special + handoff block. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md index d9049ccd0..243af6d17 100644 --- a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md +++ b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md @@ -1,5 +1,18 @@ # Agent Handoff Output And Delivery Polish Tasks +Status: deferred. Do not implement these tasks until the handoff model is +redesigned as contextual receipts or affordances rather than fixed linear +"next step" guidance. + +- [ ] Revisit after Item 14 and Item 15 clarify the beta docs, sparse + initiative flow, and context-store project-root model. +- [ ] Decide whether any deterministic receipt improvements, such as + `created_paths`, should be split into a smaller independent slice. +- [ ] Decide whether delivery terminology belongs in a separate + command-surface/delivery item. + +## Deferred Original Tasks + - [ ] Decide which existing commands should print a "Next for your agent" handoff block. - [ ] Define the minimal handoff content for context-store setup, initiative From e3b053d901adcb0c8e7896cfa2f8dc46ffb1e538 Mon Sep 17 00:00:00 2001 From: TabishB Date: Thu, 11 Jun 2026 03:21:09 +1000 Subject: [PATCH 009/111] Record store-root-selection slice artifacts and roadmap progress Adds the slice 1.2 spec, plan, and decision-review evidence, and updates the roadmap: 1.2 is implemented and tested on this branch, with review follow-up and merge remaining. --- .../roadmap.md | 89 ++- .../slice-1.2-decision-review.html | 436 ++++++++++++++ .../slices/store-root-selection/plan.md | 570 ++++++++++++++++++ .../slices/store-root-selection/spec.md | 389 ++++++++++++ 4 files changed, 1453 insertions(+), 31 deletions(-) create mode 100644 openspec/work/simplify-context-and-workspace-model/slice-1.2-decision-review.html create mode 100644 openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/plan.md create mode 100644 openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/spec.md diff --git a/openspec/work/simplify-context-and-workspace-model/roadmap.md b/openspec/work/simplify-context-and-workspace-model/roadmap.md index 454ca3104..3bb0c869d 100644 --- a/openspec/work/simplify-context-and-workspace-model/roadmap.md +++ b/openspec/work/simplify-context-and-workspace-model/roadmap.md @@ -81,10 +81,11 @@ an item are status steps for that numbered work item. Old beta plans were marked as history, and this `/work` roadmap became the active direction. - [ ] **Phase 1. Make a standalone OpenSpec repo useful.** - One slice is implemented in draft PR #1190, but the full phase still needs - normal commands to work against a named standalone repo. + Slices 1.1 and 1.2 have branch implementations and passing tests; the phase + still needs merge to `main` and the end-to-end lifecycle proof. - [ ] **Phase 2. Stop putting new work through initiatives.** - Not started. + Item 2.1 was pulled forward into slice 1.2 and implemented there; the rest is + not started. - [ ] **Phase 3. Say which project repos the work is about.** Not started. - [ ] **Phase 4. Open the right files together.** @@ -97,6 +98,8 @@ Next incomplete item: - [ ] **1.2 Let normal commands use a named standalone OpenSpec repo.** In plain English: when a user is in an app repo, they can tell OpenSpec to create or read work in a registered standalone OpenSpec repo. + Implementation, tests, and review follow-up are done on + `codex/store-root-selection`; merge remains. ## Phase 0. Make The Active Direction Easy To Find @@ -190,9 +193,10 @@ Phase checklist: - [x] **1.1** Create or register a standalone OpenSpec repo. Implemented in draft PR #1190. - [ ] **1.2** Let normal commands use a named standalone OpenSpec repo. - This is the next slice. + Implemented, tested, and review follow-up fixed on + `codex/store-root-selection`; merge remains. - [ ] **1.3** Prove the standalone repo lifecycle end to end. - Do this after normal commands can use the selected repo. + Do this after 1.1 and 1.2 are merged. ### 1.1 Create Or Register A Standalone OpenSpec Repo @@ -250,12 +254,18 @@ How the user or agent knows it worked: Progress: -- [ ] Spec written. -- [ ] Plan written. -- [ ] Implementation done. -- [ ] Tests pass. +- [x] Spec written. +- [x] Plan written. +- [x] Plan reviewed with `claude -p`; actionable feedback folded into the + slice artifacts. +- [x] Implementation done on `codex/store-root-selection` (stacked on + `codex/store-root-parity`). +- [x] Tests pass. +- [x] Review follow-up fixed. - [ ] Merged to `main`. +Slice: `slices/store-root-selection/spec.md` + Plain-English version of the next slice: ```text @@ -287,26 +297,31 @@ Why it matters: What changes in commands or files: -- Add a clear way to choose the OpenSpec root for normal commands, likely - `--store ` and/or `--store-path `. -- Start with a small command set instead of every command at once. -- Suggested first commands: - `new change`, `status`, `instructions`, `list`, `show`, `validate`, and - `archive`. +- Add `--store ` as the way to choose the OpenSpec root for normal + commands. +- First command set: `new change`, `status`, `instructions`, `list`, `show`, + `validate`, and `archive`, behind one shared root resolver. - The selected command writes normal `openspec/changes/` and reads normal `openspec/specs/`. - The command does not create initiative metadata. - The command does not create workspace planning files. -Questions to answer before implementation: - -- Should `--store-path` require `.openspec-store/store.yaml`, or can it point at - any healthy standalone OpenSpec root? -- Which commands get support first? -- How should existing `--store` and `--store-path` meanings from initiative - flows be handled? -- How should this behave when the current directory is already inside a - workspace planning home? +Decisions locked on 2026-06-10 (details in the slice spec): + +- `--store` is repurposed as root selection with exactly one meaning. Phase + 2.1 is pulled forward into this slice: `new change` stops creating + initiative links, the old initiative meanings of `--store` and + `--store-path` are removed, and `openspec set change` is removed because + initiative linking was its only behavior. +- `--store ` (registry lookup) is the only selector. `--store-path` is + deferred; registering a clone is the answer for path access. +- Leftover workspace view state never wins root resolution on this path. The + workspace branch is demoted during this slice's resolver rework instead of + waiting for Phase 2.3/5.1. +- When the current directory has no OpenSpec root and registered stores + exist, commands error with a hint naming the registered stores instead of + silently scaffolding a local root. With no registered stores, current + behavior is unchanged. How the user or agent knows it worked: @@ -374,18 +389,20 @@ should stop attaching new work to initiatives. Phase checklist: - [ ] **2.1** Stop creating new initiative links in normal change flows. + Pulled forward into slice 1.2 on 2026-06-10. - [ ] **2.2** Hide or move initiative commands out of the main path. - [ ] **2.3** Make workspace opening stop depending on initiatives. ### 2.1 Stop Creating New Initiative Links In Normal Change Flows +This item was pulled forward into slice 1.2 (`slices/store-root-selection/`) +on 2026-06-10, because repurposing `--store` as root selection only works +cleanly if initiative-link creation stops in the same slice. Track progress +under 1.2. + Progress: -- [ ] Spec written. -- [ ] Plan written. -- [ ] Implementation done. -- [ ] Tests pass. -- [ ] Merged to `main`. +- [x] Folded into slice 1.2; see the 1.2 progress checklist. What the user can do: @@ -400,8 +417,10 @@ Why it matters: What changes in commands or files: -- `new change` and `set change` stop creating new initiative links as part of - the main product path. +- `new change` stops creating new initiative links as part of the main product + path. +- `openspec set change` is removed because initiative linking was its only + behavior. - Existing `.openspec.yaml` initiative metadata remains parseable if needed. - Store/root selection points to normal OpenSpec roots, not initiative collections. @@ -731,3 +750,11 @@ is working: visible. - 2026-06-10: Numbered phases, phase subitems, and later parking-lot ideas so progress can be tracked unambiguously. +- 2026-06-10: Settled the model question behind 1.2: the OpenSpec root is the + planning home, a context store is registration/identity only, and workspace + "planning home" is legacy beta language. +- 2026-06-10: Locked the 1.2 decisions and added the store-root-selection + slice spec: repurpose `--store` as root selection and pull 2.1 forward, + defer `--store-path`, demote leftover workspace state during the resolver + rework, and replace the silent implicit-root scaffold with an error and + hint when registered stores exist. diff --git a/openspec/work/simplify-context-and-workspace-model/slice-1.2-decision-review.html b/openspec/work/simplify-context-and-workspace-model/slice-1.2-decision-review.html new file mode 100644 index 000000000..d21a1c6fa --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slice-1.2-decision-review.html @@ -0,0 +1,436 @@ + + + + + +Slice 1.2 — Decision Review + + + + +
+
+

Slice 1.2 decisions — store root selection

+ +
+ + + +
+
+
Already settled (not up for review): OpenSpec root is the planning home · context store is registration/identity only · workspace "planning home" is legacy beta language.
+
+ +
+
+
+
+ +
+
+
+ +
+
+ + + + diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/plan.md new file mode 100644 index 000000000..55138b5d8 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/plan.md @@ -0,0 +1,570 @@ +# Store Root Selection For Normal Commands Plan + +## Status + +Implemented on `codex/store-root-selection`; tests pass; review follow-up is +fixed. Merge to `main` remains. + +This plan implements `spec.md` for slice 1.2 after the 2026-06-10 locked +decisions. The main product move is simple: + +```text +--store selects an OpenSpec root. +``` + +A context store remains local registration and identity for a standalone +OpenSpec repo. Normal command behavior should read and write ordinary +`openspec/specs/`, `openspec/changes/`, and `openspec/changes/archive/` files in +the resolved root. + +## Source Of Truth + +Start from `spec.md`. + +Also keep these nearby artifacts in view: + +- `../../goal.md` +- `../../roadmap.md` +- `../store-root-parity/spec.md` +- `../store-root-parity/plan.md` + +The previous slice must be present first because this plan depends on healthy +registered context stores having the normal root shape: + +```text +context-store-root/ + .openspec-store/ + store.yaml + openspec/ + config.yaml + specs/ + changes/ + archive/ +``` + +Implementation should be stacked on the slice 1.1 branch/PR until it merges. +Do not start this slice from `main` unless `store-root-parity` has already +landed, because `src/core/openspec-root.ts` and the registry health behavior in +the code map come from that prerequisite work. + +## User-Facing Frame + +What the human wants: + +- "I am in an app repo, but the OpenSpec work lives in my standalone planning + repo." +- "Use the registered store I named, not a nearby accidental `openspec/` folder." +- "Do not make me learn initiative or workspace planning just to put work in the + right Git repo." +- "Tell me which root was used without corrupting raw command output." + +What the agent needs to know: + +- Which OpenSpec root every command resolved. +- Whether the root came from `--store`, the nearest `openspec/`, or preserved + implicit-root behavior. +- Whether a selected store is unknown, unhealthy, or mismatched with its + `.openspec-store/store.yaml` identity. +- Whether a command wrote only the selected root's OpenSpec artifacts. + +How the user knows it worked: + +- With `--store team-context`, commands use the registered store's root. +- Human mode writes `Using OpenSpec root: team-context (/abs/path)` to stderr. +- JSON mode includes an additive `root` block with the shared shape. +- No new initiative metadata is created, and `openspec set change` is gone. + +## Goals + +- Add `--store ` to the supported top-level commands: + `new change`, `status`, `instructions`, `list`, `show`, `validate`, and + `archive`. +- Route those commands through one shared OpenSpec-root resolver. +- Demote leftover workspace view state for those commands. A + `.openspec-workspace-view.yaml` ancestor is not a normal command root. +- Preserve current no-store behavior per command except where the spec calls out + intentional changes. +- Remove initiative-link creation from `new change`. +- Remove `openspec set change` from CLI registration, help, completions metadata, + workflow exports if unused, and tests/docs references. +- Add `--json` to `archive` and include the shared root block in JSON success + payloads for all supported commands. + +## Non-Goals + +- Do not add `--store-path` selection. +- Do not add a sticky/default store for a project repo. +- Do not add target repo declarations, local repo mapping, views, clone, pull, + push, sync, branch, worktree, dashboard, apply, verify, or orchestration. +- Do not delete initiative commands broadly or migrate legacy initiative data. +- Do not change deprecated noun-form commands such as `openspec change show` or + `openspec spec show`; they remain cwd-based and do not gain `--store`. +- Do not rewrite public docs or rename `context-store` terminology in this + slice. + +## Current Code Map + +Root and context-store plumbing: + +- `src/core/planning-home.ts` currently resolves repo roots, implicit roots, and + workspace planning homes. +- `src/core/context-store/registry.ts` resolves registered context-store ids and + detects metadata mismatches. Its current error fix text still mentions + `--store-path`, and unknown-store errors do not enumerate registered ids; the + normal-command resolver must update or wrap those errors. +- `src/core/openspec-root.ts` inspects healthy OpenSpec root shape. +- `src/core/context-store/operations.ts` powers setup/register/doctor. +- `src/commands/context-store.ts` prints setup/register human next-step output. + +Supported command surfaces: + +- `src/cli/index.ts` registers top-level `archive`, `validate`, `show`, + `status`, `instructions`, `new change`, and the soon-to-be-removed `set + change`. Top-level `show` currently uses `allowUnknownOption(true)`, so + `--store-path` must be registered explicitly there or it will be silently + ignored. +- `src/commands/workflow/new-change.ts` already uses planning-home resolution and + currently creates initiative metadata. It also calls + `assertInitiativeSelectorsHaveReference`, which must be removed or replaced so + `new change --store ` works without `--initiative`. +- `src/commands/workflow/status.ts` and + `src/commands/workflow/instructions.ts` already use planning-home paths. +- `src/core/list.ts`, `src/core/archive.ts`, `src/commands/show.ts`, + `src/commands/validate.ts`, `src/commands/change.ts`, `src/commands/spec.ts`, + and `src/utils/item-discovery.ts` still contain cwd-based `openspec/...` + assumptions. +- `src/core/completions/command-registry.ts` still advertises initiative-related + `new change` flags and the `set change` command. + +Existing tests to update or replace: + +- `test/commands/artifact-workflow.test.ts` covers `new change`, `status`, and + `instructions`. +- `test/commands/change-initiative-link.test.ts` covers behavior this slice + removes. +- `test/commands/context-store.test.ts` covers setup/register output. +- `test/core/planning-home.test.ts` covers workspace planning-home behavior that + normal commands will stop using. +- `test/commands/show.test.ts`, `test/commands/validate.test.ts`, + `test/core/list.test.ts`, `test/core/archive.test.ts`, and completion tests + cover the cwd-based command paths that need root injection. + +## Shared Resolver Design + +Add a shared resolver for normal OpenSpec commands. It can live in a new module +such as `src/core/root-selection.ts`, or replace the normal-command parts of +`planning-home.ts` if that keeps the code simpler. Prefer a new module if it +lets workspace-specific utilities remain untouched for later cleanup. + +Suggested types: + +```ts +type OpenSpecRootSource = 'store' | 'nearest' | 'implicit'; + +interface StoreSelectorOptions { + store?: string; + storePath?: string; +} + +interface ResolveOpenSpecRootOptions extends StoreSelectorOptions { + startPath?: string; + allowImplicitRoot?: boolean; + commandName: string; +} + +interface ResolvedOpenSpecRoot { + path: string; + changesDir: string; + specsDir: string; + archiveDir: string; + defaultSchema: 'spec-driven'; + source: OpenSpecRootSource; + storeId?: string; +} +``` + +Resolver rules: + +- If `storePath` is present, reject deliberately with guidance: + `openspec context-store register ` and then use `--store `. +- If `store` is present, resolve it through the context-store registry. +- Unknown store errors should name the unknown id and list registered ids. +- Selected store roots must be inspected as healthy OpenSpec roots. Do not + scaffold or repair them. +- Selected store metadata id must match the registry id. +- Store health and metadata errors should point to `openspec context-store + doctor`. +- Use a normal-command wrapper around context-store registry resolution, or + update the registry errors directly, so this path never suggests + `--store-path` and always includes registered ids for unknown-store failures. +- Resolver check order is: validate store id format, read registry entry, verify + store metadata identity, then inspect the OpenSpec root shape. Metadata + missing or mismatched errors win before root-health diagnostics. +- If no store is selected, find the nearest ancestor containing `openspec/` and + ignore workspace view state. +- If no nearest root exists and registered stores exist, fail with a hint naming + the registered store ids plus `--store ` or `openspec init`. +- If no nearest root exists and no stores are registered, preserve each + command's current implicit/no-root behavior. + +Command-specific no-store behavior: + +- `new change` continues to allow an implicit root when no stores are registered. +- Commands that currently fail for missing `openspec/changes` or + `openspec/specs` should keep failing in that no-store/no-root case. +- Commands that currently report empty or unknown items in an implicit cwd should + keep that behavior unless the spec says otherwise. +- The shared resolver should expose enough knobs to preserve these differences + rather than normalizing them by accident. + +Compatibility bridge: + +- Workflow commands still expect the existing planning-home shape. Provide a + small adapter from `ResolvedOpenSpecRoot` to the existing `PlanningHome` + interface with `kind: 'repo'`. +- Do not return `kind: 'workspace'` from the normal command path in this slice. +- Leave workspace commands and old workspace utilities in place unless they are + directly blocking the supported command set. + +## Output Contract + +Add shared helpers for root output: + +```ts +interface RootOutput { + path: string; + source: 'store' | 'nearest' | 'implicit'; + store_id?: string; +} +``` + +Human output: + +- When `--store` is selected, write exactly one root banner to stderr before or + near the command payload: + `Using OpenSpec root: team-context (/abs/path)`. +- Do not write the banner to stdout. This protects raw Markdown from `show` and + agent-consumed text from `instructions`. +- Without `--store`, leave human output unchanged. + +JSON output: + +- On JSON success, add top-level `root` to every supported command's existing + JSON payload. +- Keep existing command-specific fields stable; `root` is additive. +- Use `source: 'store'` with `store_id` only for selected stores. +- Use `source: 'nearest'` for nearest-root resolution. +- Use `source: 'implicit'` only for preserved implicit-root behavior. +- Resolver failures should have the same message text, error code, and non-zero + exit behavior across supported commands. Existing JSON error envelopes can + remain command-specific, but the resolver status inside them must be + consistent and JSON-mode failures must not print prose or blank lines to + stdout. + +Path output: + +- When a store is selected, any command output that names files in the store + should use absolute paths. +- Without `--store`, preserve today's relative path style where practical. + +## CLI Flag Contract + +Supported commands get: + +- `--store ` with help text like `Registered context store id to use as the + OpenSpec root`. +- A deliberate `--store-path ` rejection path. Use a hidden/compatibility + option if needed so Commander does not emit a generic unknown-option error. +- Top-level `show` needs special care because it currently uses + `allowUnknownOption(true)`: explicitly register both `--store ` and a + hidden `--store-path ` on that command so the unsupported path selector + cannot be silently ignored. + +`new change` cleanup: + +- Remove or deliberately reject `--initiative`. +- Keep `--store` for root selection only. +- Reject `--store-path` with register guidance. +- Keep `--goal` as ordinary optional change metadata. +- Reject `--areas` because affected workspace links only made sense for + workspace-scoped planning. + +`set change` removal: + +- Remove `set change` registration from `src/cli/index.ts`. +- Remove `SetChangeOptions`, `setChangeCommand` exports, and + `src/commands/workflow/set-change.ts` if no remaining import needs them. +- Check `src/commands/workflow/initiative-link.ts` after both `new change` and + `set change` stop importing it; remove it too if it becomes orphaned. +- Remove `set change` from completion metadata and command-reference tests. +- Do not add a deprecated stub or replacement command in this slice. + +## Command Implementation Plan + +### `new change` + +- Resolve the OpenSpec root before validating schema or writing files. +- Remove initiative-link lookup and metadata creation. +- Remove or replace `assertInitiativeSelectorsHaveReference` and + `assertRepoLocalInitiativeLinkPlanningHome` usage so `--store` no longer + requires `--initiative`. +- Reject `--initiative`, `--store-path`, and `--areas` before creating files. +- Preserve `--description`, `--goal`, `--schema`, and `--json`. +- Write changes under the resolved root's `openspec/changes/`. +- When selected by store, print the root banner to stderr and use absolute paths + in human and JSON path fields. +- Add `root` to JSON success. + +### `status` + +- Add selector options and resolve the root. +- Use the resolved root for change discovery, schema resolution, and + `loadChangeContext`. +- Add `root` to every JSON success shape, including no-active-changes output. +- Print the selected-store banner to stderr in human mode. + +### `instructions` + +- Add selector options and resolve the root for both artifact instructions and + `instructions apply`. +- Keep stdout payload clean. The root banner goes to stderr only. +- Add `root` to JSON success for artifact and apply instructions. +- Ensure file paths returned for selected stores are absolute where they point + into the store. + +### `list` + +- Update top-level `openspec list` to resolve the root before listing. +- Make `ListCommand` accept an absolute root or directories instead of assuming + cwd. +- Preserve deprecated noun-form `openspec change list` and `openspec spec list` + behavior. +- Add minimal JSON support for `list --specs --json` in this slice so specs mode + also gets the shared `root` block. +- Add `root` to JSON success and stderr banner for selected stores. + +### `show` + +- Resolve the root in top-level `openspec show`. +- Update item discovery to accept a root path. +- Update top-level show delegation so change/spec reads use the resolved root. +- Preserve deprecated noun-form commands as cwd-based. +- Keep raw Markdown stdout unmodified; root banner goes to stderr. +- Add `root` to JSON success for both change and spec output. +- Add a focused `show --store-path /x` test because `allowUnknownOption(true)` + would otherwise mask the deliberate rejection. + +### `validate` + +- Resolve the root in top-level `openspec validate`. +- Update direct validation, type detection, bulk validation, and interactive + item pickers to discover and operate within the resolved root. +- Add `root` to JSON success for single-item and bulk output. +- Keep deprecated noun-form `change validate` and `spec validate` cwd-based. + +### `archive` + +- Add `--store `, deliberate `--store-path` rejection, and `--json`. +- Resolve the root before selecting or validating a change. +- Use selected root changes, specs, and archive directories for validation, + spec updates, and moving the change into archive. +- In JSON mode, return the archive result and root block without human prose. +- JSON mode must be non-interactive: suppress spinner/ora output and + confirmation prompts (require `--yes` or fail with a clear error instead of + hanging on a prompt). +- JSON mode requires an explicit change name. Without one, fail before the + interactive picker. +- JSON failure cases such as validation failure, incomplete-task refusal, + spec-update abort, and cancelled confirmation should exit non-zero and emit a + machine-readable diagnostic instead of stdout prose. Do not let CLI wrapper + blank lines or ora failure output pollute JSON stdout. +- In human mode, print selected-store root banner to stderr and keep archive + status/progress on stdout. + +### `context-store setup` and `register` + +- Update successful human next steps to show normal command usage: + `openspec new change --store `. +- Update JSON output only if there is already a next-steps field. Do not invent a + large onboarding payload in this slice. + +## Error And Diagnostic Plan + +Use existing error styles where possible, but make these cases clear. The names +below are the normal-command diagnostic names; when reusing existing +`ContextStoreError` codes, document the mapping instead of inventing a second +taxonomy silently: + +- `unknown_store`: names the unknown id and lists registered ids. +- `no_registered_stores`: when `--store` is used with no registry; must not + suggest `--store-path`. +- `unhealthy_store_root`: describes missing/incomplete root and points to + `openspec context-store doctor`. +- `store_identity_mismatch`: describes registry id vs metadata id and points to + doctor. +- `store_path_not_supported`: points to `context-store register` plus + `--store `. +- `no_root_with_registered_stores`: names registered stores and suggests + `--store ` or `openspec init`. +- `initiative_option_removed`: tells users that normal changes no longer attach + to initiatives. +- `areas_option_removed`: tells users that workspace affected areas are not part + of the normal OpenSpec root path. + +Guardrails: + +- Resolution failures must occur before writes. +- Store health failures must not run setup/repair. +- Metadata missing or id mismatch should be reported before generic root-health + failures. +- Unknown or removed options should not create partial change directories. +- No supported command should silently ignore `--store` or `--store-path`. + +## Test Plan + +Create focused helpers for this slice rather than copying large setup blocks. +Suggested helper shape: + +- Temporary app repo root with no `openspec/`. +- Temporary app repo root with its own `openspec/`. +- Temporary registered context store with healthy root. +- Helpers to write store metadata and registry under isolated + `XDG_DATA_HOME`/`XDG_CONFIG_HOME`. +- Helpers to create changes/specs in a chosen root. +- Helper to parse JSON and assert root block. + +Add or update tests: + +- `test/core/root-selection.test.ts` or `test/core/planning-home.test.ts` + for resolver behavior: + - selected store resolves to healthy root. + - unknown store lists registered ids. + - unhealthy root fails without repair. + - metadata mismatch fails. + - nearest root wins without `--store`. + - leftover workspace state is ignored. + - no root plus registered stores fails with store-selection hint. + - no root plus no registered stores allows implicit only when requested. +- `test/commands/store-root-selection.test.ts` for CLI end-to-end behavior: + - `new change --store team-context` creates only in the store. + - selected store wins over nearby root. + - `status`, `instructions`, `list`, `show`, `validate`, and `archive` operate + in the selected store. + - human selected-store output writes the root banner to stderr and leaves + `show`/`instructions` stdout clean. + - JSON success payloads include the shared `root` block. + - paths in selected-store output are absolute. + - `--store-path` rejects with register guidance, including + `show --store-path /x`. + - unknown-store resolver errors have matching code/message/exit behavior + across at least two commands. + - invalid store id format fails before registry lookup. + - no-root plus registered stores fails without scaffolding. + - workspace state alone is not a root. + - `validate --all`, archive's interactive picker in human mode, and other + item pickers use the resolved root. + - stderr/stdout purity tests distinguish streams by spawning the built CLI or + by separately stubbing `process.stdout.write` and `process.stderr.write`; + assert `show` stdout starts with the raw Markdown payload. +- `test/commands/artifact-workflow.test.ts` updates: + - `new change --initiative` now rejects and writes no change. + - `new change --areas` rejects and writes no affected-area metadata. + - `new change --goal` still writes ordinary metadata and does not switch schema. +- `test/commands/change-initiative-link.test.ts`: + - delete or rewrite as legacy-read-only coverage. + - Initiative commands can remain tested elsewhere, but normal `new change` and + `set change` linking expectations must be removed. +- `test/commands/completion.test.ts` and + `test/core/completions/command-registry.test.ts`: + - `new change` advertises `--store` as root selection. + - `set change` is absent. + - old initiative wording is absent from normal `new change` completion + metadata. +- `test/commands/context-store.test.ts`: + - setup/register next-step output shows `--store` usage. +- `test/core/archive.test.ts` and command-level archive tests: + - archive can run against an explicit root and JSON payload includes root. + - `archive --json` without a change name fails non-interactively. + - JSON validation/spec-update/task-check failures exit non-zero without prose + on stdout. + +Run order during implementation: + +```bash +pnpm test -- test/core/root-selection.test.ts +pnpm test -- test/commands/store-root-selection.test.ts +pnpm test -- test/commands/artifact-workflow.test.ts +pnpm test -- test/commands/context-store.test.ts +pnpm test -- test/commands/completion.test.ts +pnpm test -- test/commands/validate.test.ts test/commands/show.test.ts +pnpm run build +pnpm test +``` + +## Implementation Checklist + +- [ ] Add shared root selection types, resolver, root JSON helper, and selected + store stderr banner helper. +- [ ] Wrap or update context-store registry errors so normal commands drop + `--store-path` suggestions and unknown stores list registered ids. +- [ ] Add root-aware item discovery helpers for changes, specs, and archived + changes. +- [ ] Update supported CLI command option types and parser wiring. +- [ ] Remove `openspec set change` registration and normal command completion + metadata. +- [ ] Remove `setChangeCommand` exports and implementation if unused. +- [ ] Update `new change` to root selection only, with initiative and areas + rejection before writes, and remove initiative selector assertions that would + reject `--store` without `--initiative`. +- [ ] Update `status` and `instructions` to use the shared resolver and output + root information. +- [ ] Update `list`, including specs JSON output, to use the shared resolver. +- [ ] Update top-level `show` to use the shared resolver while leaving noun-form + commands unchanged. +- [ ] Update top-level `validate`, including bulk and interactive paths, to use + the shared resolver. +- [ ] Update `archive` to support selectors, JSON success and failure output, + non-interactive JSON mode, and selected-root filesystem paths. +- [ ] Update `context-store setup` and `register` next-step output. +- [ ] Decide whether `src/commands/workflow/initiative-link.ts` is still needed + after `new change` and `set change` cleanup; remove orphaned exports only when + no remaining imports use them. +- [ ] Replace initiative-link creation tests with removed-option and legacy-read + tests. +- [ ] Add root-selection resolver and CLI tests from the matrix above. +- [ ] Run targeted tests, then build, then full test suite. + +## Risks And Guardrails + +- Raw stdout pollution is the easiest regression. Keep root banners on stderr and + assert that `show` and `instructions` stdout starts with their normal payload. +- Commander unknown-option behavior can produce generic errors or, for `show`, + silently ignore options because of `allowUnknownOption(true)`. Add deliberate + hidden compatibility options for `--store-path` where needed. +- Bulk validation and interactive pickers are easy to miss because they discover + items before opening files. Make discovery root-aware first. +- Existing `ChangeCommand` and `SpecCommand` are also used by deprecated noun + commands. Avoid changing those constructors in a way that accidentally gives + noun commands `--store` behavior. +- `archive` does validation, spec updates, task checks, and movement. Resolve all + directories up front from the same root to avoid cross-root reads or writes. +- Do not let context-store registry resolution create metadata or repair roots. + Selection is read-only diagnosis plus command execution. + +## Done Definition + +- All supported commands accept `--store ` and act on the selected root. +- `--store-path` rejects deliberately with register guidance. +- No supported command silently ignores `--store`. +- Without `--store`, nearest-root behavior remains, workspace state no longer + wins, and no-root-with-registered-stores fails with a clear hint. +- `new change` creates no initiative metadata, rejects old initiative options, + and handles `--goal`/`--areas` per the spec. +- `openspec set change` is not registered, not in help, and not in completion + metadata. +- JSON success payloads include the shared root block. +- JSON-mode resolver and archive-blocked failures are non-interactive, + non-zero, and do not pollute stdout with human prose. +- Human selected-store output names the root on stderr without changing raw + stdout payloads. +- Tests cover the acceptance scenarios in `spec.md`. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/spec.md new file mode 100644 index 000000000..a1de46280 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/spec.md @@ -0,0 +1,389 @@ +# Store Root Selection For Normal Commands Spec + +## Outcome + +Normal OpenSpec commands can act on a registered standalone OpenSpec root +selected by name: + +```bash +openspec new change add-billing --store team-context +``` + +Selecting a store resolves to an ordinary OpenSpec root. Everything downstream +behaves exactly as if the command had been run from inside that root: the same +`openspec/specs/`, `openspec/changes/`, and `openspec/changes/archive/` files, +the same schema, the same lifecycle. + +This slice also retires initiative-link creation from normal change flows +(Phase 2.1 pulled forward), so `--store` has exactly one meaning: which +OpenSpec root should this command use. + +## Locked Decisions (2026-06-10) + +1. **`--store` means root selection, and only that.** The old initiative + meaning of `--store` / `--store-path` on `new change` and `set change` is + removed in this slice. New changes do not create initiative links. + Initiative linking was `set change`'s only behavior, so `openspec set + change` is removed rather than kept as a deprecated stub or empty shell. +2. **`--store ` (registry lookup) is the only selector.** `--store-path` + is deferred. Registering a clone is the answer for path access; the path + form can be added later if someone actually hits the wall. +3. **Leftover workspace state never wins root resolution on this path.** The + workspace branch of the resolver is demoted during this slice's resolver + rework instead of waiting for Phase 2.3/5.1. +4. **No silent implicit-root scaffold when stores are registered.** When the + current directory has no OpenSpec root and registered stores exist, the + command errors with a hint naming the registered stores instead of + scaffolding a new local root. When no stores are registered, current + behavior is unchanged. + +## User Experience + +A human stays in the project repo they are working on and tells their agent +where the work lives. The agent discovers registered stores and selects one by +name: + +```bash +openspec context-store list --json +openspec new change add-billing --store team-context +openspec status --change add-billing --store team-context +openspec instructions proposal --change add-billing --store team-context +openspec archive add-billing --store team-context +``` + +When a store is selected, every supported command emits a human-visible +verification signal so the human can verify the work landed in the right repo +without watching the CLI run. In human mode, this signal is written to stderr so +commands whose stdout is raw Markdown or agent-consumed instructions keep their +normal stdout payload: + +```text +Using OpenSpec root: team-context (/Users/alice/src/team-context) +``` + +Without `--store`, commands keep using the nearest OpenSpec root when one +exists, including when the user is working inside the standalone repo itself. +The flag is never required; it is how you reach a root you are not standing in. +This slice intentionally changes only two legacy no-flag cases: leftover +workspace view state no longer wins root resolution, and a no-root directory +with registered stores errors with a store-selection hint instead of silently +scaffolding a new local root. + +## Scope + +In scope: + +- `--store ` on `new change`, `status`, `instructions`, `list`, `show`, + `validate`, and `archive`, with identical semantics on each. +- One shared OpenSpec-root resolver behind those commands, replacing the + per-command `cwd + openspec/changes` path joins. +- Resolved-root reporting in human stderr and JSON output for those commands. +- `--json` on `archive` (it has none today), so the shared root block is + uniform across the command set. +- Minimal `list --specs --json` support so specs listing also participates in + the shared root reporting contract. +- A deliberate `--store-path` rejection that points to + `context-store register`; a generic unknown-option error is not enough. +- Absolute paths in command output whenever a store is selected. +- Clear errors: unknown store id lists registered ids; unhealthy store root + points to `context-store doctor`. +- Consistent resolver errors across supported commands: same resolver error + code, same user-facing message, and non-zero exit, even if existing + command-specific JSON envelopes remain different. +- The no-root-plus-registered-stores error and hint. +- Demoting leftover workspace view state in root resolution for these + commands. +- Removing initiative-link creation (and the old initiative meanings of + `--store` / `--store-path`) from `new change`. +- Removing `openspec set change` from the CLI, help, completions metadata, + workflow exports if unused, and command tests/docs references. No deprecation + stub is kept because initiative linking was its only behavior. +- Clarifying workspace-era `new change` options: `--goal` remains ordinary + optional change metadata and never affects root selection, while `--areas` is + rejected because affected workspace links only made sense for workspace-scoped + planning. +- Next-steps output from `context-store setup` and `register` that shows + `--store` usage (depends on slice 1.1, `store-root-parity`, being merged). +- Help text for the supported commands describing `--store` consistently. +- Tests that cover the scenarios in this spec. + +Out of scope: + +- `--store-path` or any path-addressed selection (deferred). +- A default or sticky store per project repo, env vars, or any durable + app-repo-to-store binding (revisit with Phase 3 local repo mapping). +- Target repo declarations or local repo mapping (Phase 3). +- Opening views or workspace opening behavior (Phase 4). +- Clone, pull, push, sync, branch, worktree, dashboard, apply, verify, or + archive orchestration. +- Broad deletion of initiative/workspace systems, commands, code, or existing + user data; this slice only removes the normal-flow surfaces called out above + and leaves existing legacy data alone. +- Updating generated agent skills and guidance to mention `--store` (tracked + separately; do not forget it). +- Deprecated noun-form commands (`openspec change show`, `openspec spec + show`, and similar): they keep their current cwd-based behavior and do not + gain `--store`. +- Public docs rewrites or `context-store` terminology renaming (L7). + +## Acceptance Criteria + +### Selecting A Registered Store By Id + +`--store ` resolves the id through the local registry to the store's +OpenSpec root and runs the command against that root. + +#### Scenario: Creating A Change In A Selected Store + +- **GIVEN** a registered context store `team-context` with a healthy OpenSpec + root +- **AND** the current directory is a project repo without its own `openspec/` + root +- **WHEN** the user runs `openspec new change add-billing --store team-context` +- **THEN** OpenSpec creates `openspec/changes/add-billing/` inside the + `team-context` store root +- **AND** OpenSpec writes no OpenSpec artifacts under the current directory +- **AND** the output names the resolved root id and absolute path + +#### Scenario: Reading And Archiving In A Selected Store + +- **GIVEN** the `team-context` store contains the change `add-billing` +- **AND** the current directory is a project repo +- **WHEN** the user runs `list`, `show`, `status`, `validate`, and `archive` + with `--store team-context` +- **THEN** each command reads the store's `openspec/changes/` and + `openspec/specs/` +- **AND** `archive` moves the change into the store's + `openspec/changes/archive/` +- **AND** no OpenSpec artifacts under the current directory are read or + written + +#### Scenario: Explicit Selection Wins Over The Nearest Root + +- **GIVEN** the current directory is inside a repo that has its own + `openspec/` root +- **WHEN** the user runs a supported command with `--store team-context` +- **THEN** OpenSpec uses the `team-context` store root +- **AND** OpenSpec does not read or write the nearby local root + +#### Scenario: Rejecting An Unknown Store Id + +- **GIVEN** `team-context` is the only registered store +- **WHEN** the user runs a supported command with `--store team-contxt` +- **THEN** OpenSpec fails with an error naming the unknown id +- **AND** the error lists the registered store ids +- **AND** OpenSpec creates no files + +#### Scenario: Rejecting An Unhealthy Store Root + +- **GIVEN** a registered store whose OpenSpec root is missing or incomplete +- **WHEN** the user runs a supported command with `--store` for that id +- **THEN** OpenSpec fails with an error describing the root problem +- **AND** the error points to `context-store doctor` +- **AND** OpenSpec does not scaffold or repair the store root + +#### Scenario: Rejecting A Mismatched Store Identity + +- **GIVEN** a registered store whose `.openspec-store/store.yaml` id does not + match its registry id +- **WHEN** the user runs a supported command with `--store` for that id +- **THEN** OpenSpec fails with an error describing the identity mismatch +- **AND** the error points to `context-store doctor` + +#### Scenario: Path Selection Is Not Available + +- **WHEN** the user passes `--store-path` to a supported command +- **THEN** OpenSpec rejects the option +- **AND** guidance points to `context-store register` plus `--store ` +- **AND** no supported command silently ignores it, including commands that + otherwise allow unknown options for legacy parsing + +### Default Resolution Without --store + +Without `--store`, commands resolve the nearest OpenSpec root exactly as a +user standing in that directory would expect. + +#### Scenario: Working Inside A Project Repo + +- **GIVEN** the current directory is inside a repo with an `openspec/` root +- **WHEN** the user runs a supported command without `--store` +- **THEN** OpenSpec uses the nearest `openspec/` root, unchanged from today + +#### Scenario: Working Inside The Standalone Repo Itself + +- **GIVEN** the current directory is inside a registered store's root +- **WHEN** the user runs a supported command without `--store` +- **THEN** OpenSpec uses that root as a normal OpenSpec root +- **AND** no flag is required + +#### Scenario: No Root Anywhere And No Registered Stores + +- **GIVEN** no ancestor directory contains an `openspec/` root +- **AND** no context stores are registered on this machine +- **WHEN** the user runs a supported command +- **THEN** each command behaves exactly as it does today, even where that + behavior differs between commands (for example, `new change` treats the + current directory as an implicit root, while `list` and `archive` fail and + point to `openspec init`) +- **AND** this slice does not normalize those per-command behaviors + +#### Scenario: No Root Here But Stores Are Registered + +- **GIVEN** no ancestor directory contains an `openspec/` root +- **AND** at least one context store is registered on this machine +- **WHEN** the user runs a supported command without `--store` +- **THEN** OpenSpec fails without scaffolding a new local root +- **AND** the error names the registered store ids +- **AND** the error suggests `--store ` or `openspec init` + +### Old Workspace State Never Wins + +Leftover workspace view state does not decide where these commands act. + +#### Scenario: Ignoring Workspace State Next To A Repo Root + +- **GIVEN** an ancestor directory contains leftover + `.openspec-workspace-view.yaml` state +- **AND** the current directory is inside a repo with an `openspec/` root +- **WHEN** the user runs a supported command without `--store` +- **THEN** OpenSpec uses the nearest `openspec/` root +- **AND** OpenSpec does not route to a workspace-owned changes directory +- **AND** OpenSpec does not switch to the workspace-planning schema + +#### Scenario: Ignoring Workspace State When A Store Is Selected + +- **GIVEN** an ancestor directory contains leftover workspace view state +- **WHEN** the user runs a supported command with `--store team-context` +- **THEN** OpenSpec uses the `team-context` store root + +#### Scenario: Workspace State Alone Is Not A Root + +- **GIVEN** an ancestor directory contains leftover workspace view state +- **AND** no ancestor directory contains an `openspec/` root +- **WHEN** the user runs a supported command without `--store` +- **THEN** OpenSpec treats the directory as having no OpenSpec root +- **AND** "No Root Anywhere And No Registered Stores" or "No Root Here But + Stores Are Registered" applies, depending on whether stores are registered + +#### Scenario: Workspace-Scoped Areas Are Rejected + +- **WHEN** the user runs `openspec new change add-billing --areas api` +- **THEN** OpenSpec rejects `--areas` +- **AND** OpenSpec does not switch to the workspace-planning schema +- **AND** OpenSpec does not create affected workspace-link metadata + +#### Scenario: Goal Metadata Does Not Select Workspace Planning + +- **WHEN** the user runs `openspec new change add-billing --goal "Improve billing"` +- **THEN** OpenSpec uses the same root resolution it would use without `--goal` +- **AND** `--goal` may write the existing change goal metadata +- **AND** OpenSpec does not create workspace-owned planning state + +### Initiative Links Are Retired From Normal Change Flows + +Phase 2.1, pulled forward: normal change creation stops attaching work to +initiatives. + +#### Scenario: New Changes Create No Initiative Metadata + +- **WHEN** `new change` completes, with or without `--store` +- **THEN** OpenSpec creates no initiative link or initiative metadata + +#### Scenario: Old Initiative Options Are Gone + +- **WHEN** the user passes `--initiative` to `new change` +- **THEN** OpenSpec rejects the option +- **AND** `--store` is documented as root selection only + +#### Scenario: Set Change Is Removed + +- **WHEN** the user runs `openspec set change` or `openspec set change --help` +- **THEN** the command is no longer available +- **AND** OpenSpec does not print deprecated command guidance for initiative + linking +- **AND** OpenSpec creates or modifies no files +- **AND** initiative linking was its only behavior, so no replacement is + provided in this slice + +#### Scenario: Existing Initiative Metadata Is Left Alone + +- **GIVEN** existing changes carry initiative metadata from the beta +- **WHEN** supported commands read or list those changes +- **THEN** OpenSpec does not modify or delete that metadata in this slice + +### Every Supported Command Reports Its Root + +The human's verification signal is the output, not the command line. + +#### Scenario: Human Output Names The Root + +- **WHEN** a supported command runs with `--store` in human mode +- **THEN** stderr includes the resolved store id and the absolute root path +- **AND** stdout remains the command's normal payload, so raw Markdown from + `show` and agent-consumed text from `instructions` are not prefixed or + injected with the root banner +- **AND** without `--store`, human output is unchanged from today + +#### Scenario: JSON Output Names The Root + +- **WHEN** a supported command succeeds with `--json` +- **THEN** the JSON output includes one shared root block with the same field + names and shape on every supported command, for example: + +```json +{ + "root": { + "path": "/abs/path", + "source": "store", + "store_id": "team-context" + } +} +``` + +- **AND** `source` is one of `store`, `nearest`, or `implicit` +- **AND** `store_id` is present only when a store was selected +- **AND** `implicit` is used only for preserved no-store behavior where a + command is allowed to treat the current directory as an implicit OpenSpec root +- **AND** `list --specs --json` emits JSON rather than human text so it can + include the shared root block +- **AND** existing JSON fields keep their current shapes; the root block is + additive + +#### Scenario: JSON Archive Is Non-Interactive + +- **WHEN** the user runs `archive --json` +- **THEN** OpenSpec never opens an interactive picker or confirmation prompt +- **AND** if a change id or confirmation is required, OpenSpec fails + non-interactively with a machine-readable diagnostic and a non-zero exit +- **AND** JSON-mode archive failures such as validation failure, + incomplete-task refusal, and spec-update abort do not print human prose or + blank lines to stdout + +#### Scenario: Cross-Root Paths Are Absolute + +- **GIVEN** a supported command runs with `--store` +- **WHEN** the output references files in the store +- **THEN** those paths are absolute, never relative to the current directory + +### The Command Set Behaves Consistently + +#### Scenario: Uniform Flag Semantics + +- **WHEN** any supported command (`new change`, `status`, `instructions`, + `list`, `show`, `validate`, `archive`) receives `--store` +- **THEN** selection, errors, and root reporting behave identically across + commands +- **AND** resolver failures use the same error code, message text, and exit + behavior across commands, even if command-specific JSON envelopes are + preserved +- **AND** no supported command silently ignores the flag +- **AND** bulk and interactive modes (`validate --all`, item pickers, and + similar) discover and operate on items within the resolved root + +### Setup Points To The Next Step + +#### Scenario: Setup And Register Show Store Usage + +- **WHEN** `context-store setup` or `context-store register` succeeds +- **THEN** the next-steps output shows running a normal command with + `--store ` From b8eec8378b3e3fad34442a094ad936940b0db58e Mon Sep 17 00:00:00 2001 From: TabishB Date: Thu, 11 Jun 2026 05:15:37 +1000 Subject: [PATCH 010/111] Record store-lifecycle-proof slice artifacts and roadmap progress Spec and plan for slice 1.3 (prove the standalone repo lifecycle end to end), with two review rounds folded in. Adds slice 1.4 to the roadmap, parks archive browsability as L11, and records the single-branch workflow for the whole roadmap. --- .../roadmap.md | 165 ++++++- .../slices/store-lifecycle-proof/plan.md | 443 ++++++++++++++++++ .../slices/store-lifecycle-proof/spec.md | 383 +++++++++++++++ 3 files changed, 973 insertions(+), 18 deletions(-) create mode 100644 openspec/work/simplify-context-and-workspace-model/slices/store-lifecycle-proof/plan.md create mode 100644 openspec/work/simplify-context-and-workspace-model/slices/store-lifecycle-proof/spec.md diff --git a/openspec/work/simplify-context-and-workspace-model/roadmap.md b/openspec/work/simplify-context-and-workspace-model/roadmap.md index 3bb0c869d..5d946eb1d 100644 --- a/openspec/work/simplify-context-and-workspace-model/roadmap.md +++ b/openspec/work/simplify-context-and-workspace-model/roadmap.md @@ -74,6 +74,12 @@ workspace-owned planning, or collection state as the main model. Use this as the quick "where are we?" view. +Working branch: all roadmap implementation happens on the single +`codex/store-root-parity` branch (PR #1190), with each slice stacked on the +previous ones. Merge to `main` is deferred until the work is ready to land +as a whole; the "Merged to `main`" checkboxes in each slice stay open until +then and do not gate the next slice. + Numbered labels are roadmap work item ids. Smaller `Progress` checkboxes inside an item are status steps for that numbered work item. @@ -81,8 +87,9 @@ an item are status steps for that numbered work item. Old beta plans were marked as history, and this `/work` roadmap became the active direction. - [ ] **Phase 1. Make a standalone OpenSpec repo useful.** - Slices 1.1 and 1.2 have branch implementations and passing tests; the phase - still needs merge to `main` and the end-to-end lifecycle proof. + Slices 1.1 and 1.2 have branch implementations and passing tests; merge to + `main` remains. Slice 1.3 (lifecycle proof) has a spec; slice 1.4 (agent + and help-surface discoverability) is queued behind it. - [ ] **Phase 2. Stop putting new work through initiatives.** Item 2.1 was pulled forward into slice 1.2 and implemented there; the rest is not started. @@ -196,7 +203,10 @@ Phase checklist: Implemented, tested, and review follow-up fixed on `codex/store-root-selection`; merge remains. - [ ] **1.3** Prove the standalone repo lifecycle end to end. - Do this after 1.1 and 1.2 are merged. + Spec and plan written 2026-06-11; implements on `codex/store-root-parity` + on top of 1.1 and 1.2. +- [ ] **1.4** Teach agents and help surfaces that stores exist. + Queued behind 1.3; carries the deferred guidance debt from slice 1.2. ### 1.1 Create Or Register A Standalone OpenSpec Repo @@ -335,45 +345,127 @@ How the user or agent knows it worked: Progress: -- [ ] Spec written. -- [ ] Plan written. +- [x] Spec written. +- [x] Plan written. - [ ] Smoke flow implemented. - [ ] Tests pass. - [ ] Merged to `main`. +Slice: `slices/store-lifecycle-proof/spec.md` + Plain-English version: ```text Show that a registered standalone OpenSpec repo can do the same basic lifecycle -as an OpenSpec root inside a project repo. +as an OpenSpec root inside a project repo — including cloning it and continuing +the work from a second checkout. ``` What the user can do: -- Set up or register a standalone OpenSpec repo. -- Create a change there. -- Inspect the change. -- Get instructions. -- Validate it. -- Archive it when done. +- Set up a standalone OpenSpec repo that is a real Git repo (initialized, with + an initial commit) at a path they chose. +- Create, inspect, validate, and archive a change there from their project + repo. +- Commit and push the store themselves, clone it on another machine, register + the clone, and continue the work. +- Ask doctor whether the store repo has commits, uncommitted changes, or a + remote. Why it matters: - This proves standalone OpenSpec repos are not just setup plumbing. +- The sharing path (clone, register, continue) is the reason standalone repos + exist, and it is where the hands-on walk on 2026-06-11 found the real gaps. - It catches missing command support before more features are built on top. +Decisions locked on 2026-06-11 (details in the slice spec): + +- The proof is a two-checkout journey test in the existing CLI e2e harness, + not a solo-machine smoke or a separate script harness. +- Setup finishes what it starts: Git on by default, an initial commit of + exactly the files setup created, and a user-chosen location (`--path` + required non-interactively; interactive runs prompt with a visible path + suggestion). Tracked placeholder files keep otherwise-empty store + directories alive in clones, and setup checks for a usable Git commit + identity up front instead of failing mid-operation or inventing one. +- The Git line is create-time and read-only: setup may init and commit once; + doctor reports commits/dirty/remote facts read-only; register never + commits; nothing clones, pulls, pushes, branches, or syncs. +- The loop never drops the thread: selected-store hints carry `--store `, + the root banner prints on post-resolution failures, `new change` names the + next command, and `status` drops the workspace-era "Planning home" line. +- Register errors become terminal instead of circular, with the + one-checkout-per-id rule and `unregister` as the named escape hatch. +- `view` is explicitly out of this slice; opening things together is Phase 4. + What changes in commands or files: -- Add a clean fixture or smoke flow for a registered standalone OpenSpec repo. -- Cover setup/register, list, doctor, root selection, change creation, status, - instructions, list/show, validate, archive, and view where relevant. +- `context-store setup` Git and location defaults, plus sharing next-steps. +- Read-only Git facts in `context-store doctor` output. +- Reworked register error messages. +- Hint/banner continuity across the slice 1.2 command set. +- One chained two-checkout journey test covering setup/register, list, + doctor, root selection, change creation, status, instructions, list/show, + validate, and archive. How the user or agent knows it worked: -- The smoke passes without using old initiative collections or workspace-owned - planning state. +- The journey passes against the built CLI with isolated global state, + without using old initiative collections or workspace-owned planning state. +- A clone of a freshly set-up store is immediately a healthy OpenSpec root. - The final files are normal `openspec/specs/`, `openspec/changes/`, and - `openspec/changes/archive/` files in the standalone repo. + `openspec/changes/archive/` files in both checkouts. + +### 1.4 Teach Agents And Help Surfaces That Stores Exist + +Progress: + +- [ ] Spec written. +- [ ] Plan written. +- [ ] Implementation done. +- [ ] Tests pass. +- [ ] Merged to `main`. + +Plain-English version: + +```text +An agent prompted in a project repo can discover the registered standalone +OpenSpec repo and use it without the human spelling out flags. +``` + +What the user can do: + +- Prompt an agent with "create a change for X in our team store" and have the + agent find the registered store and use `--store` on its own. +- Read top-level help and recognize the context-store commands as the + standalone OpenSpec repo feature. + +Why it matters: + +- Prompts are the primary interface. Slice 1.2 shipped `--store`, but + generated agent guidance never mentions it, so the feature is invisible in + the product's main surface. +- This is the deferred "update generated agent skills and guidance to mention + `--store`" debt explicitly flagged in slice 1.2 ("do not forget it"). +- Phase 1 is not honestly done while agents cannot discover stores. + +What changes in commands or files: + +- Generated agent guidance and skills explain store discovery + (`context-store list --json`) and `--store ` usage when no local root + exists. +- Top-level and subcommand help one-liners describe context-store commands in + standalone-OpenSpec-repo language. No command or flag renames (L7 stays + parked). +- No command behavior changes. + +How the user or agent knows it worked: + +- A fresh agent session in a project repo with a registered store completes a + store-scoped change from a single prompt, without hand-holding. +- Generated guidance names `--store`; help text matches the model being + shipped. ## Phase 2. Stop Putting New Work Through Initiatives @@ -721,6 +813,10 @@ is working: only if they matter to the simple standalone repo flow. - **L10** Reintroduce initiative-like behavior only as a Git-native work type if it still proves useful later. +- **L11** Make archived changes browsable through commands (for example + `list --archived`) if filesystem and Git history prove insufficient. The + archive command's own confirmation line is the lifecycle's verification + signal for now. ## Roadmap Change Log @@ -758,3 +854,36 @@ is working: defer `--store-path`, demote leftover workspace state during the resolver rework, and replace the silent implicit-root scaffold with an error and hint when registered stores exist. +- 2026-06-11: Walked the standalone-store lifecycle by hand against the + built CLI. The 1.1/1.2 command mechanics held up; the gaps were the + sharing path (commitless setup repos, empty clones, circular register + errors), guidance that drops the selected store, and leftover + workspace-era output language. +- 2026-06-11: Locked the 1.3 decisions and added the store-lifecycle-proof + slice spec: the proof is a two-checkout journey test; setup defaults to + Git with an initial commit and an explicit path; doctor reports read-only + Git facts; register errors become terminal; selected-store hints keep the + store; `view` stays out until Phase 4. +- 2026-06-11: Added slice 1.4 for agent and help-surface store + discoverability (the deferred guidance debt from slice 1.2) and parked + archive browsability as L11. +- 2026-06-11: Folded review findings into the store-lifecycle-proof spec + after reproducing the empty-clone failure against the built CLI: tracked + placeholder files so clones keep empty store directories, an up-front Git + identity check for setup, an explicit interactive location prompt, and an + enumerated second-checkout journey that reads promoted specs instead of + browsing the archive. +- 2026-06-11: Wrote the store-lifecycle-proof plan, grounded in a code map + of the setup/doctor/register internals, the hint and banner sites, and + the CLI e2e harness. +- 2026-06-11: Adopted a single working branch for the whole roadmap: all + slices implement on `codex/store-root-parity` (PR #1190), stacked in + order, with merge to `main` deferred until the work lands as a whole. +- 2026-06-11: Folded plan-review findings into the slice after checking + them against the code: `store.yaml` must be written before setup's + initial commit (today it is written during registration, after Git + init), the commit must be pathspec-limited to preserve the user's + staged index, the identity preflight uses `git var` so env-var identity + counts, converted roots get placeholders at first accept while doctor + warns on clone-fragile empty directories in older stores, and the + journey's `created_files` assertion runs setup in JSON mode. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-lifecycle-proof/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/store-lifecycle-proof/plan.md new file mode 100644 index 000000000..bd531f0c9 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-lifecycle-proof/plan.md @@ -0,0 +1,443 @@ +# Standalone Store Lifecycle Proof Plan + +## Status + +Spec locked 2026-06-11 (including same-day review findings: tracked +placeholders, Git identity preflight, interactive location prompt, and the +enumerated second-checkout journey). Plan drafted 2026-06-11. Implementation +not started. + +This plan implements `spec.md` for slice 1.3. The main product move: + +```text +Setup leaves a real, clonable Git repo, and the proof is a two-checkout +journey against the built CLI. +``` + +## Source Of Truth + +Start from `spec.md`. + +Also keep nearby: + +- `../../goal.md` +- `../../roadmap.md` +- `../store-root-parity/spec.md` (root shape, doctor, setup/register safety) +- `../store-root-selection/spec.md` (selector semantics, root reporting) + +Sequencing: this slice changes setup behavior from slice 1.1 and hint/banner +behavior from slice 1.2, so it must stack on that work. The whole roadmap +is being built on the single `codex/store-root-parity` branch (PR #1190), +whose tip already contains both prerequisite implementations — implement +this slice directly on that branch. Merge to `main` is deferred until the +work lands as a whole; the old `codex/store-root-selection` branch is a +stale ancestor of the tip. + +## User-Facing Frame + +What the human wants: + +- "Set up our planning repo at a path I chose, and have it actually be a + repo — clonable, shareable, no hidden half-made state." +- "When my teammate clones it, register should just work." +- "When something is off, tell me what and how to fix it; don't loop me + between errors." +- "Never strand me: every hint you print should work if I paste it." + +What the agent needs to know: + +- Whether the store repo has commits, uncommitted changes, and a remote + (doctor facts, read-only). +- That following any printed hint preserves the selected store. +- That setup fails before creating anything when Git identity is missing, + with the exact fix. + +How the user knows it worked: + +- A clone of a freshly set-up store registers without ceremony. +- The journey test passes against the built binary with isolated global + state, ending in nothing but normal OpenSpec files. + +## Goals + +- Flip `context-store setup` Git defaults: init on by default, initial + commit of exactly the files setup created, tracked placeholders in + otherwise-empty store directories. +- Require an explicit location: `--path` in non-interactive/JSON mode; an + interactive prompt whose editable suggestion is a user-visible path. +- Preflight Git commit identity before creating anything. +- Add read-only Git facts to doctor (commits, dirty, remote) with a + commitless-repo warning. +- Make register errors terminal and explanatory (one-checkout-per-id rule, + `unregister` escape, named missing root pieces, empty-clone hint). +- Hint and banner continuity: hints carry `--store `, banner prints on + post-resolution failures, `new change` names a next command, `status` + drops the `Planning home` line. +- One chained two-checkout journey test in `test/cli-e2e/`. + +## Non-Goals + +- No clone, pull, push, sync, branch, worktree, or orchestration behavior. + `git init` plus one initial commit at setup is the entire Git write + surface; doctor reporting is read-only. +- No doctor repairs or `--fix`. +- No multi-checkout registration support for one store id per machine. +- No `view` changes (Phase 4), no agent guidance or help one-liners + (slice 1.4), no terminology renames (L7), no archive browsing (L11). +- No retrofit of placeholders into stores created before this slice, and no + change to `openspec init` baseline roots (their clone fragility is an L9 + baseline quirk, out of scope here). +- No public docs rewrites. + +## Current Code Map + +Setup, register, doctor internals: + +- `src/core/context-store/operations.ts` (916 lines) owns setup/register/ + doctor operations. `initGitRepository` (line ~277) runs `git init`; + `input.initGit ?? false` (line ~472) is the default to flip. Today + `.openspec-store/store.yaml` is written inside + `commitContextStoreRegistration` (`writeMetadataIfMissing: true`, + line ~483) — *after* Git init — so the metadata write must be decoupled + and moved before the new commit step, or the initial commit will not + contain `store.yaml` and clones will hit the register conversion prompt. + Register errors live here: `requires an existing healthy OpenSpec root` + (line ~555), metadata id mismatch (line ~569), and `already registered at + this path` (line ~190). Git inspection currently reports only + `isRepository`. +- `src/core/context-store/registry.ts` raises `already registered at + ` (line ~99) with the circular "choose a different context store + id" fix text, and `path is already registered as ''` (line ~110). +- `src/core/context-store/foundation.ts` provides + `getDefaultContextStoreRoot` (XDG data dir + `context-stores/`), used as + the silent default path and the interactive prompt suggestion. +- `src/commands/context-store.ts` (738 lines) is the command surface: + `resolveSetupInput` (line ~287) only errors non-interactively when the + *id* is missing — the path silently defaults; `promptContextStorePath` + (line ~276) already prompts interactively but suggests the XDG data path; + doctor human/JSON mapping (`is_repository`, line ~67/146/474); next-steps + output (line ~424). + +Hint, banner, and status surfaces: + +- `src/core/root-selection.ts` has `emitStoreRootBanner` (line ~300) and + the shared resolver from slice 1.2. Banner emission currently happens on + command success paths; the spec requires it after successful resolution + even when the command then fails. +- `src/commands/workflow/status.ts` prints `Planning home: