diff --git a/WORKSPACE_REIMPLEMENTATION_START_HERE.md b/WORKSPACE_REIMPLEMENTATION_START_HERE.md deleted file mode 100644 index 6f3c8f7e3f..0000000000 --- a/WORKSPACE_REIMPLEMENTATION_START_HERE.md +++ /dev/null @@ -1,68 +0,0 @@ -# Workspace Reimplementation Start Here - -This is the grep-friendly entry point for agents working on the workspace reimplementation. - -Useful search terms: - -```text -workspace reimplementation -workspace poc -workspace-poc -workspace reference guide -workspace roadmap -fresh agent -start here -``` - -## Start Here - -Read these files in order: - -1. `WORKSPACE_REIMPLEMENTATION_DIRECTION.md` -2. `openspec/changes/workspace-reimplementation-roadmap/README.md` -3. `openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md` -4. The proposal for the next implementation slice - -The POC reference commit is: - -```text -workspace-poc @ 79a45ac043f414e63d13e08b9da83b135cb20a39 -``` - -Use the POC as research material. Do not merge it into an implementation branch. Do not preserve its architecture unless a slice proposal or design explicitly decides to do so. - -## Implementation Order - -Implement these flat OpenSpec changes in order: - -1. `workspace-foundation` -2. `workspace-create-and-register-repos` -3. `workspace-open-agent-context` -4. `workspace-change-planning` -5. `workspace-agent-guidance` -6. `workspace-apply-repo-slice` -7. `workspace-verify-and-archive` - -`workspace-reimplementation-roadmap` is the continuity and reference container for the plan. - -## Before Editing - -For the slice you are about to implement, inspect the pinned POC commit using `POC_REFERENCE_GUIDE.md`, then write down: - -```text -POC findings for : - -User behavior to preserve: -- ... - -Tests or examples worth translating: -- ... - -Implementation shortcuts to avoid: -- ... - -Open design questions: -- ... -``` - -Capture durable findings in the relevant OpenSpec artifact so future sessions do not depend on chat history. diff --git a/bin/openspec.js b/bin/openspec.js index 3341bce517..1d6477c19b 100755 --- a/bin/openspec.js +++ b/bin/openspec.js @@ -1,3 +1,5 @@ #!/usr/bin/env node -import '../dist/cli/index.js'; \ No newline at end of file +import { runCli } from '../dist/cli/index.js'; + +runCli(); diff --git a/docs/cli.md b/docs/cli.md index f48d1bc283..73c1b07405 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -7,11 +7,12 @@ The OpenSpec CLI (`openspec`) provides terminal commands for project setup, vali | Category | Commands | Purpose | |----------|----------|---------| | **Setup** | `init`, `update` | Initialize and update OpenSpec in your project | -| **Workspaces (beta)** | `workspace setup`, `workspace list`, `workspace ls`, `workspace link`, `workspace relink`, `workspace doctor`, `workspace update`, `workspace open` | Set up planning across linked repos or folders | +| **Workspaces (beta)** | `workspace setup`, `workspace list`, `workspace ls`, `workspace link`, `workspace relink`, `workspace doctor`, `workspace update`, `workspace open` | Set up local views over linked repos or folders | +| **Shared context (beta)** | `context-store setup`, `context-store register`, `context-store list`, `context-store doctor`, `initiative create`, `initiative show`, `initiative list` | Manage local context-store registrations and durable initiative context | | **Browsing** | `list`, `view`, `show` | Explore changes and specs | | **Validation** | `validate` | Check changes and specs for issues | | **Lifecycle** | `archive` | Finalize completed changes | -| **Workflow** | `status`, `instructions`, `templates`, `schemas` | Artifact-driven workflow support | +| **Workflow** | `new change`, `set 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 | @@ -52,7 +53,13 @@ These commands support `--json` output for programmatic use by AI agents and scr | `openspec workspace link` | Link a repo or folder | `--json` for structured link output | | `openspec workspace relink` | Repair a linked path | `--json` for structured link output | | `openspec workspace doctor` | Check one workspace | `--json` for structured status output | -| `openspec workspace update` | Refresh workspace-local agent skills | `--tools` selects agents; profile selects workflows | +| `openspec workspace update` | Refresh workspace-local guidance and agent skills | `--tools` selects agents; profile selects workflows | +| `openspec context-store list` | Browse registered context stores | `--json` for structured registrations | +| `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 | --- @@ -168,9 +175,9 @@ openspec update ## Workspace Commands -Workspace commands are under active development and are not ready for use yet. Do not build external automation, integrations, or long-lived workflows on top of this command surface; command behavior, state files, and JSON output can change at any point. +Workspace commands are in beta. The local-view model below is the current direction, but external automation, integrations, and long-lived workflows should still treat command behavior, state files, and JSON output as evolving. -Coordination workspaces are planning homes for work that spans multiple repos or folders. Workspace visibility is not change commitment: link the repos or folders OpenSpec should know about, then create changes when you are ready to plan specific work. +Coordination workspaces are machine-local views over linked repos or folders. Workspace visibility is not change commitment: link the repos or folders OpenSpec should know about, then create changes when you are ready to plan specific work. ### `openspec workspace setup` @@ -269,7 +276,7 @@ JSON responses use typed objects plus `status` arrays. Primary data lives in `wo ### `openspec workspace update` -Refresh workspace-local OpenSpec skills from the active global profile. +Refresh workspace-local OpenSpec guidance and agent skills. ```bash openspec workspace update [name] [options] @@ -293,9 +300,9 @@ openspec workspace update --workspace platform --tools codex,claude openspec workspace update --workspace platform --tools none ``` -`workspace update` reuses the stored workspace skill agent selection when `--tools` is omitted. Passing `--tools` replaces that stored selection. It refreshes only OpenSpec-managed workflow skill directories in the workspace root, removes deselected managed workflow skills, and leaves linked repos and folders untouched. +`workspace update` refreshes the generated workspace guidance block and local open surface. For agent skills, it reuses the stored workspace skill agent selection when `--tools` is omitted. Passing `--tools` replaces that stored selection. It refreshes only OpenSpec-managed workflow skill directories in the workspace root, removes deselected managed workflow skills, and leaves linked repos and folders untouched. -Running `openspec update` from inside a workspace planning home redirects to `openspec workspace update`; run `openspec update` inside repo-local projects when you want repo-owned tool files updated. +Running `openspec update` from inside a workspace redirects to `openspec workspace update`; run `openspec update` inside repo-local projects when you want repo-owned tool files updated. ### `openspec workspace open` @@ -310,6 +317,9 @@ openspec workspace open [name] [options] | Option | Description | |--------|-------------| | `--workspace ` | Alias for the positional workspace name | +| `--initiative ` | Open an initiative as a local workspace view. Accepts `` or `/` | +| `--store ` | Registered context store id for `--initiative` | +| `--store-path ` | Existing local context store root for `--initiative` | | `--agent ` | One-session agent override: `codex`, `claude`, or `github-copilot` | | `--editor` | Open the maintained VS Code workspace file as a normal editor workspace | | `--no-interactive` | Disable workspace and opener picker prompts | @@ -322,15 +332,130 @@ openspec workspace open platform openspec workspace open platform --agent github-copilot openspec workspace open --agent codex openspec workspace open --editor +openspec workspace open --initiative billing-launch --store platform +openspec workspace open --initiative platform/billing-launch ``` `workspace open` uses the current workspace when run inside one, auto-selects the only known workspace when run elsewhere, and asks the user to choose when multiple workspaces are known. `--agent` and `--editor` do not change the stored preferred opener. Passing both opener overrides is an error; choose either `--agent ` or `--editor`. +When `--initiative` is used, OpenSpec prepares or selects a private local workspace view for that initiative. Registry-selected stores are stored by id; `--store-path` stores a runtime-local path selector because workspace views are private local state. + OpenSpec maintains `.code-workspace` at the workspace root for VS Code editor and GitHub Copilot-in-VS-Code opens. That file is machine-local and ignored by default with a specific `.code-workspace` `.gitignore` entry, so user-authored `*.code-workspace` files remain eligible for tracking. The maintained VS Code workspace includes the coordination root as `.` plus valid linked repos or folders as additional roots. VS Code displays those entries as a multi-root workspace. -Root workspace open supports exploration and planning across linked repos or folders. Implementation edits should start only after an explicit user request and a normal OpenSpec implementation workflow. +Root workspace open makes linked repos or folders visible for exploration and context. Implementation edits should start only after an explicit user request and a normal OpenSpec implementation workflow. + +--- + +## Shared Context Commands + +Context stores and initiatives are beta coordination surfaces. A context store is a local registration for durable shared context, usually a Git-backed folder or clone. An initiative is shared coordination context inside a context store; repo-local changes can link to it without copying the shared plan into every repo. + +### `openspec context-store setup` + +Create and register a local context store. + +```bash +openspec context-store setup [id] [options] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--path ` | Context store folder path; defaults to `./` | +| `--init-git` | Initialize a Git repository in the context store | +| `--no-init-git` | Do not initialize a Git repository | +| `--json` | Output JSON | + +Examples: + +```bash +openspec context-store setup team-context +openspec context-store setup team-context --path /repos/team-context --no-init-git +openspec context-store setup team-context --json --no-init-git +``` + +### `openspec context-store register` + +Register an existing local context store folder. + +```bash +openspec context-store register [path] [options] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--id ` | Context store id; defaults to store metadata or folder name | +| `--json` | Output JSON | + +### `openspec context-store list` + +List locally registered context stores. + +```bash +openspec context-store list [--json] +openspec context-store ls [--json] +``` + +### `openspec context-store doctor` + +Check local context-store registration, metadata, and Git presence. + +```bash +openspec context-store doctor [id] [--json] +``` + +Doctor is diagnostic-only; it reports missing roots, metadata mismatches, and invalid local registry state without modifying the store. + +### `openspec initiative create` + +Create an initiative in a context store. + +```bash +openspec initiative create --title --summary <summary> [options] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--store <id>` | Context store id from the local registry | +| `--store-path <path>` | Existing local context store root | +| `--title <title>` | Initiative title | +| `--summary <summary>` | Initiative summary | +| `--json` | Output JSON | + +### `openspec initiative list` + +List initiatives. Without a selector, this searches all registered context stores and reports partial-read warnings in `status`. + +```bash +openspec initiative list [options] +openspec initiative ls [options] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--store <id>` | List one registered context store | +| `--store-path <path>` | List one existing local context store root | +| `--json` | Output JSON | + +### `openspec initiative show` + +Resolve an initiative and print its canonical location. + +```bash +openspec initiative show <id> [options] +openspec initiative show <store>/<id> [options] +``` + +Without `--store`, OpenSpec searches registered context stores. If the same initiative id exists in multiple stores, pass `--store <id>` or use the `<store>/<id>` form. --- @@ -578,6 +703,53 @@ openspec archive update-ci-config --skip-specs These commands support the artifact-driven OPSX workflow. They're useful for both humans checking progress and agents determining next steps. +### `openspec new change` + +Create a repo-local change directory and optional checked-in metadata. + +```bash +openspec new change <name> [options] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--description <text>` | Description to add to `README.md` | +| `--goal <text>` | Workspace product goal to store with the change | +| `--areas <names>` | Comma-separated affected workspace link names | +| `--initiative <id>` | Link the repo-local change to an initiative | +| `--store <id>` | Context store id for `--initiative` | +| `--store-path <path>` | Existing local context store root for `--initiative` | +| `--schema <name>` | Workflow schema to use | +| `--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 set change` + +Update checked-in repo-local change metadata without recreating the change. + +```bash +openspec set change <name> [options] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--initiative <id>` | Link the repo-local change to an initiative | +| `--store <id>` | Context store id for `--initiative` | +| `--store-path <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. @@ -993,9 +1165,9 @@ openspec config profile core - Keep current settings (exit) If you keep current settings, no changes are written and no update prompt is shown. -If there are no config changes but the current project or workspace files are out of sync with your global profile/delivery, OpenSpec will show a warning and suggest `openspec update` for repo-local projects or `openspec workspace update` for workspace-local skills. +If there are no config changes but the current project or workspace files are out of sync with your global profile/delivery, OpenSpec will show a warning and suggest `openspec update` for repo-local projects or `openspec workspace update` for workspace-local guidance and skills. Pressing `Ctrl+C` also cancels the flow cleanly (no stack trace) and exits with code `130`. -In the workflow checklist, `[x]` means the workflow is selected in global config. To apply those selections to project files, run `openspec update` (or choose `Apply changes to this project now?` when prompted inside a project). From inside a workspace, use `openspec workspace update` to refresh workspace-local skills; this remains skills-only and does not generate workspace slash commands. +In the workflow checklist, `[x]` means the workflow is selected in global config. To apply those selections to project files, run `openspec update` (or choose `Apply changes to this project now?` when prompted inside a project). From inside a workspace, use `openspec workspace update` to refresh workspace-local guidance and skills; this remains skills-only for generated agent workflow files and does not generate workspace slash commands. **Interactive examples:** diff --git a/docs/concepts.md b/docs/concepts.md index 490e964a4e..4e2a68f7f9 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -51,28 +51,29 @@ This separation is key. You can work on multiple changes in parallel without con ## Coordination Workspaces -Workspace support is under active development and is not ready for use yet. Do not build external automation, integrations, or long-lived workflows on top of workspace behavior; the commands, state files, and JSON output can change at any point. +Workspace support is in beta. The local-view model below is the current direction, but external automation, integrations, and long-lived workflows should still treat command behavior, state files, and JSON output as evolving. -The commands below provide the first setup flow for planning across linked repos or folders. +The commands below provide the first setup flow for opening local views over linked repos or folders. -Repo-local OpenSpec projects are the right default when one repo owns the planning, implementation, and archive flow. Some work spans several repos or folders. For that case, an OpenSpec coordination workspace is the durable planning home. +Repo-local OpenSpec projects are the right default when one repo owns the planning, implementation, and archive flow. Some work spans several repos or folders. For that case, an OpenSpec coordination workspace is a machine-local view that keeps linked paths, opener state, and agent setup together. The workspace mental model is: ```text -workspace = where related cross-repo changes live -link = a stable name for a repo or folder the workspace can plan against -change = one feature, fix, project, or other planned piece of work +workspace = private local view over context stores, initiatives, repos, and folders +context store = durable shared context container +initiative = durable coordination context inside a context store +link = a stable name for a repo or folder the workspace can resolve locally +change = one planned piece of work; implementation belongs in the owning repo ``` A workspace has a different shape from a repo-local project: ```text -workspace-folder/ -├── changes/ # Workspace-level planning -└── .openspec-workspace/ - ├── workspace.yaml # Shared workspace identity and link names - └── local.yaml # This machine's local paths +getGlobalDataDir()/workspaces/<workspace-name>/ +├── workspace.yaml # Private local view record +├── AGENTS.md # Generated runtime guidance +└── <workspace-name>.code-workspace # Generated editor workspace file ``` Repo-local OpenSpec state keeps the existing shape: @@ -84,28 +85,33 @@ repo-root/ └── changes/ ``` -That distinction matters. The workspace folder is a coordination surface for planning across linked repos or folders. Each repo's `openspec/` directory remains the home for repo-owned specs, repo-local changes, and implementation planning. Users do not need to run repo-local `openspec init` inside a workspace folder. +That distinction matters. The workspace folder is a local coordination surface for opening and inspecting linked repos or folders. Each repo's `openspec/` directory remains the home for repo-owned specs, repo-local changes, and implementation planning. Users do not need to run repo-local `openspec init` inside a workspace folder. -Stable link names are how workspace planning refers to repos and folders. The shared workspace state keeps names such as `api`, `web`, or `checkout`; each machine maps those names to its own local paths in `.openspec-workspace/local.yaml`. +Stable link names are how a workspace refers to repos and folders. The private workspace record keeps names such as `api`, `web`, or `checkout` and maps them to this runtime's local paths. ```yaml -# .openspec-workspace/workspace.yaml +# workspace.yaml version: 1 name: platform +context: null links: - api: {} - web: {} -``` - -```yaml -# .openspec-workspace/local.yaml -version: 1 -paths: api: /repos/api web: /repos/web ``` -OpenSpec-created workspaces exclude `.openspec-workspace/local.yaml` from portable collaboration state by default. `.openspec-workspace/workspace.yaml` remains portable because it stores the workspace name and stable link names, not one user's absolute checkout paths. +When a workspace opens an initiative, `context` records the selected context-store binding and initiative id. Registry-selected stores stay portable by id; path-selected stores intentionally preserve the runtime-local path because `workspace.yaml` is private local state. + +```yaml +context: + kind: initiative + store: + id: platform + selector: + kind: registry + id: platform + initiative: + id: billing-launch +``` Linked paths can be full repos, folders inside a large monorepo, or other existing folders. They do not need repo-local `openspec/` state before they can participate in workspace planning. Later implementation, verify, or archive workflows may require more repo readiness, but planning visibility starts with the link. @@ -127,13 +133,7 @@ getGlobalDataDir()/workspaces That means `$XDG_DATA_HOME/openspec/workspaces` when `XDG_DATA_HOME` is set, `~/.local/share/openspec/workspaces` on Unix-style fallback, and `%LOCALAPPDATA%\openspec\workspaces` on native Windows fallback. Native Windows shells, PowerShell, and WSL2 each keep the path strings for the runtime running OpenSpec. This foundation does not translate between `D:\repo`, `/mnt/d/repo`, and UNC WSL paths. -OpenSpec also keeps a machine-local registry at: - -```text -getGlobalDataDir()/workspaces/registry.yaml -``` - -The registry maps workspace names to workspace locations so later global commands can list or select known workspaces from anywhere. It is only an index. Each workspace folder remains authoritative for its own `.openspec-workspace/workspace.yaml` and `.openspec-workspace/local.yaml`, so stale registry records can be reported and repaired without redefining the workspace itself. +OpenSpec can still read older beta workspace roots as compatibility inputs, but managed workspaces now use the root `workspace.yaml` record above. The workspace folder remains authoritative for its own private local view. Workspace visibility is not change commitment. Set up a workspace when OpenSpec should know which repos or folders are relevant; create a change later when you are ready to plan a feature, fix, project, or other piece of work. @@ -160,7 +160,7 @@ openspec workspace relink api-service /new/path/to/api openspec workspace doctor openspec workspace doctor --workspace platform -# Refresh workspace-local agent skills from the active global profile +# Refresh workspace-local guidance and agent skills openspec workspace update openspec workspace update --workspace platform --tools codex,claude @@ -168,17 +168,21 @@ openspec workspace update --workspace platform --tools codex,claude openspec workspace open openspec workspace open platform --agent github-copilot openspec workspace open --editor + +# Open an initiative as a local workspace view +openspec workspace open --initiative billing-launch --store platform +openspec workspace open --initiative billing-launch --store-path /repos/platform-context ``` `workspace setup` always creates the workspace in the standard workspace location, records it in the local registry, shows the workspace location, and requires at least one linked repo or folder. Interactive setup asks for a preferred opener and can install OpenSpec skills for selected agents. Non-interactive setup stores one only when `--opener codex`, `--opener claude`, `--opener github-copilot`, or `--opener editor` is provided. -Workspace skills are installed only in the workspace root. The active global profile selects which workflow skills are generated; `--tools` selects which agents receive them. Workspace setup and update are skills-only in this beta slice, so they do not create slash command files even when global delivery includes commands. Run `openspec workspace update` after changing the global profile to refresh, add, or remove managed workspace-local skill directories without editing linked repos or folders. +Workspace skills are installed only in the workspace root. The active global profile selects which workflow skills are generated; `--tools` selects which agents receive them. Workspace setup and update do not create slash command files even when global delivery includes commands. Run `openspec workspace update` to refresh workspace-local guidance and add, refresh, or remove managed workspace-local skill directories without editing linked repos or folders. OpenSpec also maintains root workspace open files: an OpenSpec-managed guidance block in `AGENTS.md`, a machine-local `<workspace-name>.code-workspace` file for VS Code and GitHub Copilot-in-VS-Code opens, and a specific ignore entry for that maintained `.code-workspace` file. User-authored `*.code-workspace` files remain trackable because the ignore rule targets only the maintained file. The maintained VS Code workspace includes the coordination root as `.` plus valid linked repos or folders as additional roots. VS Code displays those entries as a multi-root workspace. -`workspace open` opens the linked working set with the stored preferred opener unless `--agent <tool>` or `--editor` is passed for that one session. Passing both opener overrides is an error. Root workspace open makes linked repos and folders visible for exploration and planning; implementation starts after the user explicitly asks for implementation work. +`workspace open` opens the linked working set with the stored preferred opener unless `--agent <tool>` or `--editor` is passed for that one session. Passing both opener overrides is an error. Root workspace open makes linked repos and folders visible for exploration and context; implementation starts after the user explicitly asks for implementation work. `workspace link` and `workspace relink` record existing folders only; they do not create, copy, move, initialize, or edit the linked repo or folder. After a successful link or relink, OpenSpec refreshes the managed guidance, VS Code workspace file, and ignore rule. diff --git a/openspec/changes/workspace-agent-guidance/design.md b/openspec/changes/workspace-agent-guidance/design.md deleted file mode 100644 index 7cc2e76225..0000000000 --- a/openspec/changes/workspace-agent-guidance/design.md +++ /dev/null @@ -1,69 +0,0 @@ -## Context - -`workspace-change-planning` deliberately kept workflow skills generic and path-agnostic. That was the right first step: the same skill can now ask the CLI where a change lives and avoid hardcoded `openspec/changes/<id>` assumptions. - -The next problem is intent. The generated skills do not yet behave differently when they are installed into a workspace root. In particular, `openspec-new-change`, `openspec-propose`, and `openspec-ff-change` still create changes with: - -```bash -openspec new change "<name>" -``` - -That works, but it loses the workspace-specific metadata this slice just introduced. It also relies on general schema instructions to teach workspace planning after the change is created, instead of telling the agent how to approach workspace planning up front. - -## Goals / Non-Goals - -**Goals:** -- Give workspace-installed agents explicit workspace planning guidance. -- Keep the guidance layered on top of existing workflow skills instead of creating an unrelated workflow family. -- Teach change-starting skills to use `--goal` for the product goal when creating workspace changes. -- Teach change-starting skills to use `--areas` only for known registered workspace link names. -- Preserve the ability to create a workspace change before all affected areas are known. -- Keep linked repos and folders read-only during planning unless an explicit implementation workflow provides an allowed edit root. - -**Non-Goals:** -- Implement workspace apply, verify, or archive semantics. -- Add workspace slash command generation. -- Require agents to fully infer affected areas before creating a proposal. -- Add another required area manifest outside normal workspace planning artifacts. -- Replace the current `status --json` and `instructions --json` context contract. - -## Decisions - -### Layer Workspace Guidance Onto Existing Skills - -Workspace setup/update should continue selecting normal workflow skills from the active global profile. The workspace-specific part should be an installed guidance layer or generation transform that augments those skills when they are written into a workspace root. - -Alternative considered: create separate `openspec-workspace-*` skills. That would make workspace behavior obvious, but it risks duplicating every workflow and making repo-local and workspace flows diverge too early. - -### Make Change-Starting Skills Workspace-Aware - -The `new`, `propose`, and `ff` workflow skills should detect workspace context before creating a change. In workspace context, they should derive: - -- a kebab-case change name -- a concise product goal for `--goal` -- a list of confident affected areas for `--areas`, using registered workspace link names only - -If areas remain unclear, the skills should omit `--areas`, create the workspace change, and keep the unresolved area question in the proposal/specs/tasks. - -Alternative considered: always omit `--areas` and rely on artifact content. That preserves flexibility but wastes the affected-area metadata and makes status less helpful immediately after creation. - -### Keep Goal Capture Lightweight - -The goal captured by `--goal` should remain lightweight metadata, not a substitute for `proposal.md`. The generated proposal should still explain the goal in normal product language. - -Alternative considered: have `--goal` prefill proposal content. That may be useful later, but this change should first make the agent use the existing flag consistently. - -### Treat Metadata Flags As Workspace-Scoped - -`--areas` is already rejected outside workspace-scoped change creation. `--goal` should either follow that same workspace-scoped rule or the CLI should clearly document any repo-local meaning before keeping it generic. The preferred direction is to make both flags workspace planning metadata so users and skills have one clear mental model. - -### Keep Guards For Unsupported Workspace Workflows - -Apply, verify, archive, sync, and bulk archive should continue inspecting `actionContext`. If workspace status reports no `allowedEditRoots`, skills should stop before implementation edits. This change should improve planning guidance without loosening those safety boundaries. - -## Risks / Trade-offs - -- Skill content can become too conditional -> keep workspace-specific guidance short and action-oriented. -- Agents may over-infer affected areas -> require `--areas` only for confident registered link names. -- `--goal` repo-local behavior may already be observable -> decide whether to reject it outside workspaces or document it before implementation. -- Duplicated instructions across skills can drift -> use a shared helper or generation transform where practical. diff --git a/openspec/changes/workspace-agent-guidance/proposal.md b/openspec/changes/workspace-agent-guidance/proposal.md index d86a2c6177..b9cad3407d 100644 --- a/openspec/changes/workspace-agent-guidance/proposal.md +++ b/openspec/changes/workspace-agent-guidance/proposal.md @@ -1,33 +1,100 @@ ## Why -Workspace change planning can now create a shared planning home and install OpenSpec workflow skills into that home, but the installed skills still behave mostly like repo-local workflow skills. They are path-aware and guarded after a change exists, yet they do not give agents a strong workspace-native operating model before and during planning. +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. -This leaves a gap right after `workspace-change-planning`: an agent opened in a workspace should know how to explore linked repos, create a workspace change with the captured product goal, use known affected areas, and keep linked repos read-only until an explicit implementation workflow selects an allowed edit root. +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. -## What Changes +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: -- Add workspace-native guidance to workspace-local agent skill installation and refresh. -- Teach change-starting workflow skills how to recognize workspace planning context. -- In workspace planning homes, have generated skills pass `--goal` and known `--areas` when creating workspace changes. -- Keep unresolved affected areas visible when the agent cannot determine them confidently. -- Clarify that workspace planning metadata flags are workspace-scoped and should not be treated as generic repo-local change metadata. -- Preserve the existing path-agnostic status/instructions pattern and unsupported-workflow guards. +> I opened the workspace with my agent. The agent should understand where it is, +> what it can safely inspect, and how to help me turn a product goal into a +> workspace proposal. -## Capabilities +Today that handoff is too thin. Workspace-local skills are installed, and the +CLI can create workspace-scoped changes, but agents still mostly behave like +they are in a normal repo-local OpenSpec project. They do not have a clear +workspace-native starting model before change creation. -### New Capabilities +That creates avoidable confusion: -- +- linked repos or folders may look like implementation targets instead of + read-only planning context +- agents may not know which registered link names are valid affected areas +- users may feel pressured to know every affected area before planning starts +- the product goal can be lost between workspace exploration and change + creation +- workspace planning can feel like a separate mode instead of normal OpenSpec + stretched across linked areas -### Modified Capabilities +The principle this change should reinforce is: -- `workspace-links`: Workspace-local skill installation includes workspace-native agent guidance. -- `cli-artifact-workflow`: Generated workflow skills start workspace changes with workspace planning context. -- `change-creation`: Workspace planning metadata flags are treated as workspace-scoped change creation inputs. +> Workspace visibility is not change commitment. -## Impact +Linked repos and folders are available for exploration. Creating a workspace +change captures a planning commitment. Implementation edits still require an +explicit implementation workflow with an allowed edit root. -- Skill template content for workspace setup/update. -- Workspace-local skill generation and update behavior. -- Tests for generated skill content in workspace mode. -- CLI help/docs if flag semantics or workspace skill behavior become clearer to users. +## Goal + +Make workspace-local planning skills give agents a small, reliable operating +model for starting workspace proposals. + +An agent opened in a workspace should be able to: + +1. recognize that it is operating from a workspace planning home +2. inspect registered workspace links as planning context +3. keep linked repos and folders read-only during planning +4. derive a concise workspace change name and product goal from the user request +5. pass known affected areas only when they match registered workspace link names +6. continue even when affected areas are unresolved, keeping those questions + visible in the normal planning artifacts + +This should feel to the user like the ordinary OpenSpec proposal flow, just with +workspace-aware context and safety. + +## Starting Scope + +Start with the smallest useful surface: + +- workspace-local generated skill guidance +- change-starting workflows used from a workspace planning home +- the relationship between user product goals, registered link names, and + workspace change metadata +- guardrails that keep planning separate from implementation edits + +The first implementation should prefer clear agent guidance over new workflow +machinery. If the existing CLI already exposes enough workspace context, the +skills should use it. If it does not, we should identify the missing context +explicitly before adding heavier behavior. + +## Non-Goals + +This change does not need to solve the full workspace lifecycle. + +Out of scope for this slice: + +- workspace apply semantics +- workspace verify or archive semantics +- branch or worktree orchestration +- creating repo-local changes for each affected area +- shared/team coordination repo behavior +- canonical shared-contract ownership flows +- forcing users to finalize all affected areas before creating a proposal + +## Questions To Work Through + +- What exact workspace context should an agent read before creating a change? +- Is the existing workspace/status/doctor output enough, or do we need a clearer + pre-change context command? +- How should generated skills decide when an affected area is confident enough + to pass as `--areas`? +- Should `--goal` be workspace-only metadata, or should repo-local behavior be + documented too? +- Where should unresolved affected-area questions appear so users and agents + continue from the same source of truth? diff --git a/openspec/changes/workspace-agent-guidance/specs/change-creation/spec.md b/openspec/changes/workspace-agent-guidance/specs/change-creation/spec.md deleted file mode 100644 index 74e04c7301..0000000000 --- a/openspec/changes/workspace-agent-guidance/specs/change-creation/spec.md +++ /dev/null @@ -1,15 +0,0 @@ -## ADDED Requirements - -### Requirement: Workspace planning metadata flags -OpenSpec SHALL treat workspace planning metadata flags as inputs for workspace-scoped change creation. - -#### Scenario: Storing a workspace product goal -- **GIVEN** the command runs from an OpenSpec workspace planning home -- **WHEN** the user creates a change with `--goal <text>` -- **THEN** OpenSpec SHALL store the text as workspace change planning metadata -- **AND** it SHALL not treat the metadata value as a replacement for `proposal.md` - -#### Scenario: Rejecting metadata flags with unclear scope -- **WHEN** a metadata flag is intended only for workspace planning -- **THEN** OpenSpec SHALL either reject that flag outside workspace-scoped change creation or document its repo-local behavior explicitly -- **AND** generated workflow skills SHALL follow the documented scope diff --git a/openspec/changes/workspace-agent-guidance/specs/cli-artifact-workflow/spec.md b/openspec/changes/workspace-agent-guidance/specs/cli-artifact-workflow/spec.md deleted file mode 100644 index 0b40dfcb82..0000000000 --- a/openspec/changes/workspace-agent-guidance/specs/cli-artifact-workflow/spec.md +++ /dev/null @@ -1,30 +0,0 @@ -## ADDED Requirements - -### Requirement: Workspace-aware change-starting skills -Generated change-starting workflow skills SHALL create workspace changes with workspace planning context when they are operating from a workspace planning home. - -#### Scenario: Capturing the product goal when starting a workspace change -- **GIVEN** an agent is using a generated change-starting skill from a workspace planning home -- **WHEN** the agent creates a workspace change from the user's product goal -- **THEN** the skill guidance SHALL instruct the agent to pass the concise product goal with `--goal` -- **AND** it SHALL still create or update `proposal.md` as the human-readable planning artifact - -#### Scenario: Passing known affected areas -- **GIVEN** an agent is using a generated change-starting skill from a workspace planning home -- **AND** the agent can identify affected areas that match registered workspace link names -- **WHEN** the agent creates the workspace change -- **THEN** the skill guidance SHALL instruct the agent to pass those link names with `--areas` -- **AND** it SHALL not pass exploratory or uncertain area names as `--areas` - -#### Scenario: Deferring unresolved affected areas -- **GIVEN** an agent is using a generated change-starting skill from a workspace planning home -- **AND** affected areas are unclear -- **WHEN** the agent creates the workspace change -- **THEN** the skill guidance SHALL allow the agent to omit `--areas` -- **AND** it SHALL tell the agent to keep unresolved affected-area questions visible in workspace planning artifacts - -#### Scenario: Preserving repo-local change creation -- **GIVEN** an agent is using a generated change-starting skill from a repo-local planning home -- **WHEN** the agent creates a new change -- **THEN** the skill guidance SHALL preserve normal repo-local change creation behavior -- **AND** it SHALL not instruct the agent to use workspace-only metadata flags for repo-local changes diff --git a/openspec/changes/workspace-agent-guidance/specs/workspace-links/spec.md b/openspec/changes/workspace-agent-guidance/specs/workspace-links/spec.md deleted file mode 100644 index 7af6524a6f..0000000000 --- a/openspec/changes/workspace-agent-guidance/specs/workspace-links/spec.md +++ /dev/null @@ -1,21 +0,0 @@ -## ADDED Requirements - -### Requirement: Workspace-local skill guidance -Workspace-local OpenSpec skills SHALL include guidance that helps agents operate from the workspace planning home. - -#### Scenario: Installing workspace guidance with skills -- **WHEN** workspace setup or workspace update installs OpenSpec skills into a workspace root -- **THEN** the installed skills SHALL tell agents they are operating from a workspace planning home -- **AND** they SHALL describe linked repos and folders as exploration context during planning -- **AND** they SHALL preserve the rule that implementation edits require an explicit implementation workflow and allowed edit root - -#### Scenario: Keeping profile workflow selection -- **GIVEN** global config resolves to a workflow profile -- **WHEN** workspace setup or workspace update installs workspace-local skills -- **THEN** OpenSpec SHALL continue installing the workflows selected by the profile -- **AND** it SHALL layer workspace guidance onto those workflow skills without requiring a separate workspace workflow family - -#### Scenario: Refreshing workspace guidance -- **WHEN** workspace update refreshes existing workspace-local skills -- **THEN** OpenSpec SHALL refresh the workspace guidance along with the selected workflow skill content -- **AND** it SHALL continue removing only known OpenSpec-managed workflow skill directories diff --git a/openspec/changes/workspace-agent-guidance/tasks.md b/openspec/changes/workspace-agent-guidance/tasks.md deleted file mode 100644 index e3d48c3520..0000000000 --- a/openspec/changes/workspace-agent-guidance/tasks.md +++ /dev/null @@ -1,34 +0,0 @@ -## 1. Workspace Guidance Model - -- [ ] 1.1 Decide whether workspace guidance is injected through a generation transform, a small shared template block, or a dedicated workspace guidance skill. -- [ ] 1.2 Keep workspace setup/update installing profile-selected workflow skills rather than creating a separate workspace workflow family. -- [ ] 1.3 Define the workspace-mode guidance agents need before creating a change: inspect links, keep implementation read-only, identify likely affected areas, and preserve unresolved questions. - -## 2. Change-Starting Skill Updates - -- [ ] 2.1 Update `openspec-new-change` skill guidance for workspace planning homes. -- [ ] 2.2 Update `openspec-propose` skill guidance for workspace planning homes. -- [ ] 2.3 Update `openspec-ff-change` skill guidance for workspace planning homes. -- [ ] 2.4 In workspace mode, instruct agents to pass `--goal "<product goal>"` when creating the change. -- [ ] 2.5 In workspace mode, instruct agents to pass `--areas <names>` only for known registered workspace link names. -- [ ] 2.6 In workspace mode, instruct agents to omit `--areas` and record unresolved area questions in artifacts when areas are unclear. - -## 3. Flag Semantics - -- [ ] 3.1 Decide whether `--goal` should be rejected outside workspace-scoped change creation or explicitly documented for repo-local changes. -- [ ] 3.2 Align CLI help, tests, and generated skill instructions with the chosen `--goal` semantics. -- [ ] 3.3 Add tests for `--goal` and `--areas` behavior from workspace and repo-local planning homes. - -## 4. Workspace Skill Verification - -- [ ] 4.1 Add tests that workspace setup writes skills with workspace-native planning guidance. -- [ ] 4.2 Add tests that workspace update refreshes the workspace-native guidance. -- [ ] 4.3 Add tests that generated change-starting skills include the `--goal` / `--areas` workspace creation path. -- [ ] 4.4 Verify unsupported workspace workflows still guard against repo-local fallback edits. - -## 5. Documentation And Review - -- [ ] 5.1 Update CLI/docs text where users need to understand workspace-local skill behavior. -- [ ] 5.2 Run targeted tests for skill generation, workspace setup/update, and artifact workflow templates. -- [ ] 5.3 Run `openspec validate workspace-agent-guidance --strict`. -- [ ] 5.4 Manually inspect generated workspace-local skills from a clean workspace and record the observed guidance. diff --git a/openspec/changes/workspace-apply-repo-slice/proposal.md b/openspec/changes/workspace-apply-repo-slice/proposal.md index d9ebce47a5..3b98089d64 100644 --- a/openspec/changes/workspace-apply-repo-slice/proposal.md +++ b/openspec/changes/workspace-apply-repo-slice/proposal.md @@ -1,5 +1,15 @@ ## 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. + +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. + After a workspace proposal exists, users need a practical way to implement one repo slice at a time. In the proper workspace model, apply means implementation: diff --git a/WORKSPACE_REIMPLEMENTATION_DIRECTION.md b/openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md similarity index 85% rename from WORKSPACE_REIMPLEMENTATION_DIRECTION.md rename to openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md index 48762a4a93..d6319d293d 100644 --- a/WORKSPACE_REIMPLEMENTATION_DIRECTION.md +++ b/openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md @@ -2,10 +2,40 @@ Date: 2026-04-30 -Fresh-agent entry point: read `WORKSPACE_REIMPLEMENTATION_START_HERE.md` first, then return to this document for the full product direction. +## Status + +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. + +It no longer defines the durable coordination model. The current authority is +`openspec/initiatives/context-store-and-initiatives/direction.md`, which locks +this boundary: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +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. + +Fresh-agent entry point: read `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` first, then return to this document for the full product direction. This document captures the intended direction for reimplementing OpenSpec workspace support from scratch, based on what we learned from the workspace POC. +The sections below are historical POC follow-up direction. Use them for lessons +and preserved local-view behavior only. Do not treat later workspace lifecycle +sections as active implementation guidance. + The reimplementation should be ordered around the path a real user takes through OpenSpec: ```text @@ -443,14 +473,16 @@ Do not start with: Those may matter later, but they should not define the first reimplementation path. -## Product Shape +## Historical Product Shape -The workspace should feel like OpenSpec's normal workflow stretched across multiple repos, not a second product with its own lifecycle. +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. -The durable product model is: +The historical durable product model was: ```text -workspace = durable planning home +workspace = planning home links = repos or folders visible for planning proposal = scoped planning commitment repo slice = one affected repo or folder in the plan @@ -458,7 +490,16 @@ branch/worktree = implementation checkout /apply = implement one selected repo slice ``` -Keep the user journey simple: +The current durable product model is: + +```text +context store = synced shared truth +initiative = durable coordination object +workspace = local opened view +repo change = repo-owned implementation plan +``` + +The historical user journey was: ```text Open the workspace. diff --git a/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md b/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md index f953a755fa..5a3ed6836d 100644 --- a/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md +++ b/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md @@ -2,9 +2,16 @@ This guide is for a fresh agent starting a new session with no prior context about the workspace POC. -Root entry point: `WORKSPACE_REIMPLEMENTATION_START_HERE.md`. +Root entry point: `START_HERE.md`. -The goal is not to continue the POC. The goal is to use it as research material before reimplementing workspace support cleanly from the current base. +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. + +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. ## Reference Point diff --git a/openspec/changes/workspace-reimplementation-roadmap/README.md b/openspec/changes/workspace-reimplementation-roadmap/README.md index 3708e41034..65716de540 100644 --- a/openspec/changes/workspace-reimplementation-roadmap/README.md +++ b/openspec/changes/workspace-reimplementation-roadmap/README.md @@ -2,9 +2,38 @@ This change is the continuity layer for reimplementing workspace support across multiple sessions and branches. -Root entry point for fresh agents: `WORKSPACE_REIMPLEMENTATION_START_HERE.md`. +## Current Status -The user journey we are implementing is: +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. + +Keep: + +- workspace setup, link, relink, list, open, update, and doctor +- linked repos and folders as local planning context +- workspace-local skills as local agent guidance +- the POC as research material only + +Supersede: + +- workspace as the durable shared planning home +- workspace-level planning artifacts as the canonical cross-repo plan +- workspace change planning as the long-term source of truth + +Defer: + +- workspace apply, verify, and archive as first-class lifecycle commands +- 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. + +Root entry point for fresh agents: `START_HERE.md`. + +The user journey this historical roadmap was implementing is: ```text create workspace @@ -21,13 +50,13 @@ 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 copied at the repository root as `WORKSPACE_REIMPLEMENTATION_DIRECTION.md`. +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`. 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. -## Change Order +## Historical Change Order -Implement the flat sibling changes in this order: +The original flat sibling changes were: 1. `workspace-foundation` 2. `workspace-create-and-register-repos` @@ -37,7 +66,7 @@ Implement the flat sibling changes in this order: 6. `workspace-apply-repo-slice` 7. `workspace-verify-and-archive` -OpenSpec currently discovers active changes as immediate directories under `openspec/changes/`, and change names are kebab-case identifiers. Keep these changes as flat siblings until formal change-stacking metadata is available. +OpenSpec currently discovers active changes as immediate directories under `openspec/changes/`, and change names are kebab-case identifiers. These changes remain useful reference artifacts, but they are no longer a direct implementation queue. ## Dependency Notes @@ -47,26 +76,30 @@ OpenSpec currently discovers active changes as immediate directories under `open `workspace-open-agent-context` gives the agent the workspace location, linked repos or folders, active changes, and selected change scope. -`workspace-change-planning` creates the workspace-level planning commitment and identifies target repo slices. +`workspace-change-planning` created the beta workspace-level planning commitment and identified target repo slices. Under the initiative direction, this model is legacy or transitional rather than the durable shared plan. `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` treats apply as implementation of one selected repo slice, not materialization of workspace planning files. +`workspace-apply-repo-slice` is deferred until initiative-linked repo-local changes define the implementation handoff. -`workspace-verify-and-archive` makes cross-repo progress visible and separates partial repo completion from final workspace completion. +`workspace-verify-and-archive` is deferred until initiative status and linked repo-local change lifecycle exist. ## Session Handoff Prompt Use this prompt at the start of future implementation sessions: ```text -Continue the workspace reimplementation roadmap. Read -openspec/changes/workspace-reimplementation-roadmap/README.md and -openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md -first, then pick up the next unfinished flat sibling change in order. Use -workspace-poc at 79a45ac043f414e63d13e08b9da83b135cb20a39 as reference -material only. Preserve intended behavior, but reimplement cleanly from the -current base. Before editing, summarize the POC findings for the slice. +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 +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. ``` ## Branching Guidance diff --git a/openspec/changes/workspace-reimplementation-roadmap/START_HERE.md b/openspec/changes/workspace-reimplementation-roadmap/START_HERE.md new file mode 100644 index 0000000000..9ffedc440a --- /dev/null +++ b/openspec/changes/workspace-reimplementation-roadmap/START_HERE.md @@ -0,0 +1,105 @@ +# Workspace Reimplementation Start Here + +This is the grep-friendly historical entry point for agents working on the +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. + +Current product authority lives in: + +1. `openspec/initiatives/context-store-and-initiatives/direction.md` +2. `openspec/initiatives/context-store-and-initiatives/roadmap.md` + +The locked boundary is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +Useful search terms: + +```text +workspace reimplementation +workspace poc +workspace-poc +workspace reference guide +workspace roadmap +fresh agent +start here +``` + +## Start Here + +Read these files in order: + +1. `openspec/initiatives/context-store-and-initiatives/direction.md` +2. `openspec/initiatives/context-store-and-initiatives/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` + +The POC reference commit is: + +```text +workspace-poc @ 79a45ac043f414e63d13e08b9da83b135cb20a39 +``` + +Use the POC as research material. Do not merge it into an implementation branch. +Do not preserve its architecture unless a later initiative or repo-local change +design explicitly decides to do so. + +## Historical Implementation Order + +The original flat OpenSpec order was: + +1. `workspace-foundation` +2. `workspace-create-and-register-repos` +3. `workspace-open-agent-context` +4. `workspace-change-planning` +5. `workspace-agent-guidance` +6. `workspace-apply-repo-slice` +7. `workspace-verify-and-archive` + +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 `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. + +## Before Editing + +For the slice you are about to implement, inspect the pinned POC commit using `POC_REFERENCE_GUIDE.md`, then write down: + +```text +POC findings for <slice>: + +User behavior to preserve: +- ... + +Tests or examples worth translating: +- ... + +Implementation shortcuts to avoid: +- ... + +Open design questions: +- ... +``` + +Capture durable findings in the relevant initiative, context-store, or +repo-local OpenSpec artifact so future sessions do not depend on chat history. diff --git a/openspec/changes/workspace-reimplementation-roadmap/proposal.md b/openspec/changes/workspace-reimplementation-roadmap/proposal.md index 028a8b8234..99daf917d4 100644 --- a/openspec/changes/workspace-reimplementation-roadmap/proposal.md +++ b/openspec/changes/workspace-reimplementation-roadmap/proposal.md @@ -2,6 +2,13 @@ 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. + 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. This roadmap also needs to survive multiple sessions and branches. Current OpenSpec change discovery treats active changes as flat immediate directories under `openspec/changes/`, and change names are kebab-case identifiers rather than nested paths. This change is therefore a flat planning container with sibling proposal changes instead of nested child changes. diff --git a/openspec/changes/workspace-verify-and-archive/proposal.md b/openspec/changes/workspace-verify-and-archive/proposal.md index bde2bbd0f9..8856a9583a 100644 --- a/openspec/changes/workspace-verify-and-archive/proposal.md +++ b/openspec/changes/workspace-verify-and-archive/proposal.md @@ -1,5 +1,15 @@ ## 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. + +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. + Users need to know whether a cross-repo workspace change is complete without flattening all repo progress into one ambiguous done state. The desired lifecycle is: diff --git a/openspec/initiatives/context-store-and-initiatives/.initiative.yaml b/openspec/initiatives/context-store-and-initiatives/.initiative.yaml new file mode 100644 index 0000000000..c67efbeda8 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/.initiative.yaml @@ -0,0 +1,27 @@ +version: 1 +id: context-store-and-initiatives +title: Context Store And Initiatives Direction +status: exploring +summary: > + Define the direction for a synced context store, mounted collections, + initiatives, local workspaces, and repo-local changes. +owners: [] +artifacts: + readme: README.md + direction: direction.md + roadmap: roadmap.md + tasks: tasks.md + decisions: decisions.md + questions: questions.md + work_items: work-items/ +linked_changes: + - change: workspace-reimplementation-roadmap + relationship: informs + - change: workspace-agent-guidance + relationship: reframes + - change: workspace-apply-repo-slice + relationship: reframes + - change: workspace-verify-and-archive + relationship: reframes +links: [] +metadata: {} diff --git a/openspec/initiatives/context-store-and-initiatives/README.md b/openspec/initiatives/context-store-and-initiatives/README.md new file mode 100644 index 0000000000..a31c8faf17 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/README.md @@ -0,0 +1,33 @@ +# Context Store And Initiatives + +This initiative is the source of product intent for context stores, +collections, initiatives, workspaces, and repo-local changes. + +Start here before continuing workspace or initiative work. + +## 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/<id>/` contains execution notes for one roadmap item. + +## Boundary + +Initiative artifacts carry product intent and roadmap decisions. 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: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` diff --git a/openspec/initiatives/context-store-and-initiatives/decisions.md b/openspec/initiatives/context-store-and-initiatives/decisions.md new file mode 100644 index 0000000000..d04e6726bb --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/decisions.md @@ -0,0 +1,204 @@ +# Context Store And Initiatives Decisions + +## 2026-05-20: Track Roadmap Execution Inside The Initiative + +Decision: Track initiative roadmap implementation inside +`openspec/initiatives/context-store-and-initiatives/` rather than creating an +OpenSpec change for each roadmap item. + +Why: The initiative is the durable coordination object for this work. Repo-local +OpenSpec changes should be reserved for implementation slices owned by a repo or +team. Roadmap-item tracking belongs with the initiative until a task needs a +repo-owned implementation plan. + +Implications: + +- Use `tasks.md` as the initiative-wide progress dashboard. +- Use `work-items/<nn-slug>/` for detailed execution notes on one roadmap item. +- Link repo-local OpenSpec changes back to the initiative later when + implementation moves into a repo-owned slice. + +## 2026-05-20: Lock Workspace-To-Initiative Product Boundary + +Decision: Workspaces are local working views, not durable shared planning +objects. Durable coordination belongs to context stores and initiatives. Repo +local changes own implementation. + +Implications: + +- Preserve workspace setup, link, relink, list, open, update, and doctor as + beta local-view infrastructure. +- Treat workspace-planning behavior as beta or transitional compatibility. +- Defer workspace apply, verify, and archive until initiative-linked repo-local + changes exist. + +## 2026-05-21: Leave Specs Alone Until Behavior Changes + +Decision: Do not use the initial direction lock to rewrite OpenSpec specs. +Specs should describe the current behavioral contract behind the code. The +initiative artifacts should carry product intent, roadmap decisions, and future +direction until a later implementation change deliberately updates behavior and +its specs together. + +Implications: + +- Initial Item 1 cleanup should focus on initiative docs, historical roadmap + artifacts, active proposal disposition, and user-facing docs. +- Existing workspace-planning specs and schemas may continue to describe current + implemented behavior. +- Future changes to specs should happen with the behavior they govern. + +## 2026-05-21: Keep Deferred Workspace Changes As Reference Placeholders + +Decision: Keep the active workspace changes for agent guidance, repo-slice +apply, verify/archive, and the reimplementation roadmap as deferred reference +placeholders. + +Why: These areas are still expected to matter after context stores, initiatives, +and initiative-linked repo-local changes exist. Archiving or deleting them now +would lose useful research and continuity. + +Implications: + +- Do not pick them up as the immediate next implementation focus. +- Treat their current proposals as historical/deferred direction. +- Revisit and reframe them after initiative-linked repo-local changes define the + durable handoff model. + +## 2026-05-21: Generated Workspace Guidance Routes Work By Ownership + +Decision: Generated workspace guidance should describe workspaces as local +working views and route durable work to the owning artifact: initiatives own +cross-team or cross-repo intent, repo-local OpenSpec changes own implementation +plans, and linked repos or folders own their implementation. + +Why: The initiative direction supersedes the older model where a workspace-level +`changes/` tree owned the canonical shared cross-repo plan. New agent guidance +should not reinforce that old model. + +Implications: + +- Remove guidance that tells agents to use workspace-level `changes/` as the + planning home for coordinated work. +- Keep legacy or beta workspace-planning files readable as compatibility + context when present. +- Update generated workspace guidance before broad user-facing docs or specs. +- Leave specs untouched until the corresponding behavior intentionally changes. + +## 2026-05-21: Workspace Action Context Is Local Compatibility Context + +Decision: Workspace-planning action context should no longer describe +workspace-level artifacts as the source of truth. It should report +`sourceOfTruth: "workspace-local"` and describe workspace-local planning +artifacts as compatibility context for the current local view. + +Why: Workspace-planning artifacts can still exist in the beta workflow, but the +initiative direction assigns durable coordination to initiatives and +implementation planning to repo-local changes. + +Implications: + +- Keep `actionContext.mode: "workspace-planning"` for compatibility. +- Keep `allowedEditRoots: []` until an explicit edit root is selected. +- Keep linked repos and folders as context, not implicit edit roots. +- Route durable coordination to initiatives when initiative context exists. + +## 2026-05-21: Reorder Roadmap Around Agent-First Initiative Handoff + +Decision: Treat initiatives as an agent-first workflow. Users should be able to +prompt an agent with intent like "using initiative X, explore Y and create a +proposal"; OpenSpec should provide small CLI primitives the agent can compose. + +Why: The practical UX is not a human manually typing every coordination command. +Agents need reliable structured answers about where canonical initiative context +lives and how repo-local changes reference it. Local paths come from workspace +state, not from an initiative command. + +Implications: + +- Promote minimal context-store setup, registration, listing, and doctoring + before workspace initiative opening. +- Add `initiative show --json` before broader progress/status concepts. +- Connect repo-local changes with checked-in initiative metadata, not checked-in + snapshots of initiative prose. +- Do not add `initiative resolve`; workspace local-view state owns local path + mapping. +- Teach workspace opening about initiatives after show and repo-change linkage + semantics exist. + +## 2026-05-26: Workspace Initiative Opening Uses Generated Runtime Files + +Decision: Treat workspace initiative opening as a private local view record plus +generated runtime files. The workspace does not contain the work. It remembers +how this runtime opens the work. + +Why: Initiative context is shared truth in the context store, repo-local changes +own implementation, and agent/editor affordances need to exist in the runtime +where the agent actually runs. Persisting generated files as workspace truth +would blur local view state with shared coordination and create stale or +privacy-sensitive artifacts. + +Implications: + +- Persist only tiny private local view choices: selected store, selected + initiative, selected local links, opener, and selected tools. +- Preserve the selected context-store selector inside the private workspace + record, so a runtime-local `--store-path` open can be reopened without writing + machine-local paths into checked-in repo metadata. +- Generate agent guidance, skills, launch prompts, and editor workspace files as + runtime support when opening or preparing a view. +- Open existing local paths only; do not clone, branch, create worktrees, use + submodules, or infer local repos in Item 10. +- Treat generated runtime files as disposable and regenerable. +- Allow context-only initiative open; linked repos are optional local view + choices. +- Keep edit boundaries advisory in Item 10 until enforcement is designed. + +## 2026-05-26: Workspace Storage Is Keyed By Workspace Name + +Decision: Store private workspace views under +`getGlobalDataDir()/workspaces/<workspace-name>/`. The workspace name is the +local identity. The selected context store and initiative, if any, live inside +one durable private `workspace.yaml` record. + +Why: Workspaces are generic local views, not initiative-owned directories. A +user may want a custom workspace with linked repos and folders but no initiative, +or multiple personal workspaces over the same initiative. Keying storage by +store and initiative would overfit the filesystem layout to one workflow. + +Implications: + +- Keep initiative references optional inside `workspace.yaml`. +- Store initiative context with an explicit context-store binding rather than a + flat store id, because workspace state may need to remember a registry selector + or a runtime-local path selector. +- Generate `AGENTS.md`, opener workspace files, and tool-specific skills at the + managed workspace root. +- Keep `workspace.yaml` as the only view file for Item 10; do not add a separate + machine-readable view file. +- Do not introduce a separate generated-output directory for Item 10. +- If the user opens an initiative without a workspace name, derive a friendly + default workspace name from the initiative id when that is unambiguous. +- On workspace-name collisions or multiple workspaces pointing at the same + initiative, ask the human to choose or require an explicit workspace name in + non-interactive mode. + +## 2026-05-26: Item 10 Workspace Open UX Decisions + +Decision: Close the remaining Item 10 product decisions around runtime identity, +JSON output, Codex Desktop, edit boundaries, and implementation scope. + +Implications: + +- Use `getGlobalDataDir()` as the cross-platform runtime-local boundary. Do not + add path translation or a separate runtime id in Item 10. +- Keep `workspace open --json` as a machine-facing receipt for the same open + operation. It should return useful generated paths, selected context, opened + roots, skipped roots, opener, launch status, and warnings. +- Do not add `--prepare-only` for Item 10. +- For Codex Desktop, open the generated workspace root as the project and expose + attached initiative and repo/folder paths through generated guidance and + `workspace open --json` output. +- Emit advisory edit boundaries only; do not enforce write restrictions. +- Continue to open known existing local paths only. Do not clone, branch, create + worktrees, use submodules, or infer local repos in Item 10. diff --git a/openspec/initiatives/context-store-and-initiatives/direction.md b/openspec/initiatives/context-store-and-initiatives/direction.md new file mode 100644 index 0000000000..ba863a7e8d --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/direction.md @@ -0,0 +1,447 @@ +# 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. + +## Core Model + +```text +Context Store + = synced shared content container + +Collection + = mounted content system inside a store + +Initiatives + = first major collection for cross-team implementation context + +Workspace + = local working view over context stores and repos + +Change + = repo/team-owned implementation plan +``` + +The clean rule: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Locked Product Boundary + +The workspace-to-initiative pivot is now the product boundary for future +coordination work: + +- A workspace is a regenerable, machine-local working view. It maps context + stores, initiatives, projects, repos, and folders to paths the current user can + open. +- A context store is the durable synced container for shared files. +- An initiative is the durable coordination object for cross-team or cross-repo + implementation context. +- A repo-local change remains the implementation plan owned by the repo or team + doing the work. + +This supersedes the older model where a workspace-level `changes/` tree owned +the canonical shared plan for cross-repo work. Existing workspace-planning +behavior can remain as beta or legacy infrastructure, but it should not steer +new lifecycle design. + +Workspace roadmap disposition: + +- Keep setup, link, relink, list, open, update, and doctor. +- Keep linked repos and folders visible for exploration before a change exists. +- Keep workspace-local agent guidance as local view setup, refreshed by + `workspace update`. +- Defer workspace apply, verify, and archive until initiatives can link to + repo-owned OpenSpec changes. +- Defer branch/worktree orchestration, multi-repo apply, strong cross-repo + validation, and dependency graph enforcement. + +## Agent-First UX + +The primary user experience for initiatives is expected to be agent-driven: + +```text +Using initiative billing-launch, explore the API work and create a proposal. +``` + +The user should not need to know every command. OpenSpec should expose small, +structured CLI primitives that an agent can use to: + +- find the intended initiative across registered context stores +- read canonical initiative files from the context store +- create or link a repo-local OpenSpec change +- use workspace state for local repo and folder views +- respect edit boundaries instead of treating every opened folder as editable + +The CLI is therefore the agent's tool surface, not the whole user workflow. +Prefer explicit, machine-readable commands such as `initiative show --json`, +`new change --initiative ...`, and workspace local-view commands over broad +interactive flows as the first slice. + +Canonical initiative context should stay in the context store. Repo-local +changes should reference the initiative rather than checking in copied snapshots +of initiative prose. If an agent needs a compact context pack, OpenSpec can +generate that as command output from the live initiative context. + +## Context Store + +A context store is the shared/synced folder of files. It is content-agnostic. +It should not know what an initiative is. + +Example: + +```text +acme-context/ + initiatives/ + decisions/ + api-catalog/ + playbooks/ +``` + +The first backend should be Git: + +```text +create/update/delete files + -> commit + -> push + -> other users pull + -> local views update +``` + +But the application should talk to a store abstraction, not directly to Git, so +the backend can later become a cloud database. + +## Backend + +A backend provides persistence and sync for a context store. + +Examples: + +- `git` backend: local clone, pull, commit, push, watch +- `cloud` backend: database records, subscriptions, hosted sync +- `memory` backend: tests and local prototypes + +The backend should expose generic file/object operations: + +```text +read +write +delete +list +sync +watch +``` + +It should not contain initiative-specific behavior. + +## Collections + +A collection is a mounted content system inside a context store. It is +plugin-like, but "collection" is the user-facing term. + +Each collection owns: + +- a folder namespace +- a content model +- templates +- validation/rules +- optional agent guidance +- optional UI views + +Example: + +```text +context-store/ + initiatives/ # Initiative collection + decisions/ # Decision collection + api-catalog/ # API catalog collection +``` + +Core should enforce that a collection only writes inside its mount. + +## Initiative Collection + +The initiative collection is the first enterprise-oriented collection. + +An initiative is shared, agent-consumable implementation context for a +coordinated outcome. It can span teams, repos, services, APIs, contracts, and +capabilities. + +Default shape: + +```text +initiatives/ + launch-billing-flow/ + initiative.yaml + requirements.md + design.md + contracts/ + decisions.md + questions.md + tasks.md +``` + +This describes the runtime initiative collection shape in context stores. This +roadmap folder may still contain legacy `.initiative.yaml` progress metadata +while the initiative itself is being used to manage the migration; that legacy +tracker is not the model new context-store initiatives should copy. + +The default structure should be opinionated for the enterprise design +partnership, but the collection system should allow other structures later. + +## Initiative Responsibilities + +Initiatives should own implementation-relevant shared context: + +- product/program intent +- accepted requirements +- high-level technical coordination +- capability and ownership maps +- API/event/schema contracts +- dependency assumptions +- decisions and open questions +- workspace-readable context for repo-local implementation work + +Initiatives should not try to become all of Jira or Confluence. The focused +positioning is: + +```text +OpenSpec stores agreed implementation context. +Jira tracks work. +Confluence stores broad prose. +GitHub/GitLab store code. +``` + +## Initiative And Change Scope + +An initiative can span one or many OpenSpec changes. + +Those changes may live: + +- in the same repo as the initiative +- in different repos +- in multiple context stores or OpenSpec roots later + +The initiative stores shared coordination context. Workspace views can associate +that context with local repos and repo-owned changes without making the +initiative store machine-local checkout links. + +This keeps grouping separate from storage: + +```text +Initiative = shared grouping/context +Change = execution artifact +Workspace = local opened view of initiative + repos +``` + +## Workspace + +A workspace is a local working view, not the source of truth. + +It can map context stores and project identifiers to local paths, configure an +opener, and launch coding agents with the right folders visible. + +A workspace can open an initiative by resolving: + +- the initiative's context store +- locally selected repo-local changes +- local checkout paths for participating repos + +The durable workspace record should stay tiny and private. It records this +runtime's local view choices, not generated agent files or shared initiative +content. + +```text +getGlobalDataDir()/workspaces/<workspace-name>/ + workspace.yaml +``` + +The workspace name is the local identity. The workspace record can optionally +store a selected context store and initiative, plus stable link names to local +paths and opener preferences. Initiative references are data inside the record, +not path segments. + +Opening a workspace materializes opener-specific runtime files at the managed +workspace root. Those files can contain generated agent guidance, skills, +and editor workspace files. Machine-readable context is returned by JSON command +output. These are regenerated local support, not source of truth. + +```text +private local view record + -> generated runtime files + -> opener-specific launch + -> initiative context + selected local repos/folders +``` + +Workspaces should be regenerable and runtime-specific. They should not be the +canonical home for initiative content, checked-in collaboration state, branches, +worktrees, clones, or implementation progress. + +## Repo Changes + +Repo-local changes remain the team-owned implementation plan. + +An engineering team should be able to pull relevant initiative context into a +repo and create a linked OpenSpec change. + +Example: + +```text +repo/ + openspec/ + changes/ + add-billing-api/ + .openspec.yaml + proposal.md + design.md + specs/ + tasks.md +``` + +The local change should reference the initiative in metadata, for example: + +```yaml +initiative: + store: platform + id: billing-launch +``` + +This metadata is durable repo context and should be checked in. It should not +contain machine-local paths. Agents should read the initiative's canonical files +from the registered context store when they need the shared context. + +## Relationship Between Concepts + +```text +Context Store + contains Collections + +Collection + defines structure/rules for a mounted folder + +Initiative Collection + defines initiatives/ + +Initiative + coordinates one shared outcome + +Workspace + opens local views of context stores and repos + +Repo Change + implements one team's/repo's part of an initiative +``` + +End-to-end flow: + +```text +Product/program/architect creates initiative + -> initiative syncs through context store + -> engineers open local workspace + -> repo team pulls relevant initiative context + -> repo team creates linked OpenSpec change + -> repo team implements locally + -> workspace view surfaces local progress alongside initiative context +``` + +## Local API Direction + +The app should use dependency injection: + +```ts +const store = createStore({ + id: "acme-context", + backend: gitBackend({ + remote: "git@github.com:acme/context.git", + localPath: "~/.openspec/stores/acme-context", + autoSync: true, + }), + collections: [ + initiativeCollection({ mount: "initiatives" }), + ], +}); +``` + +Usage: + +```ts +const initiatives = store.collection("initiatives"); + +await initiatives.create({ id: "launch-billing-flow" }); +await initiatives.update("launch-billing-flow", patch); +await store.sync(); +``` + +Important separation: + +```text +Git backend knows Git. +Store knows sync/lifecycle/events. +Collection knows content structure. +Initiative collection knows initiatives. +``` + +## UI Direction + +The UI should be content-agnostic at the core: + +- browse folders/files +- edit Markdown/YAML +- preview content +- search +- show diffs/history +- sync status + +Collections can add richer views: + +- initiative status view +- contract table +- owner/dependency graph +- linked repo-change view + +The UI should work no matter which collections are mounted. + +## Open Questions + +- What is the first concrete context store command surface? +- Should stores be called `context`, `store`, or something more product-facing? +- Where should enterprise context stores live by default: customer GitHub, + OpenSpec-managed Git, or later hosted cloud? +- How do non-technical users edit Git-backed content without feeling Git? +- What is the minimum viable auto-sync behavior before conflict handling gets + painful? +- How does an initiative contract graduate into a canonical owner repo contract? +- How should linked repo changes report status back into an initiative without + becoming Jira? +- How should monorepos map capabilities, folders, and repo-local changes? +- What should the first repo-change linking command be called? +- Which initiative progress/status signals are useful after linked changes + exist? + +## Suggested Next Direction + +After the initial store, collection, and initiative create/list foundations, +build the next slices in this order: + +1. Reconcile the Initiative MVP around create/list, validation, templates, and + explicit deferral of read/update/delete policy. +2. Add minimal context-store UX for setup, registration, listing, and doctoring. +3. Add agent-first initiative discovery with `initiative show --json` and + registered-store lookup. +4. Add repo-local change metadata and an agent-friendly create/link flow for + `--initiative`. +5. Reject standalone `initiative resolve`; local path mapping belongs to + workspaces, not initiative commands. +6. Let workspaces open initiative-aware local views once show/link semantics + exist. +7. Add local-to-initiative escalation UX. +8. Harden team-shared coordination, sync, conflict guidance, and progress + status after real usage shapes those needs. diff --git a/openspec/initiatives/context-store-and-initiatives/questions.md b/openspec/initiatives/context-store-and-initiatives/questions.md new file mode 100644 index 0000000000..79c42ebc8b --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/questions.md @@ -0,0 +1,23 @@ +# Context Store And Initiatives Questions + +## Open + +- Should the user-facing command vocabulary say `context`, `store`, or + something more product-facing? +- What migration or compatibility path should existing workspace-planning + changes get once initiatives exist? +- How should linked repo changes report progress back into an initiative without + becoming a Jira clone? +- How should monorepos map capabilities, folders, and repo-local changes? +- Should OpenSpec support configurable change homes across context stores and + local OpenSpec repos, and what ownership rules keep that model safe? + +## Resolved + +- Workspaces should not be the durable shared planning object. +- Initiative roadmap implementation should be tracked inside the initiative + until repo-owned implementation changes are needed. +- The first concrete context store command surface is `context-store setup`, + `context-store register`, `context-store list`/`ls`, and + `context-store doctor`. Sync, push/pull, remotes, and conflict handling are + future work. diff --git a/openspec/initiatives/context-store-and-initiatives/roadmap.md b/openspec/initiatives/context-store-and-initiatives/roadmap.md new file mode 100644 index 0000000000..744723ed5a --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/roadmap.md @@ -0,0 +1,543 @@ +# Context Store And Initiatives Roadmap + +This roadmap turns the direction in `direction.md` into shippable chunks. + +The product decision underneath every step is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## 1. Lock The Direction + +Goal: make the workspace-to-initiative pivot explicit so future workspace work +does not keep implementing the older "workspace owns the plan" model. + +Ship: + +- Record that workspaces are local working views, not durable shared planning + objects. +- Record that initiatives are the durable coordination object for cross-team or + cross-repo work. +- Mark the current workspace apply, verify, and archive direction as deferred or + superseded until initiative-linked repo changes exist. +- Keep the already-built workspace setup, link, open, update, and doctor + behavior as useful beta infrastructure. + +Done when: + +- Fresh agents can tell which workspace ideas still apply and which ones should + not steer implementation. + +Locked disposition: + +- Keep workspace setup, link, relink, list, open, update, and doctor as beta + local-view infrastructure. +- Keep "workspace visibility is not change commitment" as a safety rule for + linked repos and folders. +- Supersede "workspace is the durable planning home" with "initiatives are the + durable coordination object." +- Supersede workspace-level planning artifacts as the canonical shared + cross-repo plan. +- Defer workspace apply, verify, and archive as first-class lifecycle commands + until initiative-linked repo-local changes exist. +- Defer branch/worktree orchestration, strong cross-repo validation, dependency + graph enforcement, and shared contract governance. + +Fresh-agent rule: + +- Start from `openspec/initiatives/context-store-and-initiatives/direction.md` + for product authority. +- Treat `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` and + `openspec/changes/workspace-reimplementation-roadmap/` as historical reference + material for preserved local-view behavior and POC lessons. +- Do not pick up `workspace-apply-repo-slice` or + `workspace-verify-and-archive` as the next implementation slice unless a later + initiative-linked repo-change design explicitly reactivates them. + +## 2. Stabilize Workspace As Local View + +Goal: keep workspaces useful without making them the source of truth. + +Ship: + +- Workspace guidance that routes durable coordination to initiatives, + implementation planning to repo-local changes, and linked repos or folders to + local context until an edit root is selected. +- Workspace-open behavior that launches the local planning view with linked + folders visible. +- Workspace doctor/status output that explains local path mappings, unresolved + links, installed agent skills, and repair steps. +- Clear docs that `workspace update` refreshes local agent guidance and does not + modify linked repos. + +Done when: + +- A user can set up a workspace, link repos, open an agent, and understand that + the workspace is a local view over context, not the canonical shared plan. + +## 3. Add Context Store Foundation + +Goal: create the generic local context-store foundation that can later hold +initiatives and other shared context collections. Sync/watch behavior remains a +future hardening slice. + +Ship: + +- A context store abstraction with generic local operations: read, write, + delete, and list. +- A first Git-shaped backend model that can point at a local store root. +- A test/memory backend for fast tests and prototypes. +- A store configuration model that does not contain initiative-specific logic. + +Done when: + +- OpenSpec can create and manipulate files inside a local context store without + the core store layer knowing what those files mean. Pull, push, watch, + remote creation, and conflict handling are tracked as future sync work. + +## 4. Add Collection Foundation + +Goal: let product-specific content systems live inside a context store without +hardcoding every future concept into the store layer. + +Ship: + +- A collection interface with a mounted folder namespace. +- Rules that keep a collection's writes inside its mount. +- Basic collection validation and template hooks. +- A way for collections to expose optional agent guidance or UI metadata later. + +Done when: + +- The context store can host a mounted `initiatives/` collection while staying + generic enough for future collections like decisions, API catalogs, or + playbooks. + +## 5. Ship Initiative MVP + +Goal: give coordinated work a durable, shared, agent-consumable home. + +Ship: + +- Initiative creation and listing. +- A default initiative file shape: + +```text +initiatives/<id>/ + initiative.yaml + requirements.md + design.md + decisions.md + questions.md + tasks.md +``` + +- Templates for product intent, accepted requirements, design decisions, open + questions, and coordination tasks. +- Validation for required initiative metadata. +- Explicit deferral of full read/show, update, and delete policy until the + agent-first discovery and lifecycle needs are clearer. + +Done when: + +- A user or agent can create and list initiatives as shared planning objects + before any repo has committed to implementation details. + +## 6. Add Minimal Context Store UX + +Goal: make shared initiative storage usable before repo handoff or workspace +opening depends on it. + +Ship: + +- `context-store setup <id>` for creating a local Git-backed store folder with + portable store metadata and local registration. +- `context-store register <path>` for registering an existing clone or folder, + defaulting the store id from the repo or folder name. +- `context-store list` and `context-store doctor` for local visibility and + non-mutating diagnostics. +- `initiative list` defaulting to all registered stores, with `--store` as a + filter and `--store-path` as an escape hatch. +- Minimal human output and JSON output suitable for agents. + +Done when: + +- A single developer or teammate can create or register a shared context store, + list initiatives across registered stores, and diagnose missing or broken + local store setup without learning the internal registry layout. + +## 7. Add Agent-First Initiative Discovery + +Goal: let an agent resolve the initiative the user named and read canonical +initiative context from the source of truth. + +Ship: + +- `initiative show <id>` that searches registered stores by default. +- Ambiguity handling when the same initiative id exists in multiple stores. +- JSON output with canonical initiative metadata, store identity, initiative + root path, and metadata path. +- Human output focused on identity and available files, not work progress. + +Done when: + +- An agent can answer, "Which initiative did the user mean, where is the + canonical context, and where is the initiative metadata?" + +## 8. Connect Repo-Local Changes To Initiatives + +Goal: split shared coordination from repo-owned implementation plans cleanly. + +Discussion points to confirm before implementation: + +- Should the create/link flow explicitly report where the change lives, which + initiative it references, and the next suggested command? +- Should `--initiative <id>` search registered stores by default, or should it + require `--store` when more than one store is registered? +- What should the command do when the initiative exists but the current repo has + no obvious ownership match? + +Ship: + +- Repo-local change metadata that can reference an initiative by store id and + initiative id. +- An agent-friendly create or link flow such as + `new change <id> --initiative <store>/<initiative>`. +- Guidance that repo-local changes remain responsible for implementation, + validation, and archive. +- No checked-in `initiative.md` snapshot by default; agents read canonical + initiative files live from the context store. + +Done when: + +- One initiative can coordinate several repo-local changes without copying the + shared plan into every repo, storing machine-local links in the initiative, or + making the initiative own implementation artifacts. + +## 9. Reject Initiative Resolve + +Decision: do not add `openspec initiative resolve`, now or later. + +Rationale: + +- `initiative show` already resolves canonical shared initiative context. +- A workspace is the local view over repos, folders, context stores, and + initiatives. +- Repo-local changes already carry durable initiative links in checked-in + `.openspec.yaml` metadata. +- Repo-local status already reports work progress. +- A standalone resolve command would either duplicate workspace local-view state + or produce weak output when no workspace is present. + +Do not ship: + +- `openspec initiative resolve <id>` +- all-workspace or all-repo scans for initiative availability +- explicit path scanning as an initiative command +- Git remote matching for initiative participation +- repo ownership inference +- cloning, branch creation, or worktree creation as part of initiative + resolution +- initiative backlinks +- local availability or progress dashboards under the initiative command + +Done when: + +- Future agents can see that "initiative resolve" is intentionally rejected and + should not be revived under another command name. + +## Proposed Discussion Point: Add Initiative Next / Agent Handoff UX + +Status: candidate work item, not locked into the numbered roadmap yet. + +Question to confirm: + +- Should this become a roadmap item before "Let Workspaces Open Initiatives"? + +Goal: give agents and users a small "what now?" command after initiative +discovery from the current repo or workspace, without turning it into a +dashboard or progress/status surface. + +Possible shape: + +```bash +openspec initiative next billing-launch --json +``` + +Possible JSON answer: + +```json +{ + "initiative": "billing-launch", + "next_action": "create_repo_change", + "reason": "initiative found, no linked local change exists for this repo", + "suggested_command": "openspec new change add-billing-api --initiative billing-launch" +} +``` + +Discussion points to confirm before implementation: + +- Is `initiative next` the right command name, or should this guidance belong + inside workspace initiative opening or repo-local status? +- Should it return exactly one suggested next action, or a ranked set of options? +- Should it ever inspect work progress, or stay limited to handoff/readiness? +- How should it behave when no stores are registered, the initiative is + ambiguous, or the local repo is unrelated? + +Done when, if accepted: + +- An agent can answer "what should I do next for this initiative from here?" + without guessing across `show`, workspace state, and repo-local + change metadata. + +## 10. Let Workspaces Open Initiatives + +Goal: connect durable initiative context to this runtime's local working view +after initiative show and repo-change linkage exist. + +Locked direction: + +- A workspace does not contain the work. It remembers how this runtime opens the + work. +- Persist only tiny private local view choices. +- Generate opener-specific runtime files on open. +- Attach initiative context and selected existing local repos or folders. +- Do not clone, branch, create worktrees, use submodules, or infer local repos in + this slice. +- Context-only open is valid. + +Product decision status: + +- No remaining Item 10 product decisions are open. Implementation may still + uncover mechanical details, but the intended UX shape is locked. + +Command UX decision: + +- Use `openspec workspace open --initiative <initiative>`. +- Support `<store>/<initiative>` and `<initiative> --store <store>`. +- Support `openspec workspace open <workspace-name> --initiative <initiative>` + when the user wants to choose the local workspace identity explicitly. +- If only `<initiative>` is provided, proceed when exactly one registered + context store has that initiative id. +- On ambiguity, list exact matches and require an explicit store selector. +- On no exact match, show likely matches when available and suggest `openspec + initiative list`; do not silently open a fuzzy match. +- If the user omits a workspace name, derive a friendly default from the + initiative id when that is unambiguous; otherwise require the user to pick an + explicit workspace name. + +Open target decision: + +- Open the initiative directory by default, not the whole context store. +- Generated guidance and JSON output should still report the context store root + and that broader context is available. +- A later explicit option may open the whole context store, but broad store + scope is not the Item 10 default. + +Local view record decision: + +- Use one private local view record for initiative-aware local views. +- Store initiative-view state in the root `workspace.yaml` file. +- The record stores selected context-store binding, initiative, local links, + opener, and selected tools. The binding may preserve a registry selector or a + runtime-local path selector. +- The context binding is optional, so a workspace can also be a custom local view + with linked folders and no initiative. + +Workspace storage decision: + +- Store each private workspace view under + `getGlobalDataDir()/workspaces/<workspace-name>/`. +- The workspace name is the local identity. Selected store and initiative, if + any, are data inside the private record rather than path segments. +- Use one durable `workspace.yaml` at the workspace root. +- Generate `AGENTS.md`, opener workspace files, and tool-specific skills at the + workspace root. +- Do not introduce a separate generated-output directory for Item 10. + +Runtime identity decision: + +- Use `getGlobalDataDir()` as the cross-platform runtime-local boundary. +- Local paths are valid only in the runtime that wrote the private + `workspace.yaml`. +- Do not add path translation or a separate `<runtime-id>` path segment in Item + 10. + +Prepare/JSON decision: + +- Keep `workspace open --json` as a machine-facing receipt for the same open + operation. +- Do not add `--prepare-only` for Item 10. +- JSON should return useful generated paths, selected context, opened roots, + skipped roots, opener, launch status, and warnings rather than a bare success + response. + +Codex Desktop decision: + +- Open the generated workspace root as the Codex Desktop project. +- Expose attached initiative and linked repo/folder paths through generated + guidance and `workspace open --json` output. +- Defer Desktop multi-root automation until there is a clearer Desktop contract. + +Edit-boundary decision: + +- Emit advisory boundaries only. +- Label initiative/context-store files as shared coordination context and linked + repos/folders as local implementation context when selected. +- Do not enforce write restrictions in Item 10. + +Ship: + +- Private local view state that can remember the selected context store, + selected initiative, selected local links, opener, and selected tools for this + runtime. +- `workspace open` support for generating opener-specific runtime files and + opening initiative context plus locally resolved linked repos/folders. +- Agent guidance and machine-readable `workspace open --json` output that + explain the current initiative, opened roots, skipped roots, local paths, and + advisory edit boundaries. +- Workspace-name reuse behavior that avoids silently repointing an existing + workspace to a different initiative. +- Open-time warnings that skip missing linked repos/folders while failing when + the selected initiative or context store cannot be resolved. +- Continued support for custom non-initiative workspaces as first-class local + views. +- Doctor guidance for missing context stores, missing linked repos/folders, and + stale local view records. + +Done when: + +- A teammate can open the same initiative in their runtime while using their own + local paths and selected repo subset. +- Generated runtime files are clearly derived and can be regenerated without + losing the user's local view choices. + +## 11. Add Escalation UX + +Goal: let users start locally and upgrade only when coordination is actually +needed. + +Ship: + +- Explore/propose guidance that starts in the current repo by default. +- A recommendation path when work spans multiple owned areas: + +```text +This appears to span multiple owned areas. +OpenSpec can upgrade it into a coordinated initiative and carry the current +planning context forward. +``` + +- Carry-forward behavior for the current change name, product goal, notes, + inferred areas, and relevant questions. +- Clear prompts that ask about concrete affected areas rather than abstract + storage models. + +Done when: + +- Coordinated planning feels like a continuation of local planning, not a + workflow restart. + +## 12. Harden Team-Shared Coordination + +Goal: make initiatives practical for teams without turning setup into an admin +ceremony. + +Ship: + +- A recommended Git-backed shared context store pattern. +- Lightweight teammate onboarding: + +```text +Clone the context store. +Run openspec workspace doctor. +Open the initiative with your agent. +``` + +- Repair flows for local path mappings. +- Sync status and conflict guidance. +- Clear separation between committed initiative state and machine-local + workspace state. + +Done when: + +- Several teammates can share the same initiative while each keeps their own + local checkout layout. + +## 13. Explore Initiative-Hosted Target-Bound Change Artifacts + +Goal: decide whether shared initiative artifacts can graduate into executable +OpenSpec changes only after they are bound to a target repo or spec root, +without blurring initiative coordination, repo ownership, and workspace +local-view boundaries. + +Discussion points to confirm before exploration: + +- Should "change home" stay internal resolver language, with user-facing + phrasing like "where should this plan live?" and "editable target"? +- What is the difference between initiative work items, briefs, target-bound + changes, and repo-local changes? +- What portable target metadata is required before an initiative-hosted artifact + can be considered implementation-ready? +- Should shared target-bound changes require explicit opt-in, or can + initiative/store policy select them? +- What user/team scenario would justify an initiative-hosted target-bound change + instead of a repo-local linked change? + +Ship: + +- Audit commands, templates, validation, archive, apply, completion, and docs + for repo-local `openspec/changes/` assumptions. +- Define the concepts of artifact home, implementation target, allowed edit + roots, and action context. +- Decide how initiative-hosted target-bound changes bind to repo specs, + implementation roots, branches, validation, archive, and sync/conflict + behavior. +- Define agent-readable JSON output for work target, artifact home, + implementation target, initiative link, edit boundaries, unsupported + lifecycle commands, and next commands. +- Record compatibility behavior for existing repo-local and workspace-local + changes. +- Recommend whether this should become an implementation slice, remain deferred, + start as initiative work items only, or be limited to specific schemas or + workflows first. + +Done when: + +- The initiative has a concrete recommendation, opt-in/config examples, affected + command list, and go/no-go criteria for implementation. + +## Later, Not First + +These are important, but should wait until the initiative model has real usage: + +- Workspace apply, verify, and archive as first-class lifecycle commands. +- Branch or worktree orchestration. +- Strong cross-repo validation. +- Dependency graph enforcement. +- Shared contract ownership workflows. +- Sponsor/driver governance flows. +- Initiative progress/status dashboards. +- Cloud-hosted context stores. + +## Suggested Shipping Sequence + +1. Lock the direction and defer old workspace lifecycle slices. +2. Stabilize workspace as local view and agent launcher. +3. Add context store foundation. +4. Add collection foundation. +5. Ship initiative MVP. +6. Add minimal context-store UX. +7. Add agent-first initiative discovery. +8. Link repo-local changes to initiatives. +9. Keep initiative resolve rejected; use workspace local-view mapping instead. +10. Pending discussion: optionally add initiative next / agent handoff UX. +11. Let workspaces open initiatives. +12. Add local-to-initiative escalation UX. +13. Harden team-shared coordination. +14. Explore configurable change homes. diff --git a/openspec/initiatives/context-store-and-initiatives/tasks.md b/openspec/initiatives/context-store-and-initiatives/tasks.md new file mode 100644 index 0000000000..877416f754 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/tasks.md @@ -0,0 +1,183 @@ +# 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/`. + +## 1. Lock The Direction + +Work item: `work-items/01-lock-the-direction/` + +- [x] Record the workspace-to-initiative product boundary in initiative docs. +- [x] Mark the old workspace reimplementation roadmap as historical reference. +- [x] Defer workspace apply, verify, and archive until initiative-linked repo + changes exist. +- [x] Complete a non-spec direction pass so roadmap, work items, docs, and + active change artifacts point to the initiative as product intent. +- [x] Decide whether user-facing workspace docs need any change now; default to + no unless they misrepresent current behavior. +- [x] Decide how to handle active no-task workspace changes after the + disposition pass. +- [x] Record final evidence and remaining risks for Item 1. + +## 2. Stabilize Workspace As Local View + +Work item: `work-items/02-stabilize-workspace-as-local-view/` + +- [x] Re-anchor generated workspace guidance in the initiative direction. +- [x] Decide that generated guidance should stop recommending workspace-level + `changes/` as the planning home for coordinated work. +- [x] Decide that `workspace update` should refresh generated workspace + guidance for existing workspaces. +- [x] Decide that workspace-planning action context should treat beta workspace + artifacts as local compatibility context. +- [x] Decide to defer doctor installed-skill summaries and only update stale + `workspace update` wording for now. +- [x] Define exact local-view behavior to preserve. +- [x] Review current workspace setup, link, relink, list, open, update, and + doctor behavior against that definition. +- [x] Identify any product wording or guidance gaps left after Item 1. + +## 3. Add Context Store Foundation + +Work item: `work-items/03-add-context-store-foundation/` + +- [x] Define the initial store/backend data model. +- [x] Decide that the first slice is core API only, with no CLI surface yet. +- [x] Decide that the first backend is Git/local checkout config only. +- [x] Decide where context store roots, local registry YAML, and portable store + metadata YAML live. +- [x] Implement context-store foundation helpers and tests. + +## 4. Add Collection Foundation + +Work item: `work-items/04-add-collection-foundation/` + +- [x] Define collection mount rules. +- [x] Decide validation/template hooks stay inert extension fields for this + slice. +- [x] Prove `initiatives/` can mount without store-specific logic. + +## 5. Ship Initiative MVP + +Work item: `work-items/05-ship-initiative-mvp/` + +- [x] Define initiative file shape and validation. +- [x] Add templates for requirements, design, decisions, questions, and tasks. +- [x] Implement create/list mounted collection operations and CLI adapter. +- [x] Decide full read/show, update, and delete policy should move to later + agent-first discovery and lifecycle work. + +## 6. Add Minimal Context Store UX + +Work item: `work-items/06-add-minimal-context-store-ux/` + +- [x] Create Item 6 work-item tracking notes. +- [x] Define high-level `context-store setup`, `register`, `list`, and `doctor` + UX direction. +- [x] Decide exact checked-in store metadata and machine-local registry + behavior. +- [x] Decide setup/register/list/doctor human behavior and responsibility split. +- [x] Decide `initiative list` partial-success behavior across registered + stores. +- [x] Decide final Item 6 edge cases: id inference, non-empty setup folders, + registry conflicts, empty states, JSON exit behavior, and static completions. +- [x] Update `initiative list` to default across registered stores, with + `--store` as a filter and `--store-path` as an escape hatch. +- [x] Add focused tests and verification for context-store CLI behavior. + +## 7. Add Agent-First Initiative Discovery + +- [x] Define `initiative show <id>` human and JSON output. +- [x] Search registered stores by default and handle ambiguous initiative ids. +- [x] Return canonical initiative metadata, store identity, root path, and + metadata path for agent reads. +- [x] Keep work-progress status out of this command. + +## 8. Connect Repo-Local Changes To Initiatives + +Work item: `work-items/08-connect-repo-local-changes-to-initiatives/` + +- [x] Decide that the initiative link lives in repo-local `.openspec.yaml`. +- [x] Add repo-local initiative metadata. +- [x] Add an agent-friendly create or link flow for repo-local changes. +- [x] Decide command naming for `--initiative` linking on new change creation. +- [x] Confirm whether create/link output should report where the change lives, + which initiative it references, and the next suggested command. +- [x] Confirm whether `--initiative <id>` searches registered stores by default + or requires explicit store selection in multi-store setups. +- [x] Keep canonical initiative context in the context store; do not add a + checked-in `initiative.md` snapshot by default. + +## 9. Reject Initiative Resolve + +Work item: `work-items/09-add-initiative-resolve/` + +- [x] Pressure-test whether a standalone `initiative resolve` command is needed. +- [x] Decide not to add `openspec initiative resolve`, now or later. +- [x] Keep canonical initiative discovery in `initiative show`. +- [x] Keep local path mapping in workspace behavior. +- [x] Keep implementation progress in repo-local status. +- [x] Reject all-repo scans, all-workspace scans, explicit path scanning as an + initiative command, Git remote matching, cloning, worktree creation, and + initiative backlinks. + +## Proposed Discussion: Initiative Next / Agent Handoff UX + +Work item draft: +`work-items/proposed-initiative-next-agent-handoff-ux/` + +- [ ] Decide whether to add this as a numbered roadmap item between Item 9 and + Item 10. +- [ ] Decide whether the surface is `initiative next`, workspace initiative + opening, or repo-local status guidance. +- [ ] Decide whether it suggests one next action or multiple ranked options. +- [ ] Decide that progress/status stays out of scope, unless we explicitly want + this command to grow into a broader status surface. + +## 10. Let Workspaces Open Initiatives + +- [x] Create Item 10 work-item tracking notes. +- [x] Lock the command UX for opening an initiative as a local workspace view. +- [x] Define the private local view record for selected context store, + initiative, local links, opener, and selected tools. +- [x] Decide the private local view record storage namespace and keying. +- [x] Decide the default open target: initiative directory versus full context + store. +- [x] Decide where generated runtime files live and how they are regenerated. +- [x] Define runtime identity rules for macOS, Codespaces, WSL, SSH, and + containers without path translation. +- [x] Decide the prepare/JSON surface for agents and desktop integrations. +- [x] Decide the Codex Desktop behavior for generated workspace roots and attached + paths. +- [x] Define advisory edit-boundary output for Item 10. +- [x] Confirm this slice opens known local paths only and does not create + clones, branches, worktrees, or submodules. + +## 11. Add Escalation UX + +- [ ] Define local-to-initiative recommendation triggers. +- [ ] Carry current planning context into a new initiative. +- [ ] Keep prompts grounded in affected areas. + +## 12. Harden Team-Shared Coordination + +- [ ] Document recommended Git-backed store setup. +- [ ] Define teammate onboarding and repair flows. +- [ ] Add sync status and conflict guidance. + +## 13. Explore Configurable Change Homes + +Work item: `work-items/13-explore-configurable-change-homes/` + +- [ ] Confirm "change home" stays internal language and user-facing wording is + closer to "where should this plan live?" +- [ ] Explore when changes should live in a context store versus a local + OpenSpec repo. +- [ ] Decide the configuration surface for selecting a default change home. +- [ ] Define how `new change`, initiative linking, and workspace guidance + discover the configured change home. +- [ ] Decide how context-store-hosted changes bind to target repo specs, + implementation roots, validation, archive, and sync behavior. +- [ ] Record compatibility behavior for existing repo-local and + workspace-local changes. +- [ ] Identify follow-on implementation slices and risks. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md new file mode 100644 index 0000000000..d9483abe69 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md @@ -0,0 +1,154 @@ +# Work Item 01 Evidence + +## 2026-05-20 Initial Direction Lock + +Completed before this work item folder was created: + +- Added locked disposition to `roadmap.md`. +- Added locked product boundary to `direction.md`. +- Marked `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` as historical reference. +- Marked `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` as historical reference. +- Marked `openspec/changes/workspace-reimplementation-roadmap/` as historical + reference. +- Marked `workspace-apply-repo-slice` and `workspace-verify-and-archive` as + deferred until initiative-linked repo-local changes exist. + +Research findings: + +- Current workspace setup, link, relink, list, open, update, and doctor behavior + is useful beta local-view infrastructure and should be preserved. +- Live specs describe current workspace-planning behavior. They should not be + rewritten during the initial direction lock; initiative artifacts should carry + future product intent until behavior changes. +- Existing runtime behavior should remain intact until initiatives and linked + repo-local changes can replace workspace-level planning. + +Verification: + +- `git diff --check` passed after the initial direction-lock edits. +- `openspec validate workspace-reimplementation-roadmap --no-interactive`, + `openspec validate workspace-apply-repo-slice --no-interactive`, and + `openspec validate workspace-verify-and-archive --no-interactive` failed + because those existing active changes have no spec deltas. That predates the + disposition wording and is tracked as an active-change cleanup question. + +## 2026-05-21 Initiative Entry Point + +Added `README.md` as the initiative entry point and linked it from +`.initiative.yaml`. + +The README explains: + +- this initiative is the source of product intent +- the reading order for direction, roadmap, tasks, decisions, questions, and + work items +- specs remain the current behavioral contract behind the code +- specs should not be rewritten for future intent until behavior changes + +Updated `work-items/01-lock-the-direction/tasks.md` to mark the initiative +source-of-intent review complete. + +## 2026-05-21 Historical Workspace Roadmap Review + +Reviewed the historical workspace reimplementation entry points: + +- `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` +- `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` +- `openspec/changes/workspace-reimplementation-roadmap/README.md` +- `openspec/changes/workspace-reimplementation-roadmap/proposal.md` +- `openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md` + +Added a guard near the top of +`openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` stating +that the remaining sections are historical POC follow-up direction and should +not be treated as active implementation guidance. + +The roadmap README and handoff prompt already direct agents to the initiative +direction first and warn not to continue the old flat sibling queue unless a +later initiative-linked repo-change design reactivates it. + +## 2026-05-21 Active Workspace Proposal Review + +Reviewed active workspace proposal artifacts: + +- `workspace-reimplementation-roadmap` +- `workspace-agent-guidance` +- `workspace-apply-repo-slice` +- `workspace-verify-and-archive` + +Added small notes to `workspace-apply-repo-slice` and +`workspace-verify-and-archive` clarifying that the remaining proposal sections +are preserved for later reference, not discarded, and should become relevant +again after initiatives and initiative-linked repo-local changes exist. + +Left `workspace-agent-guidance` untouched because it already has unrelated +worktree edits and should be handled as a separate active-change disposition +decision. + +## 2026-05-21 User-Facing Docs Decision + +Decision: Do not update `docs/cli.md` as part of the initial direction lock +unless it misrepresents current user-facing behavior. + +Reasoning: + +- The direction lock is for contributors and agents deciding what to build next. +- User-facing docs should describe current CLI behavior, not future initiative + intent. +- Initiatives do not have a CLI surface yet, so announcing the pivot in user + docs would draw attention to an internal product direction before users can act + on it. + +Revisit user-facing docs when initiative or context-store commands exist, or if +current docs promise unavailable workspace apply, verify, or archive behavior. + +Verification: + +- `git diff --check` passed. +- No files under `openspec/specs/` or `schemas/workspace-planning/` were + modified in this pass. + +## 2026-05-21 Active Change Disposition + +Decision: Keep the active workspace changes as deferred reference placeholders. + +Rationale: + +- Workspace agent guidance, apply, verify, and archive are still expected to + matter after initiative infrastructure exists. +- The immediate focus should be context stores, initiatives, and + initiative-linked repo-local changes. +- Keeping the proposals preserves research and continuity without making them + the next implementation queue. + +Follow-up: + +- Revisit the deferred workspace changes after initiative-linked repo-local + changes define the durable handoff model. + +## Final Item 1 State + +Item 1 is complete. + +What is locked: + +- Initiative artifacts are the source of product intent for context stores, + collections, initiatives, workspaces, and repo-local changes. +- Specs and schemas remain the current behavioral contract and were not edited + for future intent. +- Historical workspace roadmap artifacts remain available as reference, not as + the active shipping queue. +- Deferred workspace changes remain active reference placeholders because their + domains are expected to matter after initiative infrastructure exists. +- User-facing docs were intentionally left unchanged unless they misrepresent + current behavior. + +Remaining risks: + +- `openspec list` still shows deferred workspace changes as active no-task + changes. This is intentional for now but may remain visually noisy. +- `workspace-agent-guidance` has unrelated worktree edits and should be handled + carefully before any future commit or archive decision. +- Future agents still need to read the initiative README first; the historical + workspace docs are safer now, but still contain useful old lifecycle details + deeper in the file. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md new file mode 100644 index 0000000000..05a15c5a82 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md @@ -0,0 +1,90 @@ +# Work Item 01: Lock The Direction + +## Goal + +Make the workspace-to-initiative pivot explicit enough that future agents and +contributors do not continue implementing the older "workspace owns the plan" +model. + +The locked model is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Direction + +This work item is a non-spec direction pass, not a runtime removal. + +Specs should continue to describe the current behavioral contract behind the +code. Product intent, roadmap decisions, and future direction should live in the +initiative artifacts until a later implementation change intentionally updates +behavior and its specs together. + +Keep: + +- workspace setup, link, relink, list, open, update, and doctor +- linked repos and folders as local planning context +- workspace-local skills as local agent guidance +- "workspace visibility is not change commitment" + +Mark as transitional: + +- workspace-level `changes/` planning +- `workspace-planning` schema +- workspace-scoped status/instructions compatibility + +Defer: + +- workspace apply, verify, and archive as first-class lifecycle commands +- branch/worktree orchestration +- strong cross-repo validation +- dependency graph enforcement + +Supersede: + +- workspace as the durable shared planning home +- workspace-level planning artifacts as the canonical cross-repo plan +- workspace change planning as the long-term source of truth + +## Files To Review Now + +- `openspec/initiatives/context-store-and-initiatives/*.md` +- `openspec/initiatives/context-store-and-initiatives/work-items/**/*.md` +- `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` +- `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` +- `openspec/changes/workspace-reimplementation-roadmap/*` +- active `openspec/changes/workspace-*` proposals +- `docs/cli.md` + +## Files To Leave Alone For Now + +- `openspec/specs/**/*.md` +- `schemas/workspace-planning/**` + +Those files should change only when we intentionally change behavior or create a +repo-owned implementation change that updates the relevant behavioral contract. + +## Non-Goals + +- Do not remove current workspace-planning runtime behavior. +- Do not delete the `workspace-planning` schema. +- Do not add CLI deprecation warnings until the initiative replacement exists. +- Do not implement context stores in this work item. +- Do not edit OpenSpec specs as part of the initial direction lock. + +## Done When + +- Initiative artifacts clearly carry the product intent and roadmap decisions. +- Historical workspace roadmap artifacts no longer read as the active shipping + queue. +- User-facing docs describe current workspaces as local views where that does + not contradict current behavior. +- Existing workspace-planning behavior is clearly treated as current behavior, + not the future product model, in initiative and roadmap artifacts. +- Workspace apply, verify, and archive are clearly deferred. +- Fresh agents can identify the initiative direction as the source of truth. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md new file mode 100644 index 0000000000..d04b60a620 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md @@ -0,0 +1,44 @@ +# Work Item 01 Tasks + +## Tracking Setup + +- [x] Create initiative-level `tasks.md`, `decisions.md`, and `questions.md`. +- [x] Create `work-items/01-lock-the-direction/`. +- [x] Record why roadmap implementation is tracked inside the initiative instead + of creating a new OpenSpec change. + +## Direction Lock Already Captured + +- [x] Add locked disposition to `roadmap.md`. +- [x] Add locked product boundary to `direction.md`. +- [x] Mark `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` as historical reference. +- [x] Mark `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` as historical reference. +- [x] Mark `workspace-reimplementation-roadmap` as historical reference. +- [x] Mark `workspace-apply-repo-slice` as deferred. +- [x] Mark `workspace-verify-and-archive` as deferred. + +## Non-Spec Direction Pass + +- [x] Keep OpenSpec specs unchanged until behavior changes. +- [x] Review initiative artifacts for a clear source-of-intent story. +- [x] Review historical workspace roadmap artifacts for any remaining language + that tells agents to continue the old shipping queue. +- [x] Review active workspace proposal artifacts for any remaining language that + presents workspace apply, verify, or archive as next. +- [x] Decide whether user-facing docs need changes now; default to no unless + they misrepresent current behavior. +- [x] Record a decision that specs remain current behavioral contracts, while + initiative docs carry future product intent. + +## Active Change Disposition + +- [x] Decide whether `workspace-agent-guidance` should be reframed, closed, or + kept as a local-view guidance item. +- [x] Decide whether no-task deferred workspace changes should stay active, + move to archive, or be represented only by initiative work items. + +## Verification + +- [x] Run `git diff --check`. +- [x] Confirm no OpenSpec specs were modified in this pass. +- [x] Record evidence in `evidence.md`. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md new file mode 100644 index 0000000000..85d0b0a03b --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md @@ -0,0 +1,68 @@ +# Stabilize Workspace As Local View Evidence + +## Direction Evidence + +`direction.md` says the durable shared object is a synced context store, with +initiatives as the first major collection. It defines workspaces as local +working views over context stores and repos, and repo changes as repo/team-owned +implementation plans. + +The locked product boundary supersedes the older model where a workspace-level +`changes/` tree owned the canonical shared cross-repo plan. Existing +workspace-planning behavior can remain as beta or legacy infrastructure, but it +should not steer new lifecycle design. + +## Subagent Research + +Implementation research found that workspace setup, link, relink, list, open, +update, and doctor already mostly behave like local-view infrastructure: + +- shared link names live in workspace state +- machine-local paths and opener/skill state live in local state +- `workspace open` launches linked folders as a local working set +- linked repos are treated as context for workspace-planning commands +- `workspace update` refreshes workspace-local skills and leaves linked repos + untouched + +Guidance research found that the generated `AGENTS.md` block is the most +important mismatch because it still frames the workspace as planning across +linked repos and says to use `changes/` for workspace-level planning. + +Test research found strong current coverage for setup/list/doctor, link/relink, +open, update, artifact placement, and workspace-planning guards. The targeted +workspace/artifact test slice passed, as did the skill-template parity test. + +## Main Risk + +If generated workspace guidance continues to recommend workspace-level +`changes/`, agents may treat the workspace as the durable shared planning +object even though the initiative direction assigns durable coordination to +initiatives and implementation planning to repo-local changes. + +## Implementation Evidence + +The first implementation slice updates the generated workspace `AGENTS.md` +guidance and makes `workspace update` refresh the workspace-local open surface. +It also updates workspace-planning action context so beta workspace artifacts are +reported as `workspace-local` compatibility context instead of the source of +truth. + +Doctor/status review found that local path mappings, unresolved links, repair +steps, malformed local state, missing local state, repo specs paths, and skill +drift warnings are already covered. Normal installed-skill summaries are +deferred for now; the current slice only updates stale `workspace update` +wording so it matches the guidance refresh behavior. + +Verification: + +- `pnpm run build` +- `pnpm exec vitest run test/commands/workspace.test.ts test/commands/artifact-workflow.test.ts test/core/workspace/foundation.test.ts` +- `pnpm run lint` +- `git diff --check` + +## Closeout Evidence + +Live docs no longer describe workspaces as durable planning homes or as the +canonical place for cross-repo planning. Historical and deferred workspace +artifacts remain as reference material, with active deferred proposals labeled +so they do not steer the next implementation slice. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md new file mode 100644 index 0000000000..147b94556a --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md @@ -0,0 +1,80 @@ +# Stabilize Workspace As Local View + +## Status + +Complete for the current local-view stabilization slice. Remaining workspace +planning/apply/verify/archive behavior stays deferred until initiative-linked +repo-local changes exist. + +## Source Of Truth + +Start from `../direction.md`. + +The relevant model is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Goal + +Keep workspace setup, link, relink, list, open, update, and doctor useful while +making it clear that a workspace is a regenerable machine-local view, not the +durable coordination object. + +## Agreed Guidance Direction + +Generated workspace guidance should route agents by ownership: + +- Use the workspace to open the local view of coordinated work. +- Use initiatives for durable cross-team or cross-repo intent, decisions, + requirements, and coordination context. +- Use repo-local OpenSpec changes for implementation plans owned by a repo or + team. +- Use linked repos and folders to inspect context, understand ownership, and + make edits in the place that owns the work. +- Keep workspace-local files focused on local paths, opener state, agent setup, + and other machine-specific view state. +- Use OpenSpec workspace commands instead of hand-editing + `.openspec-workspace/*.yaml`. +- If a workspace contains legacy or beta workspace-level planning files, treat + them as compatibility context unless the user explicitly asks to use that beta + flow. + +## Guidance To Stop Reinforcing + +Do not tell agents to use workspace-level `changes/` as the planning home for +coordinated work. That reinforces the superseded model where a workspace-level +`changes/` tree owned the canonical shared cross-repo plan. + +Existing workspace-planning behavior may remain as beta or legacy +infrastructure, but it should not steer new lifecycle design. + +## Likely Repo Slice + +- Reword generated workspace guidance in + `src/core/workspace/open-surface.ts`. +- Update focused guidance tests. +- Make `workspace update` refresh the guidance block for existing workspaces. +- Keep specs untouched until a behavior change intentionally updates them. + +## Closeout + +Implemented: + +- generated workspace guidance now routes work by ownership +- `workspace update` refreshes workspace-local guidance/open-surface files and + managed agent skills +- workspace-planning action context treats beta workspace artifacts as + `workspace-local` compatibility context +- live docs describe workspaces as local views instead of durable planning homes + +Deferred: + +- normal doctor installed-skill inventory +- workspace apply, verify, and archive +- initiative-linked repo-local change orchestration diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md new file mode 100644 index 0000000000..5a07a1f579 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md @@ -0,0 +1,23 @@ +# Stabilize Workspace As Local View Tasks + +- [x] Research current workspace runtime, guidance, and test coverage. +- [x] Re-anchor guidance direction in `direction.md`. +- [x] Decide that generated guidance should route durable coordination to + initiatives and implementation planning to repo-local changes. +- [x] Decide that generated guidance should stop recommending workspace-level + `changes/` as the planning home. +- [x] Decide that `workspace update` refreshes the generated guidance block + for existing workspaces. +- [x] Update workspace-planning action context so beta workspace artifacts are + compatibility context, not the source of truth. +- [x] Decide to defer normal doctor skill summaries until users need an + installed-skill inventory. +- [x] Update `workspace update` wording to include workspace-local guidance and + agent skills. +- [x] Define the minimal doctor/status improvement for local paths, unresolved + links, and installed agent skills. +- [x] Identify the focused code/test files for the implementation slice. +- [x] Run the targeted workspace and artifact workflow test slice before + landing implementation. +- [x] Close out live docs wording that still framed workspaces as durable + planning homes. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md new file mode 100644 index 0000000000..402c2f8fbe --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md @@ -0,0 +1,43 @@ +# Add Context Store Foundation Evidence + +## Research Summary + +Existing OpenSpec patterns point toward a small explicit foundation: + +- Global data uses XDG/platform locations from `getGlobalDataDir()`. +- Workspace registries are machine-local convenience indexes under global data. +- Workspace portable state uses versioned YAML and strict Zod validation. +- Existing read/write helpers validate state before writing and use + `FileSystemUtils.writeFile()` to create parent directories. +- Schema/backend-style code favors small explicit adapters and registries over + heavy framework abstractions. + +## Decisions + +- The first context-store backend is Git/local checkout config only. +- OpenSpec records where the local checkout lives; it does not decide where real + team stores are cloned by default. +- The local registry is not source of truth. It is a machine-local index. +- Store-root metadata is portable source-of-identity for the synced store. +- Initiatives and collections are later consumers, not part of the store + foundation. +- A thin facade should hide raw registry/metadata writes before initiative CLI + wiring. + +## Implementation Evidence + +- `src/core/context-store/registry.ts` registers Git/local context stores, + lists local registry entries, and resolves registered stores with metadata id + validation. +- `src/core/context-store/index.ts` exports the facade. +- `test/core/context-store/registry.test.ts` covers registration, registry + merge/update, metadata mismatch rejection, listing, resolution, missing or + mismatched metadata, and initiative collection mounting from a resolved root. + +## Verification + +- `pnpm exec vitest run test/core/context-store/foundation.test.ts` +- `pnpm exec vitest run test/core/context-store/registry.test.ts` +- `pnpm run build` +- `pnpm run lint` +- `git diff --check` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md new file mode 100644 index 0000000000..2ea0c27afd --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md @@ -0,0 +1,85 @@ +# Add Context Store Foundation + +## Status + +Registration/resolution facade implemented. + +## Source Of Truth + +Start from `../direction.md`. + +The relevant model is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Goal + +Add the smallest core foundation for context stores without making the store +layer know about initiatives, collections, workspaces, or repo-local changes. + +## Locked Direction + +- Support one backend for the first slice: a Git/local checkout backend. +- Treat the actual context store root as a user-chosen local Git checkout or + synced folder. +- Do not hide real team context stores under XDG data by default. +- Store the machine-local registry under global data: + `$XDG_DATA_HOME/openspec/context-stores/registry.yaml`. +- Store portable context-store identity inside the store root: + `<store-root>/.openspec-store/store.yaml`. +- Start with backend identity/config, strict validation, path helpers, and + registry/metadata read-write helpers. +- Add a thin registration/resolution facade before initiative CLI wiring so + callers do not manipulate raw registry and metadata YAML directly. +- Do not reimplement the TypeScript or Node filesystem APIs as the public store + interface. +- Do not add initiative, collection, workspace-open, sync, pull, push, or CLI + behavior in this slice. + +## Initial Shape + +Machine-local registry: + +```yaml +version: 1 +stores: + acme-context: + backend: + type: git + local_path: /Users/me/repos/acme-context + remote: git@github.com:acme/context.git + branch: main +``` + +Portable metadata in the store root: + +```yaml +version: 1 +id: acme-context +``` + +## Likely Repo Slice + +- Add `src/core/context-store/foundation.ts`. +- Add `src/core/context-store/registry.ts`. +- Add `src/core/context-store/index.ts`. +- Export the core context-store foundation from `src/core/index.ts`. +- Add focused tests under `test/core/context-store/`. +- Keep specs untouched until a behavior/API contract is deliberately surfaced. + +## Implemented Facade Slice + +- Added `registerContextStore(...)`. +- Added `listRegisteredContextStores(...)`. +- Added `resolveRegisteredContextStore(...)`. +- Registration writes portable store metadata when missing, validates existing + metadata when present, and merges/updates the machine-local registry. +- Resolution validates that the registry id matches the store-root metadata id. +- No Git clone, pull, push, sync, workspace state, collection manifest, or CLI + behavior was added. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md new file mode 100644 index 0000000000..4aeddc285d --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md @@ -0,0 +1,17 @@ +# Add Context Store Foundation Tasks + +- [x] Research existing config, registry, file-system, and schema/backend + patterns. +- [x] Decide to start with Git/local backend identity only, not a generic file + API. +- [x] Decide that real context store roots are user-chosen Git checkouts or + synced folders. +- [x] Decide that the local registry lives under global data and portable store + metadata lives inside the store root. +- [x] Add context-store foundation types, path helpers, parse/serialize, and + read/write helpers. +- [x] Add focused tests for validation, paths, registry roundtrip, metadata + roundtrip, and Git/local backend path resolution. +- [x] Run targeted verification. +- [x] Decide registration/resolution facade should precede initiative CLI. +- [x] Add context-store registration/list/resolve facade and tests. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md new file mode 100644 index 0000000000..fe751b6b91 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md @@ -0,0 +1,77 @@ +# Add Collection Foundation Evidence + +## Research Summary + +Subagent and local review converged on the same direction: + +- Item 4 should define the boundary between store identity and product-specific + content meaning. +- The collection layer should own mounted namespaces and logical path fences. +- The context-store layer should stay content-agnostic. +- Initiative CRUD and initiative file shape belong to Item 5. +- A runtime injected registry is enough for now; persisted manifests and dynamic + plugins are premature. +- A thin registration facade should hide metadata and local registry writes, but + Item 4 should not depend on that facade. + +## Clean-Code Notes + +- Use module boundaries and mounted objects to carry context. +- Prefer `validateMount`, `parseCollectionPath`, `createCollectionRegistry`, + and `mountCollections` inside the collection module. +- Avoid public helper names that stack every concept together, such as + `validateContextStoreCollectionRelativePath`. +- Keep path resolution pure and lexical until a future write-capable layer + deliberately handles symlinks, canonical parent paths, and backend behavior. +- Keep persisted YAML shape below the public setup surface. Runtime/public + handles should use camelCase fields such as `storeRoot`; persisted backend + state can continue to use `local_path`. + +## Chosen Pattern + +Use a two-step pattern: + +```ts +const store = await registerContextStore({ + id: "acme-context", + backend: gitLocalBackend({ + localPath: "/Users/me/repos/acme-context", + remote: "git@github.com:acme/context.git", + branch: "main", + }), +}); + +const collections = createCollectionRegistry([ + { id: "initiatives", mount: "initiatives" }, +]); + +const mounted = mountCollections({ + storeRoot: store.storeRoot, + collections, +}); +``` + +For Item 4 itself, `mountCollections({ storeRoot, collections })` is the +canonical API. One-call setup facades, store lifecycle objects, builder DSLs, +and initiative-specific setup presets are deferred. + +## Implementation Evidence + +- `src/core/collections/runtime.ts` defines runtime collection + definitions, registries, mounted collection contexts, logical path parsing, + and mount/path resolution. +- `src/core/collections/index.ts` exports the collection module, and + `src/core/index.ts` re-exports it for core consumers. +- `test/core/collections/runtime.test.ts` covers mount and id validation, + logical path parsing, duplicate id/mount rejection, Windows-style roots, + `createHandle(context)`, no filesystem creation, and generic `initiatives/` + mounting. + +## Verification + +- `pnpm exec vitest run test/core/collections/runtime.test.ts` +- `pnpm run build` +- `pnpm exec vitest run test/core/collections/runtime.test.ts test/core/context-store/foundation.test.ts test/core/planning-home.test.ts` +- `pnpm exec vitest run test/utils/file-system.test.ts` +- `pnpm run lint` +- `git diff --check` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md new file mode 100644 index 0000000000..6475df3a88 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md @@ -0,0 +1,198 @@ +# Add Collection Foundation + +## Status + +First implementation slice implemented. + +## Source Of Truth + +Start from `../../direction.md`. + +The relevant model is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Goal + +Add the smallest collection foundation that lets product-specific content +systems mount inside a context store without making the context-store layer know +what those systems mean. + +## Locked Direction So Far + +- Treat Item 4 as a mount/path foundation, not a collection runtime. +- Keep collection composition runtime-only and dependency-injected. +- Keep context-store registration separate from runtime collection mounting. +- Use a future thin registration facade for metadata/registry setup instead of + showing raw registry or metadata state writes in public examples. +- Do not add a persisted collection manifest yet. +- Do not add CLI behavior yet. +- Do not add generic `read`, `write`, `list`, or `delete` helpers. +- Do not add initiative file shape, initiative CRUD, or initiative validation + yet. +- Prove `initiatives/` can mount through generic collection definitions, not + through initiative-specific context-store logic. + +## Naming Direction + +Use the module/object boundary to carry context instead of growing helper names. + +Use a focused generic module such as `src/core/collections/runtime.ts` with +short names: + +```ts +validateCollectionId(id); +validateMount(mount); +parseCollectionPath(input); + +createCollectionRegistry(...); +mountCollections(...); +``` + +Prefer mounted objects for context-aware operations: + +```ts +const mounted = collections.require("initiatives"); + +mounted.resolvePath("launch-billing-flow/initiative.yaml"); +mounted.toStorePath("launch-billing-flow/initiative.yaml"); +``` + +Avoid names like `validateContextStoreCollectionRelativePath`. They indicate +that too much context has leaked into a standalone helper name. + +## Minimal API Shape + +The first slice should stay close to this: + +```ts +interface CollectionDefinition<THandle = unknown> { + id: string; + mount: string; + metadata?: CollectionMetadata; + hooks?: CollectionHooks; + createHandle?: (context: MountedCollectionContext) => THandle; +} + +interface MountedCollectionContext { + storeRoot: string; + collectionId: string; + mount: string; + mountRoot: string; + resolvePath(relativePath?: string): string; + toStorePath(relativePath?: string): string; +} + +interface MountedCollection<THandle = unknown> { + collectionId: string; + mount: string; + mountRoot: string; + context: MountedCollectionContext; + handle: THandle | undefined; +} +``` + +Use `id` on definitions, but `collectionId` on mounted handles and contexts so +domain object IDs such as initiative IDs do not collide with collection type IDs. + +## Setup And Mounting Pattern + +Use two separate layers: + +1. A context-store registration facade for setup. +2. A pure runtime collection mounting API for Item 4. + +Registration should hide persisted YAML details: + +```ts +const store = await registerContextStore({ + id: "acme-context", + backend: gitLocalBackend({ + localPath: "/Users/me/repos/acme-context", + remote: "git@github.com:acme/context.git", + branch: "main", + }), +}); +``` + +The registration facade can call lower-level helpers such as backend config +normalization, metadata writes, and local registry writes internally. Public +examples should not call raw `writeContextStoreMetadataState(...)`, +`writeContextStoreRegistryState(...)`, or expose persisted snake_case backend +state such as `local_path`. + +Item 4 mounting should stay independent of registration and accept only the +authority it needs: + +```ts +const collections = createCollectionRegistry([ + { id: "initiatives", mount: "initiatives" }, +]); + +const mounted = mountCollections({ + storeRoot: store.storeRoot, + collections, +}); + +mounted.require("initiatives").resolvePath( + "launch-billing-flow/initiative.yaml" +); +``` + +Prefer `mountCollections({ storeRoot, collections })` as the canonical first +API. Passing a whole store handle can wait until there is a real need. + +## Path Direction + +- Mount names are single-segment kebab-case folder names such as `initiatives`, + `decisions`, or `api-catalog`. +- Collection-relative paths are logical portable paths inside a mount. +- The path resolver is lexical only. It proves that a logical path belongs under + a collection mount; it does not claim to be a filesystem security sandbox. +- Future write-capable helpers must revisit symlink and canonical parent-path + handling before touching disk. + +Reject: + +- empty mounts +- `.` +- `..` +- hidden/reserved mounts such as `.openspec-store` +- absolute paths +- Windows drive paths +- UNC paths +- NUL bytes +- traversal segments +- sibling-prefix escapes + +## Deferred + +- Store-level collection config files. +- Dynamic plugin loading. +- One-call `setupContextStore({ id, backend, collections })` APIs. +- `createStore(...).setup()` lifecycle APIs. +- Builder-style setup DSLs. +- Initiative-specific setup presets in the generic context-store layer. +- Template override search paths. +- Rich validation execution. +- Agent guidance generation. +- Workspace integration. +- Git sync, commits, pull, push, watch, or conflict behavior. + +## Implemented Slice + +- Added a pure runtime collection module at + `src/core/collections/runtime.ts`. +- Exported the module through `src/core/collections/index.ts` and + `src/core/index.ts`. +- Added focused tests under `test/core/collections/runtime.test.ts`. +- Proved a generic `{ id: "initiatives", mount: "initiatives" }` definition can + mount and resolve paths without initiative-specific store logic. +- Kept validation/template hooks as inert extension fields for now; rich hook + execution remains deferred. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md new file mode 100644 index 0000000000..215b8092da --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md @@ -0,0 +1,14 @@ +# Add Collection Foundation Tasks + +- [x] Research what Item 4 needs to decide. +- [x] Compare collection model options. +- [x] Run clean-code and design-pattern review. +- [x] Decide to keep Item 4 as a runtime mount/path foundation. +- [x] Decide to avoid long context-stacked helper names. +- [x] Decide to separate context-store registration from runtime collection + mounting. +- [x] Define exact collection mount and path rules. +- [x] Define the minimal runtime registry and mounted collection API. +- [x] Implement collection foundation helpers and tests. +- [x] Prove `initiatives/` can mount without store-specific initiative logic. +- [x] Run targeted verification. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md new file mode 100644 index 0000000000..7b9e7ab036 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md @@ -0,0 +1,99 @@ +# Ship Initiative MVP Evidence + +## Research Summary + +- Initiative code should live in `src/core/collections/initiatives/`, outside + `src/core/context-store/`. +- Initiative APIs should consume a mounted `initiatives` collection from Item 4 + rather than raw context-store roots. +- The first coding slice should lock metadata and templates before mounted + create/list operations. +- Visible `initiative.yaml` is preferred for the new shared initiative model. +- `links.yaml` should not exist in the initiative MVP. Repo-change wiring is a + workspace/local coordination concern to revisit later. +- Read/show, update, and delete have extra policy risk, so create/list should + come before broader lifecycle behavior. +- The first mounted operation slice should do create/list only. A full + `readInitiative` API is deferred until the return shape is clearer. + +## Decisions + +- Use `src/core/collections/initiatives/` for initiative-domain code. +- Do not put initiative semantics into `src/core/context-store/`. +- Add `initiative.yaml` strict parse/serialize helpers. +- Generate Markdown files up front, but do not validate Markdown content beyond + existence/templates in the first pass. +- Defer workspace opening, repo resolution, status dashboards, sync, linked + change lifecycle, `links.yaml`, `contracts/`, and CLI behavior. +- Detect initiatives by valid `initiative.yaml`: missing means ignore, invalid + means fail loudly, and the YAML `id` must match the folder name. + +## Suggested First Coding Slice + +Add: + +- `src/core/collections/initiatives/schema.ts` +- `src/core/collections/initiatives/templates.ts` +- `src/core/collections/initiatives/operations.ts` +- `src/core/collections/initiatives/index.ts` +- focused tests under `test/core/collections/initiatives/` + +Cover: + +- constants for initiative file names +- `validateInitiativeId` +- strict `initiative.yaml` parse/serialize +- create/list operations through a mounted `initiatives` collection +- template builders for `requirements.md`, `design.md`, `decisions.md`, + `questions.md`, and `tasks.md` +- tests for valid and invalid metadata, invalid IDs, unknown YAML fields, + required `created`, and generated template names/content shape + +## Implementation Evidence + +- `src/core/collections/initiatives/schema.ts` defines initiative constants, + strict persisted `initiative.yaml` parsing/serialization, required + `created`, bounded JSON-like metadata, statuses, and portable kebab-case + initiative IDs. +- `src/core/collections/initiatives/templates.ts` defines deterministic default + Markdown file builders for requirements, design, decisions, questions, and + tasks. +- `src/core/collections/initiatives/index.ts` exports the initiative + schema/template surface inside the initiative module only. +- `src/core/collections/initiatives/operations.ts` creates MVP initiative + folders and lists initiative states using the valid-`initiative.yaml` + detection rule. +- `src/core/collections/index.ts` exports the initiative module now that it has + a mounted operation API. +- `test/core/collections/initiatives/schema.test.ts` covers file constants, + no `links.yaml`, ID validation, strict YAML behavior, required `created`, + default owners/metadata, metadata validation, and serialization round trips. +- `test/core/collections/initiatives/templates.test.ts` covers generated + Markdown file names, deterministic ordering, trailing newlines, and expected + section headings. +- `test/core/collections/initiatives/operations.test.ts` covers create, list, + duplicate protection, cleanup on partial write failure, missing + `initiative.yaml` ignored, invalid `initiative.yaml` failure, and folder/id + mismatch failure. +- `src/core/context-store/registry.ts` was added as the next integration + enabler before CLI wiring. +- `src/commands/initiative.ts` adds `openspec initiative create/list` as a thin + CLI adapter over the context-store facade and mounted initiatives collection. +- `src/cli/index.ts` registers the initiative command. +- `src/core/completions/command-registry.ts` registers static completion + metadata for `initiative create/list/ls`. +- `test/commands/initiative.test.ts` covers JSON create, `--store-path` list, + human output, selector errors, duplicate create errors, and completion + registry entries. + +## Verification + +- `pnpm exec vitest run test/core/collections/initiatives/schema.test.ts test/core/collections/initiatives/templates.test.ts` +- `pnpm exec vitest run test/core/collections/initiatives/operations.test.ts` +- `pnpm exec vitest run test/core/collections/initiatives/schema.test.ts test/core/collections/initiatives/templates.test.ts test/core/collections/initiatives/operations.test.ts test/core/collections/runtime.test.ts test/core/context-store/foundation.test.ts test/core/planning-home.test.ts` +- `pnpm exec vitest run test/commands/initiative.test.ts` +- `pnpm exec vitest run test/core/context-store/registry.test.ts test/core/collections/initiatives/operations.test.ts test/core/collections/initiatives/schema.test.ts test/core/collections/initiatives/templates.test.ts test/core/collections/runtime.test.ts` +- `pnpm exec vitest run test/commands/workspace.test.ts` +- `pnpm run build` +- `pnpm run lint` +- `git diff --check` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md new file mode 100644 index 0000000000..d6463765cb --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md @@ -0,0 +1,236 @@ +# Ship Initiative MVP + +## Status + +Create/list operation and CLI adapter slices complete. Full read/show, update, +and delete policy is deferred to later agent-first discovery and lifecycle +work. + +## Source Of Truth + +Start from `../../direction.md`. + +The relevant model is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Goal + +Give coordinated work a durable, shared, agent-consumable home inside an +`initiatives/` collection. + +## Roadmap Shape + +Default initiative shape: + +```text +initiatives/<id>/ + initiative.yaml + requirements.md + design.md + decisions.md + questions.md + tasks.md +``` + +Direction also leaves room for later `contracts/` content: + +```text +initiatives/<id>/ + contracts/ +``` + +## Initial Boundaries + +- Initiative code should live outside `src/core/context-store/`. +- Context-store core should not know initiative semantics. +- Initiative APIs should consume a mounted `initiatives` collection from Item 4. +- Repo-local OpenSpec changes remain the implementation artifacts; initiatives + coordinate intent, decisions, questions, and tasks. +- Do not implement workspace opening, repo resolution, status dashboards, sync, + or linked change lifecycle in this item. + +## Locked Direction So Far + +- Put initiative code under `src/core/collections/initiatives/`. +- Export initiatives from `src/core/index.ts` only after a real API exists. +- Use visible `initiative.yaml`, not hidden `.initiative.yaml`, for the runtime + context-store initiative model. Existing roadmap folders may still carry + legacy `.initiative.yaml` progress metadata until that tracker is migrated or + retired. +- Use strict YAML parsing and validation, following the existing foundation + patterns. +- Do not create `links.yaml` in the initiative MVP. Repo-change wiring belongs + to workspace/local coordination work later. +- Keep Markdown validation light; generate useful structure but do not validate + prose content yet. +- Start implementation with initiative schema and template helpers before + mounted collection operations. +- For the first mounted operation slice, add create and list only. Avoid a + broad `readInitiative` API until the shape of "full initiative" is clearer. +- Treat a child folder as an initiative only when it contains a valid + `initiative.yaml`. Missing `initiative.yaml` means "not an initiative"; + invalid `initiative.yaml` means broken shared state and should fail loudly. + +## Deferred From Item 5 + +- Full initiative show/read behavior belongs in agent-first initiative discovery + once the return shape is clearer. +- Metadata update and guarded delete belong in later lifecycle work after + create/list usage has shaped the policy. + +## Initial `initiative.yaml` + +Recommended shape: + +```yaml +version: 1 +id: launch-billing-flow +title: Launch Billing Flow +summary: > + Coordinate the billing launch across product, API, and client surfaces. +status: exploring +created: "2026-05-21" +owners: [] +metadata: {} +``` + +Required: + +- `version` +- `id` +- `title` +- `summary` +- `status` +- `created` + +Defaulted or optional: + +- `owners` +- `metadata` + +Initial statuses: + +- `exploring` +- `active` +- `complete` +- `archived` + +## Initial Markdown Templates + +Create these files up front: + +- `requirements.md`: product intent, accepted requirements, out of scope. +- `design.md`: context, approach, affected areas, dependencies, risks. +- `decisions.md`: accepted decisions with date/title/decision/why/implications. +- `questions.md`: open and resolved questions. +- `tasks.md`: coordination tasks only, not repo implementation tasks. + +Defer `contracts/`, `README.md`, milestones, dependency graphs, external issue +links, workspace path mappings, status dashboards, `links.yaml`, and Markdown +content validation. + +## Likely Repo Slice + +- Add `src/core/collections/initiatives/schema.ts`. +- Add `src/core/collections/initiatives/templates.ts`. +- Add `src/core/collections/initiatives/index.ts`. +- Add focused tests under `test/core/collections/initiatives/`. +- Add types, constants, ID validation, strict `initiative.yaml` + parse/serialize helpers, and default template builders. +- Add create/list mounted collection operations after schema and templates are + locked. +- Keep context-store collection APIs unchanged unless a real integration gap is + found. + +## Implemented Slice + +- Added `src/core/collections/initiatives/schema.ts`. +- Added `src/core/collections/initiatives/templates.ts`. +- Added `src/core/collections/initiatives/index.ts`. +- Added focused tests under `test/core/collections/initiatives/`. +- Exported initiatives through `src/core/collections/index.ts` now that a + mounted operation API exists. +- Kept `links.yaml` out of the initiative MVP file contract. + +## Operation Slice Direction + +- Add `src/core/collections/initiatives/operations.ts`. +- Export initiatives through `src/core/collections/index.ts` now that a mounted + operation API exists. +- `createInitiative` should create exactly the MVP file shape: + `initiative.yaml`, `requirements.md`, `design.md`, `decisions.md`, + `questions.md`, and `tasks.md`. +- `createInitiative` should generate `created` through an injectable date + provider, fail if the initiative folder already exists, and clean up a + partially created folder on write failure. +- `listInitiatives` should inspect immediate child directories under the + mounted `initiatives` collection, ignore folders without `initiative.yaml`, + parse and validate folders with `initiative.yaml`, require + `initiative.yaml.id` to match the folder name, and return initiative states + sorted by id. + +## Implemented Operation Slice + +- Added `src/core/collections/initiatives/operations.ts`. +- Added `createInitiative` for creating the MVP folder shape through a mounted + `initiatives` collection. +- Added `listInitiatives` using the valid-`initiative.yaml` detection rule. +- Exported initiatives through `src/core/collections/index.ts`. +- Added focused operation tests under + `test/core/collections/initiatives/operations.test.ts`. + +## Next Integration Enabler + +Before adding `openspec initiative create/list`, add a context-store +registration/resolution facade so CLI code can resolve a named store and mount +the initiatives collection without exposing raw registry or metadata YAML. + +## CLI Adapter Direction + +Add the first initiative CLI surface as a thin adapter over the mounted +collection operations: + +```bash +openspec initiative create <id> --store <store-id> --title <title> --summary <summary> +openspec initiative create <id> --store-path <path> --title <title> --summary <summary> +openspec initiative list --store <store-id> +openspec initiative list --store-path <path> +``` + +Use `initiative create/list` as a deliberate noun namespace, similar to +`workspace` and `schema`, even though newer OpenSpec conventions generally +prefer verb-first top-level commands. The stricter alternative would spread +initiative behavior across `new initiative` and global `list` flags, which is a +larger surface for this slice because initiative commands must resolve a +context store. + +Keep store selection explicit in the first CLI slice. Require either +`--store <id>` or `--store-path <path>`, reject both together, and do not add +current-directory discovery, single-store auto-selection, an interactive picker, +a global default store, or workspace selected-store state yet. + +Because shell completions are manually registered, adding the runtime command +also requires adding `initiative create/list/ls` to `COMMAND_REGISTRY`. Keep +completion support static for now: command names and flags only, with no dynamic +store-id or initiative-id completion. + +## Implemented CLI Adapter Slice + +- Added `src/commands/initiative.ts`. +- Registered `openspec initiative create` and `openspec initiative list` from + the top-level CLI. +- Added `openspec initiative ls` as an alias for list. +- Required explicit context-store selection through `--store <id>` or + `--store-path <path>`. +- Rejected conflicting `--store` and `--store-path` selectors. +- Returned workspace-style JSON payloads with a top-level `status` diagnostics + array. +- Added static shell completion metadata for `initiative create/list/ls`. +- Added focused command tests under `test/commands/initiative.test.ts`. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md new file mode 100644 index 0000000000..197a333bf1 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md @@ -0,0 +1,21 @@ +# Ship Initiative MVP Tasks + +- [x] Create Item 5 work-item tracking notes. +- [x] Research initiative shape, API, module placement, and first slice. +- [x] Decide where initiative code lives. +- [x] Decide required `initiative.yaml` metadata. +- [x] Decide no initiative `links.yaml` in the MVP. +- [x] Decide first coding slice starts with initiative schema/templates before operations. +- [x] Add initiative schema helpers and tests. +- [x] Add default initiative templates. +- [x] Run targeted verification for schema/templates. +- [x] Decide create/list-only operation slice. +- [x] Add create/list mounted initiative operations and tests. +- [x] Run targeted verification for operations. +- [x] Research initiative CLI adapter gaps. +- [x] Decide explicit context-store selection for first CLI slice. +- [x] Document noun-command and manual-completion tradeoffs. +- [x] Add `openspec initiative create/list` CLI adapter. +- [x] Register static shell completions for initiative commands. +- [x] Add focused CLI tests for create/list, selection errors, and completions. +- [x] Run targeted verification for the initiative CLI adapter. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md new file mode 100644 index 0000000000..6b0bc32b2e --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md @@ -0,0 +1,97 @@ +# Add Minimal Context Store UX Evidence + +## Conversation Decisions + +- The next roadmap step should not jump straight to repo-local change linking + or workspace initiative opening. +- Teams first need a simple way to create or register the shared context store + that holds initiatives. +- The workflow is agent-first: the user prompts an agent, and the agent uses CLI + primitives to discover stores and initiatives. +- `context-store` should be the top-level command namespace for now. It is more + explicit for agents than `store`, and `store` can remain shorthand in scoped + flags such as `initiative list --store <id>`. +- A store can start as a local Git-backed folder. OpenSpec can help create the + folder, write metadata, register it locally, and optionally initialize Git. +- When setup does not receive `--path`, it should create or use `./<id>`. This + keeps the real shared store visible and avoids hiding it under global data. +- Using the current directory should require explicit `--path .`. +- If a user registers an existing folder or clone, the default store id can be + the repo or folder name. +- Portable `.openspec-store/store.yaml` metadata should be checked in and should + not include local paths. +- `.openspec-store/store.yaml` is the identity file itself, not a bundle beside + another checked-in metadata file. It should contain only `version` and `id` + for now. +- Future backend, sync, collection, permission, or policy config should not be + added to `store.yaml` by default. +- The local registry maps store ids to local paths on one machine. +- Remote-url clone/setup sugar is useful but can wait. +- `initiative list` should list all registered stores by default; `--store` + should filter. +- Interactive setup should prompt for Git initialization and default to yes + when no explicit Git flag is provided. +- Non-interactive, JSON, `--init-git`, and `--no-init-git` setup should not + prompt. +- `context-store register` should be idempotent for the same id/path and fail + for the same id with a different path until a future explicit replacement + option exists. +- `context-store list` should stay a simple registry index and should not show + health warnings. +- `context-store doctor` owns health diagnostics. The first slice should check + registry/path/metadata and cheap Git repository presence, not dirty state, + branch, remote, sync, pull/push, or conflicts. +- `initiative list` should allow partial success in all-store mode: show + initiatives from readable stores and print one small warning pointing to + `context-store doctor` when other registered stores cannot be read. +- Filtered `initiative list --store` and explicit `--store-path` should fail + directly when the selected store cannot be read. +- Partial success should exit 0 with warning diagnostics in JSON. Total failure + should exit nonzero. +- Register id inference should use the repo/folder name as-is with normal + context-store id validation. Do not add normalization in this slice. +- Setup should reject non-empty folders without context-store metadata for now. +- Registry conflicts should fail when the same id points at a different path or + the same path is already registered under a different id. +- Empty states should stay simple: no stores registered for `context-store list` + and `doctor`; no initiatives found because no stores are registered for + `initiative list`. +- Static shell completion metadata is now part of the shipped command surface; + dynamic store-id and initiative-id completions remain deferred. + +## Risks To Check Before Implementation + +- Existing command naming conventions may prefer verb-first flows, while + context-store commands are naturally noun namespaced. +- Shell completions are manually registered; keep future command additions in + `src/core/completions/command-registry.ts` with focused registry tests. +- Human output should match existing compact CLI output patterns. +- JSON output should be stable enough for agents without over-modeling future + sync or remote behavior. + +## Implementation Evidence + +- `src/commands/context-store.ts` adds the `context-store` command namespace + with setup, register, list, and doctor subcommands. +- `src/cli/index.ts` registers the context-store command. +- `src/commands/context-store.ts` keeps strict CLI setup/register policy in the + command layer while reusing context-store foundation helpers. +- `src/commands/initiative.ts` now lets `initiative list` search all registered + stores by default, keeps `--store` as a filter, preserves `--store-path`, and + reports all-store partial success with warning diagnostics. +- `src/core/completions/command-registry.ts` registers static completion + metadata for the context-store command surface. +- `test/commands/context-store.test.ts` covers setup, register, list, doctor, + conflict handling, non-empty setup rejection, and interactive Git init. +- `test/commands/initiative.test.ts` covers all-store initiative listing, + compact human output, empty registered-store state, partial success, and all + unreadable stores. + +## Verification + +- `pnpm run build` +- `pnpm exec vitest run test/commands/context-store.test.ts test/commands/initiative.test.ts` +- `pnpm exec vitest run test/core/context-store/foundation.test.ts + test/core/context-store/registry.test.ts + test/core/collections/initiatives/operations.test.ts` +- `pnpm run lint` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md new file mode 100644 index 0000000000..35f64ceb5c --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md @@ -0,0 +1,333 @@ +# Add Minimal Context Store UX + +## Status + +Minimal context-store CLI and all-store initiative listing implemented. + +## Source Of Truth + +Start from `../../direction.md`. + +The current roadmap order is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +This item exists because agent-first initiative workflows need a usable shared +store before repo-local handoff and workspace opening can feel coherent. + +## Goal + +Let a user or agent create, register, list, and diagnose local context stores +without knowing the internal registry layout. + +## Agent-First Framing + +The expected user prompt is closer to: + +```text +Using initiative billing-launch, explore the API work and create a proposal. +``` + +Before an agent can do that, it needs to answer: + +- Which context stores are registered locally? +- Which store contains the named initiative? +- Is the registered store path valid? +- Is store metadata present and consistent? +- If no store exists yet, how should one be created? + +This work item should provide those primitives. It should not implement +repo-local initiative linking, initiative resolution, workspace opening, or +progress/status dashboards. + +## Locked Direction So Far + +- Keep the user-facing term `store` for now; naming polish is deferred. +- Use `context-store` as the top-level CLI namespace for this slice. It is more + explicit for agents and avoids overloading a broad top-level `store` command. + Keep `store` as shorthand only when the context is already scoped, such as + `initiative list --store <id>`. +- `context-store setup <id>` should create or use a local folder, write portable + store metadata, register the local path, and optionally initialize Git. +- When `--path` is omitted, `context-store setup <id>` should default to + `./<id>`. +- Using the current directory should be explicit with `--path .`; setup should + not silently turn the current repo into a context store. +- The actual shared context store should be visible on disk, not hidden under + XDG/global data. XDG/global data is only for the machine-local registry. +- `context-store register <path>` should register an existing clone or folder. +- Registration means "this folder already exists on my machine; remember it as + a known context store." It should not create the folder, initialize Git, pull, + push, commit, or create remotes. +- Default the store id from the repo or folder name when metadata is missing. +- Portable store metadata is exactly `.openspec-store/store.yaml`. It should be + checked into the context-store repo and contain only portable identity for + now: + +```yaml +version: 1 +id: team-context +``` + +- Do not put backend config, local paths, remote URLs, collection config, sync + policy, or permissions in `store.yaml`. +- If future collection/store config is needed, add a separate explicit file + rather than expanding the identity file by default. +- Machine-local registry state should stay outside the checked-in store and map + store ids to local paths. +- Registration should not pull, push, commit, or create remote repositories. +- Remote-url registration or clone sugar can come later. +- `initiative list` should default to all registered stores. `--store` should + filter to one store, and `--store-path` should remain an explicit escape + hatch. +- Human output should stay compact and avoid a `Status` column for now. + +## Suggested Command Shape + +```bash +openspec context-store setup <id> [--path <path>] [--init-git|--no-init-git] [--json] +openspec context-store register <path> [--id <id>] [--json] +openspec context-store list [--json] +openspec context-store doctor [id] [--json] +openspec initiative list [--store <id>] [--store-path <path>] [--json] +``` + +## Command Behavior + +### `context-store setup` + +`context-store setup <id>` creates or uses a visible local store root and +registers it on the current machine. + +Locked behavior: + +- Default path is `./<id>` when `--path` is omitted. +- Current-directory setup is allowed only with explicit `--path .`. +- Missing folders are created. +- Existing folders are allowed when metadata is missing or matches the requested + id. +- Non-empty folders without context-store metadata are not supported for setup + in this slice. +- Existing metadata with a different id fails. +- File paths fail. +- `.openspec-store/store.yaml` is written when missing. +- The store is registered in the machine-local registry. +- Interactive TTY mode prompts for Git initialization when neither + `--init-git` nor `--no-init-git` is provided; the default answer is yes. +- `--json`, non-TTY execution, `--init-git`, and `--no-init-git` do not prompt. +- Git is initialized only when the prompt answer is yes or `--init-git` is + passed. +- Setup does not commit, push, pull, create remotes, or create hosted repos. +- If a user wants to initialize an existing non-empty folder, fail with a clear + message and suggest filing the use case or using `context-store register` for + an existing context store. + +Suggested human output: + +```text +Context store setup complete + +ID: team-context +Location: /Users/me/work/team-context +Metadata: /Users/me/work/team-context/.openspec-store/store.yaml +Registry: /Users/me/.local/share/openspec/context-stores/registry.yaml +Git: initialized +``` + +### `context-store register` + +`context-store register <path>` records an existing local folder or clone as a +known context store on the current machine. + +Locked behavior: + +- Path must already exist and be a directory. +- If `.openspec-store/store.yaml` exists, use its id. +- `--id` may confirm the metadata id but cannot conflict with it. +- If metadata is missing, infer the id from the folder or repo name unless + `--id` is passed. +- Inference uses the folder or repo name as-is and then applies normal context + store id validation. Do not do clever normalization in this slice. +- Missing metadata is written. +- The machine-local registry is updated. +- Same id and same path is an idempotent success. +- Same id and different path fails for now; a future `--replace` can make + replacement explicit. +- Same path already registered under a different id fails for now. +- Register does not create the folder, initialize Git, pull, push, commit, + create remotes, or clone. + +Suggested human output: + +```text +Context store registered + +ID: team-context +Location: /Users/me/src/team-context +Metadata: /Users/me/src/team-context/.openspec-store/store.yaml +Registry: /Users/me/.local/share/openspec/context-stores/registry.yaml +``` + +### `context-store list` + +`context-store list` is an index view of the local registry. + +Locked behavior: + +- Reads the local registry. +- Shows registered id and location only. +- Sorts by store id. +- Does not check metadata, path health, Git, sync, remote, dirty state, or + conflicts. +- Does not mutate anything. +- Prints no health warnings; health belongs to `context-store doctor`. + +Suggested human output: + +```text +OpenSpec context stores (2) + +ID Location +platform /Users/me/src/platform-context +team-context /Users/me/src/team-context +``` + +Empty output: + +```text +No context stores registered. + +Next: + openspec context-store setup team-context + openspec context-store register /path/to/context-store +``` + +### `context-store doctor` + +`context-store doctor [id]` is the non-mutating health and repair surface. + +Locked behavior: + +- Checks all registered stores by default. +- Checks one store when `id` is passed. +- Checks registry presence, path existence, directory shape, metadata presence, + metadata parsing, and metadata id matching. +- Includes a cheap Git repository presence check. +- Does not check dirty state, branch, remote, sync, pull/push, or conflicts in + this slice. +- Does not mutate anything. + +Empty output: + +```text +No context stores registered. +``` + +Suggested human output: + +```text +Context store doctor + +team-context + Location: /Users/me/src/team-context + Metadata: ok + Git: repository detected + Issues: none +``` + +### `initiative list` + +`initiative list` becomes the agent-friendly discovery command across +registered stores. + +Locked behavior: + +- Without `--store` or `--store-path`, list initiatives from all readable + registered stores. +- If no context stores are registered, print a concise empty message. +- Sort by store id, then initiative id. +- Do not show a `Status` column in human output. +- Do not print detailed health diagnostics. +- If some stores cannot be read, still show initiatives from readable stores + and print one small warning that points to `context-store doctor`. +- If all registered stores are unreadable, print a concise failure/empty message + and point to `context-store doctor`. +- With `--store <id>`, filter to one registered store. +- With `--store-path <path>`, list from that explicit store path. +- Filtered `--store` or `--store-path` mode fails directly if that store cannot + be read, because there are no fallback stores. + +Suggested all-store output: + +```text +OpenSpec initiatives (3 across 2 stores) + +ID Store Title +billing-launch platform Billing Launch +docs-refresh platform Docs Refresh +api-cleanup team API Cleanup + +Some registered context stores could not be read. +Run: openspec context-store doctor +``` + +No registered stores output: + +```text +No initiatives found because no context stores are registered. +``` + +Suggested filtered output: + +```text +OpenSpec initiatives in platform (2) + +ID Title +billing-launch Billing Launch +docs-refresh Docs Refresh + +Location: /Users/me/src/platform-context +``` + +## Boundaries + +Do not implement in this item: + +- initiative `show` +- repo-local change metadata +- `new change --initiative` +- initiative local resolution +- workspace initiative opening +- sync, pull, push, remote repository creation, or conflict handling + +## Remaining Decisions + +None before implementation. JSON shapes can follow the existing command pattern: +top-level result objects plus a `status` diagnostics array. Partial success +returns exit code 0 with warning diagnostics; total failure returns nonzero. + +## Implemented Slice + +- Added `openspec context-store setup/register/list/doctor`. +- Registered the `context-store` command from the top-level CLI. +- Initially kept shell completion metadata out of scope; static metadata was + added later with the shipped command surface. +- Implemented strict CLI registration policy without changing the permissive + lower-level registry facade. +- Added setup behavior for default `./<id>`, explicit `--path .`, interactive + Git init prompt, non-interactive/JSON no-prompt behavior, non-empty directory + rejection, and metadata writing. +- Added register behavior for existing folders, id inference from folder name, + metadata writing, id/path conflict rejection, and registry updates. +- Added list behavior as a registry index only. +- Added doctor behavior for registry/path/metadata health and cheap Git + presence. +- Updated `initiative list` so no selector lists across registered stores, + `--store` filters, `--store-path` remains an escape hatch, human output is + compact, and all-store partial success returns warning diagnostics. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md new file mode 100644 index 0000000000..e17b34dd7a --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md @@ -0,0 +1,29 @@ +# Add Minimal Context Store UX Tasks + +- [x] Create Item 6 work-item tracking notes. +- [x] Capture agent-first setup and discovery direction. +- [x] Decide `context-store` is the first CLI namespace. +- [x] Decide setup defaults to `./<id>` when `--path` is omitted. +- [x] Decide current-directory setup requires explicit `--path .`. +- [x] Record that checked-in store metadata stays minimal. +- [x] Decide checked-in store metadata is exactly `.openspec-store/store.yaml` + and contains portable identity only. +- [x] Record that machine-local registry state stays outside the store. +- [x] Record that `initiative list` should default across registered stores. +- [x] Decide setup interactive and non-interactive behavior. +- [x] Decide register behavior. +- [x] Decide context-store list is registry index only. +- [x] Decide doctor owns health checks. +- [x] Decide initiative list partial-success behavior. +- [x] Decide JSON and exit behavior for partial success and total failure. +- [x] Decide id inference uses folder/repo name as-is with normal validation. +- [x] Decide setup rejects non-empty folders without context-store metadata. +- [x] Decide registry path/id conflicts fail for now. +- [x] Decide empty states for list, doctor, and initiative list. +- [x] Initially defer completion metadata; later add static metadata with the + rest of the shipped command surface. +- [x] Finalize exact JSON payload fields for setup, register, list, doctor, and + all-store initiative list. +- [x] Implement `context-store setup/register/list/doctor`. +- [x] Update `initiative list` all-store behavior and output. +- [x] Add focused tests and verification evidence. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md new file mode 100644 index 0000000000..a08d1fe17d --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md @@ -0,0 +1,97 @@ +# Add Agent-First Initiative Discovery Evidence + +## Conversation Decisions + +- `initiative show <id>` should be a locator/discovery command for agents. +- The command should answer which initiative the user meant, where the + canonical context lives, and where the initiative metadata is. +- The command should not concatenate markdown, summarize initiative contents, + compute work progress, resolve local repos, list linked changes, or open a + workspace. +- Default lookup should search all registered context stores. +- `--store <id>` should disambiguate or filter to one registered store. +- `--store-path <path>` should remain the explicit local-path escape hatch. +- Duplicate initiative ids across stores should fail with an ambiguity error. +- Default all-store lookup should fail when any registered store is unreadable, + because uniqueness is unknowable. +- Explicit `--store` and `--store-path` lookup should only care about the + selected store. +- `initiative.status` should be omitted from the v1 output projection. +- `owners` should be omitted from the v1 output projection. +- Arbitrary `metadata` should be omitted from the v1 output projection. +- `version` and `created` should stay in the v1 initiative projection. +- `files` should be omitted from v1. +- `initiative.metadata_path` should point to the validated `initiative.yaml`. +- `initiative.root` is enough for an agent to inspect the folder with normal + filesystem tools. +- Top-level `matches` should be omitted. Ambiguity and incomplete-lookup + candidates should live under the diagnostic that needs them, for example + `status[0].details.matches`. +- `context_store.source` should be omitted from `initiative show` v1 because it + is selector provenance, not context-store identity. +- A top-level `resolution` field is not needed in v1. +- Existing `initiative create/list` output can keep `context_store.source` for + now; this item should not refactor old output shapes. +- `readInitiative` should return `null` when the exact initiative is absent and + throw when `initiative.yaml` exists but is invalid or has the wrong id. +- In default all-store lookup, any unreadable registered store should make the + primary error `initiative_lookup_incomplete`, even when readable stores have + partial matches. +- If `initiatives/<id>/initiative.yaml` exists but is invalid or has the wrong + id, `initiative show` should fail as broken initiative state instead of + treating that store as not found. +- Human output should be a compact locator view on success: title, id, summary, + context store, location, and canonical filenames. +- Human ambiguity and incomplete-lookup errors should show matching or partial + matching stores inline, then point to the next command. +- Static shell completion metadata should ship for `initiative show`. +- Dynamic completions for store ids and initiative ids should remain deferred. + +## Research Notes + +- Current initiative create/list output spreads the full parsed + `initiative.yaml` state, which is useful for MVP but too broad for the first + `show` contract. +- A focused per-initiative read operation is preferred over implementing `show` + through `listInitiatives`, because exact lookup should not fail due to an + unrelated malformed initiative folder. +- Other initiative files are schema/config dependent and should not be + hardcoded into `show`. +- Keeping candidates inside diagnostic details follows the same general shape as + GraphQL-style responses: successful data stays clean, while error-specific + context travels with the error. +- If selector provenance is needed later, add a separate explicit field such as + `resolution` rather than putting provenance inside `context_store`. +- Human output should stay compact: title, id, summary, context store, + location, and metadata path. + +## Implementation Evidence + +- `src/core/collections/initiatives/operations.ts` adds `readInitiative` for + exact initiative lookup. +- `src/commands/initiative.ts` adds `initiative show <id>` with all-store + default lookup, `--store`, `--store-path`, JSON output, compact human output, + ambiguity diagnostics, and incomplete-lookup diagnostics. +- `src/core/completions/command-registry.ts` adds static completion metadata for + `initiative show`. +- `test/core/collections/initiatives/operations.test.ts` covers exact read, + absent initiatives, invalid exact initiatives, id mismatches, and unrelated + invalid folders. +- `test/commands/initiative.test.ts` covers `initiative show` success, + `--store-path`, human output, ambiguity, incomplete lookup, not found, + invalid exact initiative state, no `context_store.source`, no `files`, no + top-level `matches`, and static completions. + +## Verification + +- `pnpm run build` +- `pnpm exec vitest run test/core/collections/initiatives/operations.test.ts` +- `pnpm exec vitest run test/commands/initiative.test.ts` +- `pnpm exec vitest run test/commands/context-store.test.ts + test/commands/initiative.test.ts test/core/context-store/foundation.test.ts + test/core/context-store/registry.test.ts + test/core/collections/initiatives/operations.test.ts` +- `pnpm run lint` +- `git diff --check` +- Markdown line-length check for the initiative roadmap, task tracker, and Item + 7 work-item notes. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md new file mode 100644 index 0000000000..d59c23bc88 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md @@ -0,0 +1,184 @@ +# Add Agent-First Initiative Discovery + +## Status + +Implementation complete; verification in progress. + +## Source Of Truth + +Start from `../../direction.md`. + +This item exists because the expected workflow is agent-first: + +```text +Using initiative billing-launch, explore the API work and create a proposal. +``` + +Before repo-local linking, local resolution, or workspace opening can work, the +agent needs a small command that answers: + +- Which initiative did the user mean? +- Which context store contains the canonical initiative? +- Where is the initiative metadata, and what root should the agent inspect? + +## Goal + +Add agent-first initiative discovery without turning `show` into a reader, +progress dashboard, repo resolver, or workspace launcher. + +## Locked Direction So Far + +- `initiative show <id>` is a locator/discovery command. +- It should return identity, context-store location, initiative location, and + the initiative metadata path. +- It should not concatenate markdown, summarize file contents, compute progress, + resolve repos, list linked changes, or open workspaces. +- Default lookup searches all locally registered context stores. +- `--store <id>` filters to one registered store. +- `--store-path <path>` remains the explicit local-path escape hatch. +- Duplicate initiative ids across stores are ambiguous. The command should not + auto-pick a match. +- In default all-store lookup, unreadable stores make the lookup incomplete. + The command should fail rather than silently returning a possibly false + unique match. +- Explicit `--store` and `--store-path` modes only consider the selected store. + +## Output Contract Direction + +The first JSON contract should be a resolver/read-pointer projection, not a +full serialization of `initiative.yaml`. + +Suggested success shape: + +```json +{ + "context_store": { + "id": "platform", + "root": "/path/to/platform-context" + }, + "initiative": { + "version": 1, + "id": "billing-launch", + "title": "Billing Launch", + "summary": "Coordinate billing launch work.", + "created": "2026-05-21", + "root": "/path/to/platform-context/initiatives/billing-launch", + "store_path": "initiatives/billing-launch", + "metadata_path": "/path/to/platform-context/initiatives/billing-launch/initiative.yaml" + }, + "status": [] +} +``` + +Locked field decisions: + +- Keep `initiative.version`. +- Keep `initiative.created`. +- Keep `initiative.id`, `title`, `summary`, `root`, `store_path`, and + `metadata_path`. +- Keep `context_store.id` and `root`. +- Omit `context_store.source` from `initiative show` v1. It is selector + provenance, not context-store identity. Existing create/list output can remain + unchanged for now. +- Omit a top-level `resolution` field from v1. +- Omit `initiative.status` from the v1 projection. +- Omit `initiative.owners` from the v1 projection. +- Omit arbitrary `initiative.metadata` from the v1 projection. +- Omit a `files` list from the v1 projection. +- Omit top-level `matches`. +- Put ambiguity and incomplete-lookup candidates under the relevant diagnostic + entry, such as `status[0].details.matches`. +- Keep top-level `status` as command diagnostics only, not initiative work + progress. + +## Still To Decide + +- Nothing for the minimal v1 slice. + +## Human Output Direction + +Success output should stay locator-focused: + +```text +OpenSpec initiative: Billing Launch + +ID: billing-launch +Summary: Coordinate billing launch work. +Context store: platform +Location: /path/to/platform-context/initiatives/billing-launch + +Files: + Metadata: /path/to/platform-context/initiatives/billing-launch/initiative.yaml +``` + +Error output should stay plain: + +- Not found: say the initiative was not found in registered context stores and + suggest `openspec initiative list`. +- Ambiguous: show matching stores and paths, then suggest + `openspec initiative show <id> --store <store>`. +- Incomplete lookup: say some context stores could not be read, include partial + matches when present, then suggest `openspec context-store doctor`. + +## File Listing Direction + +`initiative show` should not list initiative folder contents in v1. + +Only `initiative.yaml` is required to identify and validate the initiative. All +other files are schema/config dependent and may differ across teams. Once the +command has resolved `initiative.root`, agents can use normal filesystem tools +to inspect the folder. Later schema-aware views can expose important files +without hardcoding today's default template filenames. + +## Completion Direction + +Add static shell completion metadata for: + +```text +initiative show <id> --store <id> --store-path <path> --json +``` + +Do not add dynamic completions for registered store ids or initiative ids in +this slice. + +## Core Read Operation Direction + +Add a focused `readInitiative` operation for exact lookup. + +Behavior: + +- Return `null` when the initiative folder or `initiative.yaml` is absent. +- Throw when `initiative.yaml` exists but is invalid. +- Throw when the parsed `initiative.yaml` id does not match the folder id. +- Do not scan unrelated initiative folders. + +## Lookup Error Precedence + +For default all-store lookup, any unreadable registered store makes lookup +incomplete. + +If one or more readable stores contain the initiative and one or more other +stores cannot be read, the primary error should still be +`initiative_lookup_incomplete`, not success or ambiguity. Include any readable +partial matches under the diagnostic details. + +Explicit `--store` and `--store-path` modes are scoped to the selected store and +do not check unrelated registered stores. + +Invalid exact initiative folders are broken shared state, not "not found". + +If `initiatives/<id>/initiative.yaml` exists but is invalid or has a mismatched +id, `initiative show` should fail with an invalid-initiative diagnostic. In +default all-store lookup, unreadable stores still take precedence as +`initiative_lookup_incomplete` because the full candidate set is unknowable. + +## Explicitly Out Of Scope + +- Top-level `openspec show` integration. +- Markdown content bundles or generated context packs. +- Checked-in initiative snapshots in repo-local changes. +- Repo-local change linking. +- Local repo/workspace resolution. +- Workspace opening. +- Git sync status, dirty state, remotes, pull, push, or conflicts. +- Initiative progress or status dashboards. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md new file mode 100644 index 0000000000..2bf8440d78 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md @@ -0,0 +1,27 @@ +# Add Agent-First Initiative Discovery Tasks + +- [x] Create Item 7 work-item tracking notes. +- [x] Decide `initiative show <id>` is a locator/discovery command. +- [x] Decide default lookup searches all registered context stores. +- [x] Decide `--store` and `--store-path` remain the narrowing selectors. +- [x] Decide duplicate initiative ids are ambiguity errors. +- [x] Decide unreadable stores make default all-store lookup incomplete. +- [x] Decide the v1 projection omits `initiative.status`, `owners`, and + arbitrary `metadata`. +- [x] Decide the v1 projection keeps `initiative.version` and `created`. +- [x] Decide v1 omits `files` and only returns initiative root plus metadata + path. +- [x] Decide ambiguity and incomplete-lookup candidates live under diagnostic + details, not top-level `matches`. +- [x] Decide exact human output direction for success and error states. +- [x] Decide `initiative show` omits `context_store.source`. +- [x] Decide `initiative show` omits a top-level `resolution` field. +- [x] Decide static completion metadata ships with Item 7. +- [x] Decide `readInitiative` returns `null` for absent and throws for invalid. +- [x] Decide incomplete lookup takes precedence over success or ambiguity in + default all-store mode. +- [x] Decide invalid exact initiative folders are errors, not not-found. +- [x] Implement a focused per-initiative read operation. +- [x] Implement `initiative show`. +- [x] Register static completion metadata for `initiative show`. +- [x] Add focused tests and verification evidence. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md new file mode 100644 index 0000000000..917c121652 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md @@ -0,0 +1,239 @@ +# Connect Repo-Local Changes To Initiatives Evidence + +## Decision 1: Initiative Link Location + +The initiative link should live in the repo-local change `.openspec.yaml`. + +Example: + +```yaml +schema: spec-driven +created: 2026-05-22 +initiative: + store: platform + id: billing-launch +``` + +This keeps repo implementation ownership in the repo while preserving a durable +reference to canonical initiative context. + +The link should not include local paths, copied initiative prose, or backlinks +inside the initiative store. + +## Research Notes + +- `createChange()` already writes `.openspec.yaml` for every change. +- `ChangeMetadataSchema` currently allows schema, created, goal, and + affected-area fields. Item 8 can extend that schema with `initiative`. +- Archive moves the whole change directory, so the initiative link will move + with archived changes. +- Apply, validate, and archive should not require context-store availability in + this slice. + +## Decision 2: Create Command Shape + +Initiative-linked creation should use `openspec new change` with `--initiative`. + +Supported first-slice forms: + +```bash +openspec new change add-billing-api --initiative billing-launch --json +openspec new change add-billing-api --initiative platform/billing-launch --json +openspec new change add-billing-api --initiative billing-launch --store platform --json +``` + +This keeps the operation repo-owned. The initiative is a reference on the +change, not the actor that creates or owns the change. + +The first slice should also add `--json` to `new change` so agents can capture +the created change path, metadata path, and initiative reference. + +## Decision 3: Initiative Lookup Behavior + +Bare `--initiative <id>` should reuse `initiative show` lookup semantics. + +It searches all registered context stores and succeeds only when the lookup is +complete and exactly one readable store contains the initiative. + +Explicit store selectors narrow lookup: + +```bash +openspec new change add-billing-api --initiative platform/billing-launch +openspec new change add-billing-api --initiative billing-launch --store platform +openspec new change add-billing-api --initiative billing-launch --store-path ./context +``` + +`--store-path` validates the explicit path and reads its store id, but does not +auto-register the store. Metadata still stores only the portable store id and +initiative id. + +Repo-local metadata should not be written until initiative lookup is complete +and unambiguous. + +## Decision 4: Repo-Local Only For V1 + +Item 8 should support initiative links only on repo-local changes. + +If `openspec new change <id> --initiative ...` runs from a workspace planning +home, v1 should refuse and tell the user to run the command from the repo that +owns the implementation plan. + +Existing workspace-planning changes remain compatibility behavior and should not +gain initiative linkage in this slice. + +This preserves the boundary that initiatives coordinate shared context, +repo-local changes own implementation plans, and workspaces open local views. + +## Decision 5: No Repo Ownership Matching In V1 + +Item 8 should not verify that the current repo is named by, owned by, or inferred +from the initiative. + +Creating a repo-local change with an initiative link records participation in the +initiative. It does not prove ownership, repo impact, or coverage of an +initiative area. + +Repo ownership matching can be revisited after initiative resolution or explicit +initiative metadata has a real repo/area model. + +## Decision 6: JSON And Human Output + +Create output should stay factual and minimal. + +Human output should confirm: + +- the created change id and location +- the schema +- the initiative link `{ store, id }` + +JSON output should include: + +```json +{ + "change": { + "id": "add-billing-api", + "path": "/repo/openspec/changes/add-billing-api", + "metadataPath": "/repo/openspec/changes/add-billing-api/.openspec.yaml", + "schema": "spec-driven" + }, + "initiative": { + "store": "platform", + "id": "billing-launch" + } +} +``` + +The output should not include `next` or other suggested workflow actions. API +responses should report operation results or errors; choosing the next action is +the agent's responsibility and depends on broader context. + +## Decision 7: Existing Change Recovery + +Item 8 should include a friendly recovery command for existing repo-local +changes: + +```bash +openspec set change add-billing-api --initiative billing-launch --json +openspec set change add-billing-api --initiative platform/billing-launch --json +openspec set change add-billing-api --initiative billing-launch --store platform --json +openspec set change add-billing-api --initiative billing-launch --store-path ../context --json +``` + +This command is a validated setter for checked-in repo-local change metadata. In +Item 8, the only supported settable field is the initiative link, and the only +file it may mutate is `openspec/changes/<id>/.openspec.yaml`. + +The command should not edit proposal, design, tasks, specs, or initiative-store +files. It should not store local paths or write backlinks into the initiative. + +If the requested initiative link already exists, the command should succeed as +an idempotent no-op. If a different initiative link already exists, the command +should fail without writing. Replacement, relink, unlink, and dry-run behavior +are deferred. + +Rationale: + +- Agents can forget to link a change during creation, so a first-class recovery + path is useful. +- `set change` matches the actual side effect: writing validated change metadata + to `.openspec.yaml`. +- Keeping the command scoped to `.openspec.yaml` avoids creating a broad change + editing surface. +- `openspec change ...` is currently deprecated, `edit` implies opening an + editor, and `update` already means refreshing local OpenSpec tooling or + guidance. + +## Decision 8: Status And Instructions Visibility + +Status and instructions should surface that the repo-local change is linked to +an initiative, but should not display or resolve the initiative itself. + +Human status output should show the stored initiative reference, and JSON status +output should include the stored initiative `{ store, id }`. Instructions output +should include a concise factual note that the change is linked to the +initiative. + +Status and instructions should not read, summarize, validate, or resolve the +initiative from the context store in v1. Missing or unavailable context stores +should not make repo-local status or instructions fail. + +This keeps the relationship visible during ordinary repo-local workflows while +preserving the boundary that initiative lookup and context reading belong to +initiative-specific commands. + +## Latest Open-Decision Notes + +Date: 2026-05-23. + +All decisions for Item 8 are now confirmed for implementation. + +Implementation should keep the first slice small: + +- The light release should test whether initiative-linked repo-local changes are + useful before adding gating, ownership inference, or broader workflow + integration. +- Standalone `initiative resolve` was later rejected; workspace local-view state + owns local path mapping. +- Source provenance, history/export, contract maps, and target-bound + initiative-hosted changes remain useful future discussion points, but should + not block this initial slice. + +## Implementation Evidence + +Date: 2026-05-23. + +Implemented: + +- `openspec new change <id> --initiative ...` for repo-local changes, with + `--json`, `--store`, and `--store-path` support. +- `openspec set change <id> --initiative ...` for existing repo-local changes. +- Portable checked-in metadata under `initiative: { store, id }`. +- Status and instructions visibility from stored metadata only. +- Workspace refusal, lookup-failure no-write behavior, same-link idempotency, + and different-link conflict protection. + +Verification: + +```bash +pnpm run build +``` + +Result: passed. + +```bash +pnpm exec eslint src/commands/workflow/new-change.ts src/commands/workflow/set-change.ts src/commands/workflow/initiative-link.ts src/commands/workflow/instructions.ts src/commands/workflow/status.ts src/commands/workflow/shared.ts src/commands/initiative.ts src/core/artifact-graph/types.ts src/core/artifact-graph/instruction-loader.ts src/utils/change-utils.ts src/cli/index.ts +``` + +Result: passed. + +```bash +pnpm exec vitest run test/utils/change-metadata.test.ts test/commands/change-initiative-link.test.ts +``` + +Result: passed, 39 tests. + +```bash +pnpm exec vitest run test/commands/artifact-workflow.test.ts test/commands/initiative.test.ts test/core/artifact-graph/instruction-loader.test.ts +``` + +Result: passed, 110 tests. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md new file mode 100644 index 0000000000..6019549f95 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md @@ -0,0 +1,279 @@ +# Connect Repo-Local Changes To Initiatives + +## Status + +Implemented. The original decision text below is preserved as design record; +current completion evidence lives in `tasks.md` and `evidence.md`. + +## Source Of Truth + +Start from `../../direction.md` and the Item 8 roadmap entry. + +The relevant boundary is: + +```text +Initiatives coordinate shared context. +Repo-local changes own implementation plans. +Workspaces open local views. +``` + +## Goal + +Let an agent create or link a repo-local OpenSpec change to a shared +initiative without copying initiative prose, storing machine-local paths, or +making the initiative own repo implementation artifacts. + +Example user prompt: + +```text +Using initiative billing-launch, create a proposal for API work. +``` + +## Decisions + +### 1. Initiative Link Location + +Decision: Store the initiative link in the repo-local change `.openspec.yaml`. + +Suggested metadata shape: + +```yaml +schema: spec-driven +created: 2026-05-22 +initiative: + store: platform + id: billing-launch +``` + +Rules: + +- Store only the context store id and initiative id. +- Do not store local context-store paths. +- Do not store local repo paths. +- Do not create a checked-in `initiative.md` snapshot by default. +- Do not write backlinks into the initiative. + +Rationale: + +- `.openspec.yaml` is already the per-change machine-readable metadata file. +- The link is durable repo context and should be checked in with the change. +- The canonical initiative context remains in the context store. +- The metadata stays portable across teammates and machines. + +### 2. Create Command Shape + +Decision: Add initiative linking to the repo-local change creation command with +`--initiative`. + +Supported first-slice forms: + +```bash +openspec new change add-billing-api --initiative billing-launch --json +openspec new change add-billing-api --initiative platform/billing-launch --json +openspec new change add-billing-api --initiative billing-launch --store platform --json +``` + +Rules: + +- The command starts from `new change` because the change is repo-owned. +- `--initiative` modifies repo-local change creation; it does not make the + initiative create or own the change. +- `--json` should be added to `new change` for agent-readable handoff output. +- A separate initiative-owned create command is not part of the first slice. + +Rationale: + +- The expected user flow is agent-first: "using initiative X, create a proposal + for repo work." +- Agents need one normal repo-local create command that can also write the + initiative reference. +- Keeping the verb rooted in `new change` preserves the boundary that changes + implement repo-owned slices. + +### 3. Initiative Lookup Behavior + +Decision: Reuse `initiative show` lookup semantics for `--initiative`. + +Rules: + +- Bare `--initiative <id>` searches all registered context stores. +- Bare lookup succeeds only when exactly one readable registered store contains + the initiative id. +- Duplicate initiative ids across stores fail as ambiguous. +- Any unreadable registered store makes bare lookup incomplete and fails before + writing change metadata. +- `--initiative <store>/<id>` selects one registered store by id. +- `--initiative <id> --store <store>` also selects one registered store by id. +- `--initiative <id> --store-path <path>` validates the explicit local context + store path, reads its store id, and writes only `{ store, id }` to metadata. +- `--store-path` does not auto-register the context store. +- Do not write repo-local initiative metadata until lookup is complete and + unambiguous. + +Rationale: + +- Agents can use the short form when it is safe. +- Durable repo-local links should not be created from partial knowledge. +- The behavior matches existing agent-first discovery semantics. + +### 4. Repo-Local Only For V1 + +Decision: Item 8 supports initiative links only on repo-local changes. + +Rules: + +- `openspec new change <id> --initiative ...` creates an initiative-linked + change only when the current planning home is repo-local. +- If the command runs from a workspace planning home, v1 refuses with clear + guidance to run the command from the repo that owns the implementation plan. +- Existing workspace-planning changes remain compatibility behavior and are not + extended with initiative linkage in this slice. + +Rationale: + +- The current product boundary assigns implementation plans to repo-local + OpenSpec changes. +- Workspaces are local views, not the durable planning owner for initiative + work. +- Extending workspace-planning changes would revive the superseded + workspace-owns-the-plan model. + +### 5. Repo Ownership Matching + +Decision: Do not attempt repo ownership matching in v1. + +Rules: + +- Creating a repo-local change with an initiative link records participation in + the initiative. +- The link does not claim that OpenSpec verified repo ownership, repo impact, or + initiative area coverage. +- The command should not block or warn solely because the current repo is absent + from initiative content. + +Rationale: + +- Item 8 should not invent repo ownership or monorepo area semantics. +- Ownership matching belongs with later initiative resolution or explicit + initiative metadata. +- Keeping v1 small lets teams test whether linked repo-local changes are useful + before adding policy gates. + +### 6. JSON And Human Output + +Decision: Keep create output factual and minimal. + +Rules: + +- Output should report what the command did, not recommend workflow next steps. +- Human output should confirm the created change location, schema, and initiative + link. +- JSON output should include stable fields for the created change and initiative + link. +- JSON output should not include a `next` command or suggested workflow action. +- Output should not include initiative summaries, repo ownership claims, + resolved local context-store paths, or progress/status-like fields. + +Suggested JSON shape: + +```json +{ + "change": { + "id": "add-billing-api", + "path": "/repo/openspec/changes/add-billing-api", + "metadataPath": "/repo/openspec/changes/add-billing-api/.openspec.yaml", + "schema": "spec-driven" + }, + "initiative": { + "store": "platform", + "id": "billing-launch" + } +} +``` + +Rationale: + +- CLI/API-style responses should state operation results or errors. +- Accurately choosing the next action depends on agent context and should remain + the agent's responsibility. +- Keeping output factual avoids coupling change creation to later lifecycle + design. + +### 7. Existing Change Recovery + +Decision: Include a recovery command for setting the initiative link on an +existing repo-local change. + +Command shape: + +```bash +openspec set change add-billing-api --initiative billing-launch --json +openspec set change add-billing-api --initiative platform/billing-launch --json +openspec set change add-billing-api --initiative billing-launch --store platform --json +openspec set change add-billing-api --initiative billing-launch --store-path ../context --json +``` + +Rules: + +- `openspec set change <id> --initiative ...` is a validated setter for + repo-local change metadata. +- In Item 8, the only supported settable field is the initiative link. +- The command only mutates `openspec/changes/<id>/.openspec.yaml`. +- The command does not edit proposal, design, tasks, specs, or initiative-store + files. +- The command uses the same initiative lookup semantics as + `openspec new change <id> --initiative ...`. +- If the same initiative link already exists, the command succeeds as an + idempotent no-op. +- If a different initiative link already exists, the command fails without + writing. Replacement, relink, unlink, and dry-run behavior are not part of v1. +- If the command runs from a workspace planning home, it refuses for the same + reason as initiative-linked `new change`. + +Rationale: + +- Agents can forget to pass `--initiative` during change creation; v1 needs a + friendly recovery path. +- `set change` describes the real operation: setting checked-in change metadata, + not creating an initiative-owned relationship. +- Keeping the command limited to `.openspec.yaml` avoids a broad edit surface. +- Avoid `openspec change ...` because that namespace is currently deprecated. +- Avoid `edit` because it implies opening an editor, and avoid `update` because + OpenSpec already uses update for local guidance/tool refresh. + +### 8. Status And Instructions Visibility + +Decision: Surface the initiative link in status and instructions output without +resolving or displaying the initiative itself. + +Rules: + +- Human status output should show that the change is linked to an initiative. +- JSON status output should include the stored initiative `{ store, id }`. +- Instructions output should include a concise factual note that the change is + linked to the initiative. +- Status and instructions must not read, summarize, validate, or resolve the + initiative from the context store in v1. +- Missing or unavailable context stores must not make repo-local status or + instructions fail. +- Output should not add next-step recommendations. + +Rationale: + +- The initiative link should be visible in normal repo-local workflow output so + users and agents do not miss the relationship. +- Keeping visibility to stored metadata avoids introducing context-store + availability as a dependency for repo-local workflow commands. +- Initiative resolution belongs to initiative-specific commands, not status or + instructions in this slice. + +## Open Decisions + +None. Decision pass complete; confirm the decisions before implementation. + +## Latest Suggested Resolutions + +These were the suggested answers carried into implementation: + +- Surface the stored initiative link in status and instructions without reading + or displaying the initiative itself. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md new file mode 100644 index 0000000000..926e34ebbd --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md @@ -0,0 +1,22 @@ +# Connect Repo-Local Changes To Initiatives Tasks + +## Decisions + +- [x] Decide where the initiative link lives. +- [x] Decide command shape for creating initiative-linked changes. +- [x] Decide initiative lookup behavior for `--initiative`. +- [x] Decide whether workspace-scoped changes are allowed in this slice. +- [x] Decide whether repo ownership matching is attempted in v1. +- [x] Decide JSON and human output shape. +- [x] Decide whether Item 8 includes linking existing changes. +- [x] Decide whether status/instructions surface initiative links. +- [x] Confirm latest suggested resolutions in `plan.md` before implementation. + +## Implementation + +- [x] Extend change metadata schema with an optional initiative link. +- [x] Persist initiative metadata when creating repo-local changes. +- [x] Add command support for creating initiative-linked changes. +- [x] Add tests for metadata validation and persistence. +- [x] Add tests for command output and lookup failures. +- [x] Add status/instruction visibility for stored initiative links. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md new file mode 100644 index 0000000000..a7bf4c51a4 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md @@ -0,0 +1,64 @@ +# Item 9 Decision: Reject Initiative Resolve + +## Final Decision + +Do not implement a standalone `openspec initiative resolve <id>` command, now +or later. + +The command is unnecessary because it tries to do work that already belongs to +other concepts: + +- `initiative show` finds the canonical initiative. +- A workspace is the local view over repos and folders. +- Repo-local changes link themselves to initiatives. +- Repo-local status reports work progress. + +## Decision 1: No Command + +No separate initiative command is needed. + +If the user only has a context store, `initiative show` is enough. If the user +has a workspace, the local view is already represented by that workspace. If the +user is inside a repo, repo-local commands are enough. + +## Decision 2: Local Resolution Belongs To Workspace + +A workspace maps local repos and folders to paths on one machine. Future +initiative-aware local opening belongs in workspace behavior. + +## Decision 3: Agent Behavior + +Agents should: + +- Use `openspec initiative show <id> --json` for shared context. +- Use the current workspace view when the user is working in a workspace. +- Use repo-local commands when the user is working in a repo. +- Let the user decide which repos are present locally. + +## Decision 4: Rejected Scope + +Remove all standalone resolve behavior: + +- no `initiative resolve` +- no all-repo scan +- no all-workspace scan +- no `--path` search roots +- no Git remote matching +- no cloning +- no worktree or branch creation +- no initiative backlinks +- no local availability dashboard + +## Decision 5: Roadmap Update + +Convert Item 9 into a decision-only checkpoint. + +Replacement: + +```text +Item 9. Reject Initiative Resolve + +Decision: do not add `openspec initiative resolve`, now or later. Initiative +discovery belongs to `initiative show`; local path mapping belongs to +workspaces; implementation progress belongs to repo-local changes. +``` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md new file mode 100644 index 0000000000..26ce8ca83d --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md @@ -0,0 +1,106 @@ +# Reject Initiative Resolve Evidence + +## Decision Summary + +Date: 2026-05-25. + +After review, the standalone `openspec initiative resolve <id>` command should +not be implemented, now or later. + +The useful distinction is already covered by existing concepts: + +- `initiative show` resolves canonical shared initiative context. +- A workspace is the local view over repos and folders. +- Repo-local changes link themselves to initiatives through checked-in metadata. +- Repo-local status reports implementation progress. + +A standalone resolve command would mostly duplicate workspace local-view state +or provide weak output when no workspace is present. + +## Pressure Test + +Scenario: + +```bash +git clone git@github.com:acme/context.git +openspec context-store register ./context --id platform +openspec initiative show billing-launch --json +``` + +This can locate: + +```text +platform/billing-launch +./context/initiatives/billing-launch +./context/initiatives/billing-launch/initiative.yaml +``` + +It cannot know: + +```text +which implementation repos should exist locally +where those repos are on this machine +which repos the user intends to work in +which repos should be cloned +which workspace view the user wants +``` + +That knowledge belongs to the user and the workspace, not the initiative. + +## Why Workspace Changes The Answer + +When a user has a workspace, the local view is already resolved by the +workspace: + +```text +workspace -> link names -> machine-local paths +``` + +The agent can operate from the workspace context. A separate +`initiative resolve` command would add another layer that mostly reprints what +the workspace already owns. + +If future UX needs initiative-aware opening, it should be part of workspace +behavior, such as opening or preparing a workspace around a selected initiative. +It should not be a standalone initiative command pretending to infer local repo +availability. + +## Research Notes Retained + +The earlier investigation is still useful as background: + +- `initiative show` already has correct context-store lookup behavior, + ambiguity handling, incomplete lookup handling, and JSON locator output. +- Item 8 stores initiative links in repo-local `.openspec.yaml` as + `{ store, id }`. +- Workspace state owns local path mappings and generated open surfaces. +- Existing repo-local status and instructions expose initiative links but do not + resolve or summarize the initiative. + +Those findings support the final decision: do not add a standalone command; keep +each responsibility in its existing owner. + +## Rejected Scope + +Rejected for Item 9: + +- `openspec initiative resolve <id>` +- path-resolution dashboards +- progress dashboards +- all-workspace scans +- all-repo scans +- explicit path scanning as an initiative command +- Git remote matching +- repo ownership inference +- cloning or branch/worktree orchestration +- initiative backlinks + +## Verification + +This pass updates decision artifacts only. + +```bash +git diff --check +``` + +Result: passed after this revision. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md new file mode 100644 index 0000000000..6a5a482f47 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md @@ -0,0 +1,141 @@ +# Reject Initiative Resolve + +## Status + +Final decision: do not implement a standalone `openspec initiative resolve` +command, now or later. + +## Source Of Truth + +Start from `../../direction.md` and the boundary: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +Item 8 already established that repo-local changes may reference initiatives +through portable checked-in metadata: + +```yaml +initiative: + store: platform + id: billing-launch +``` + +## Final Decision + +Do not ship `openspec initiative resolve <id>` as a user-facing command in this +slice or any future slice. + +The earlier command framing was too broad. It tried to join initiative identity, +workspace local paths, explicit repo roots, and linked repo-local changes into a +new CLI surface. That makes the command look authoritative even though the +initiative does not own local repo paths, repo participation, or implementation +state. + +## Why The Command Is Not Needed + +If a user only has a context store clone, OpenSpec can already resolve the +canonical initiative with: + +```bash +openspec initiative show billing-launch --json +``` + +That answers: + +```text +What initiative is this, which context store contains it, and where is the +canonical initiative folder? +``` + +It cannot answer: + +```text +Which local implementation repos should exist on this machine? +``` + +because that information is not in the context store. + +If a user has a workspace, the workspace is already the local view. It already +maps local repos and folders to paths on this machine. A separate +`initiative resolve` command would mostly re-describe the workspace the user is +already using. + +If a user is in a repo, the repo-local change commands and status commands +already operate from that repo. The user or agent can inspect the current repo's +changes directly. + +## Product Rule + +Do not create a new command whose main job is to discover local paths that the +workspace already represents. + +Rules: + +- `initiative show` remains the command for canonical initiative discovery. +- Workspaces remain the local view over repos, folders, context stores, and + initiatives. +- Repo-local changes remain the implementation artifacts. +- Agents should use the current workspace or current repo context rather than + asking a standalone initiative command to infer local availability. +- OpenSpec should not infer repo ownership, scan arbitrary repos, clone repos, + create worktrees, or write backlinks to make resolve appear smarter than it + is. + +## What To Do Instead + +Keep the pieces separate: + +- Use `openspec initiative show <id> --json` to locate canonical shared context. +- Use workspace commands to set up, link, relink, list, open, update, and doctor + local views. +- Use repo-local `openspec new change ... --initiative ...` and + `openspec set change ... --initiative ...` to create durable links from repo + work to initiative context. +- Use `openspec status --change <id> --json` inside the owning repo to inspect + implementation progress. + +If a future workspace workflow needs to open an initiative-specific view, it +should be designed under workspace behavior, not as a standalone initiative +resolve command. + +## Deferred Or Replaced Scope + +The following ideas are not part of Item 9 implementation: + +- `openspec initiative resolve <id>` +- scanning all registered workspaces +- scanning all repos on disk +- explicit `--path` based initiative resolution +- Git remote matching +- repo ownership inference +- cloning, fetching, pulling, pushing +- branch or worktree creation +- initiative backlinks +- progress dashboards +- local availability dashboards + +## Roadmap Disposition + +Item 9 is a decision-only checkpoint. It records that standalone initiative +resolution is rejected permanently. + +Roadmap framing: + +```text +Item 9. Reject Initiative Resolve + +Decision: do not add `openspec initiative resolve`, now or later. Initiative +discovery belongs to `initiative show`; local path mapping belongs to +workspaces; implementation progress belongs to repo-local changes. +``` + +## Next Useful Work + +The next useful implementation slice is workspace initiative opening, without a +standalone resolve prerequisite. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md new file mode 100644 index 0000000000..f442d86bd9 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md @@ -0,0 +1,22 @@ +# Reject Initiative Resolve Tasks + +## Decisions + +- [x] Create Item 9 work-item tracking notes. +- [x] Pressure-test whether a standalone `initiative resolve` command is needed. +- [x] Decide that a standalone user-facing `initiative resolve` command should + not be implemented now or later. +- [x] Decide `initiative show` remains sufficient for canonical initiative + discovery. +- [x] Decide workspace local-view state is the right place for local repo/path + mapping. +- [x] Decide repo-local status remains the right place for work progress. +- [x] Decide not to add all-repo scanning, all-workspace scanning, Git remote + matching, cloning, worktree creation, or initiative backlinks. + +## Follow-Up + +- [x] Update the central roadmap entry for Item 9. +- [x] Update the initiative task tracker. +- [x] Record workspace initiative opening as the next useful implementation + slice. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md new file mode 100644 index 0000000000..42444b568b --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md @@ -0,0 +1,430 @@ +# Let Workspaces Open Initiatives + +## Status + +Product decisions are locked. The remaining work is implementation design and +delivery. + +## Source Of Truth + +Start from `../../direction.md` and the boundary: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +Item 9 rejected standalone initiative resolution. Initiative discovery belongs +to `initiative show`; local path mapping belongs to workspace local-view state. + +## Locked Direction + +A workspace does not contain the work. It remembers how this runtime opens the +work. + +```text +private local view record + -> generated runtime files + -> opener-specific launch + -> initiative context + selected local repos/folders +``` + +The durable part is the user's private local view choice. The generated part is +runtime support for agents and editors. + +## Product Goal + +Let a user open a shared initiative in their own local runtime with the context +and repos they care about. + +Examples: + +- A Team A developer opens `platform/billing-launch` with local Repo A and Repo + B. +- A Team B developer opens the same initiative with local Repo C only. +- A user opens the initiative context only, links repos later, and still gets + useful agent guidance. + +## Non-Goals + +- Do not clone repos. +- Do not create branches or worktrees. +- Do not use Git submodules as the workspace primitive. +- Do not infer all participating repos from Git remotes or disk scans. +- Do not write generated agent files into linked repos or context stores. +- Do not make workspace-level `changes/` the durable planning model. +- Do not enforce edit permissions in Item 10. + +## Decision Register + +### Command UX + +Status: decided. + +Use `workspace open` for initiative local-view realization: + +```bash +openspec workspace open --initiative platform/billing-launch +openspec workspace open --initiative billing-launch --store platform +openspec workspace open --initiative billing-launch +openspec workspace open team-a-billing --initiative platform/billing-launch +``` + +Rationale: the action being performed is local view realization, so the command +belongs under `workspace open` rather than `initiative open`. + +Lookup behavior: + +- If the user provides `<store>/<initiative>`, use that exact store selector. +- If the user provides `<initiative> --store <store>`, use that exact store + selector. +- If the user provides only `<initiative>`, search registered context stores and + proceed when there is exactly one exact match. +- If multiple stores contain the same initiative id, stop and show the matching + stores with a hint to retry using `<store>/<initiative>` or `--store`. +- If no exact match exists, do not silently open the closest match. Show a small + list of likely matches when available, plus a hint to run `openspec + initiative list`. +- If some registered stores cannot be read, keep the result conservative. Do not + choose a match that could be ambiguous behind an unreadable store unless the + user supplied an explicit store selector. + +Interactive UX may let a human choose from suggestions. JSON and non-interactive +UX should return structured errors and suggestions without prompting. + +Workspace-name behavior: + +- The optional positional workspace name remains the local view identity. +- If the user provides a workspace name with `--initiative`, create or reuse that + named local view. +- If the user omits a workspace name, create or reuse a friendly default derived + from the initiative id when that is unambiguous. +- On name collisions or multiple existing local views for the same initiative, + let the human choose interactively or require an explicit workspace name in + non-interactive mode. + +### Open Target + +Status: decided. + +Default to opening the initiative directory, not the whole context store. + +User-facing behavior: + +```bash +openspec workspace open --initiative billing-launch +``` + +opens a focused local view: + +```text +generated files in the workspace root +context-store/initiatives/billing-launch/ +selected local repos/folders +``` + +It should not open the entire context store by default. + +Rationale: + +- The user asked for one initiative, so the opened context should be focused on + that initiative. +- Agents receive less unrelated shared context. +- Unrelated initiatives and shared files are not exposed by default. +- The local view stays easier to understand: generated workspace root plus this + initiative plus selected implementation roots. + +Generated guidance and JSON output should still report the context store root +and that broader context exists. A later explicit option may open the full +context store, for example `--context-scope store` or `--include-store`, but +broad store scope is not the default for Item 10. + +### Local View Record + +Status: decided. + +Use one private local view record: the root `workspace.yaml` file. + +```yaml +version: 1 +name: billing-launch +context: + kind: initiative + store: + id: platform + selector: + kind: registry + id: platform + initiative: + id: billing-launch +links: + repo-a: /Users/me/repos/repo-a + repo-b: /Users/me/repos/repo-b +preferred_opener: codex +tools: + - codex +``` + +This decision covers the conceptual record shape and the fact that generated +runtime files are not durable state. + +If the user selected a context store by local path, the private workspace record +can keep that runtime-local selector without changing checked-in repo metadata: + +```yaml +context: + kind: initiative + store: + id: platform + selector: + kind: path + path: /Users/me/context/platform + observed_id: platform + initiative: + id: billing-launch +``` + +The context binding is optional. A user can also create a workspace that is not +linked to any initiative: + +```yaml +version: 1 +name: team-a-local +context: null +links: + repo-a: /Users/me/repos/repo-a + repo-b: /Users/me/repos/repo-b +preferred_opener: codex +tools: + - codex +``` + +This is a first-class workspace shape, not only an edge case for initiative +opening. Item 10 should preserve custom non-initiative workspaces while adding +initiative-aware opening. + +### Workspace Storage And Generated Files + +Status: decided. + +Store each private workspace view under the user's OpenSpec global data +directory, keyed by workspace name: + +```text +getGlobalDataDir()/workspaces/<workspace-name>/ +``` + +The workspace name is the local identity. The selected store and initiative, if +any, are data inside the private record; they do not define the storage path. +This keeps the workspace API generic enough for custom local views that are not +initiative-linked. + +Initial shape: + +```text +getGlobalDataDir()/workspaces/<workspace-name>/ + workspace.yaml + AGENTS.md + <workspace-name>.code-workspace + .codex/ + skills/ + .claude/ + skills/ +``` + +`workspace.yaml` is the durable private view record and the only view file in +Item 10. The other files are generated runtime support owned by OpenSpec. They +may be overwritten by `workspace open`, `workspace update`, or a future explicit +preparation surface. + +Do not add a separate generated-output directory for Item 10. The managed +workspace root is already the private generated view. + +Initiative open defaults: + +- If the user provides a workspace name and no workspace exists, create that + workspace bound to the selected initiative. +- If the user provides a workspace name and it already points at the same + initiative, reuse it and regenerate runtime files. +- If the user provides a workspace name and it has no context binding, bind it + to the selected initiative only after clear user confirmation; in + non-interactive mode, fail and require an explicit future rebind/update + surface. +- If the user provides a workspace name and it points at a different initiative + or context, do not silently repoint it. Stop with a clear error and require an + explicit future rebind/update surface. +- If the user omits a workspace name and exactly one existing workspace points at + the selected initiative, reuse it. +- If the user omits a workspace name and no existing workspace points at the + selected initiative, create a friendly default workspace name derived from the + initiative id only when that name is unused. +- If the derived workspace name collides with another workspace, ask for an + explicit workspace name or show matching workspace choices instead of hiding + the collision behind a path convention. +- If multiple workspaces point at the same initiative, let the user choose or + require an explicit workspace name in non-interactive mode. + +### Generated Runtime Files + +Status: decided. + +Generate runtime files at the workspace root, next to `workspace.yaml`. + +```text +getGlobalDataDir()/workspaces/<workspace-name>/ +``` + +The generated files can contain `AGENTS.md`, skills, launch prompts, and +generated editor workspace files. + +Regeneration behavior: + +- `workspace open` regenerates the managed runtime files before launching the + opener. +- `workspace update` regenerates the managed runtime files without changing + durable local view choices unless the user asked for a state change. +- Generated files are OpenSpec-owned and may be overwritten each time. +- `workspace.yaml` is not generated output and should not be overwritten except + when the local view record itself changes. + +### Runtime Identity + +Status: decided. + +Use `getGlobalDataDir()` as the runtime-local boundary. It is already +cross-platform and resolves to the appropriate user data directory for macOS, +Linux, Windows, Codespaces, WSL, SSH hosts, and containers. + +Local paths in `workspace.yaml` are valid only in the runtime that wrote them. +If the same user opens the same initiative from another runtime, they create or +relink that runtime's workspace there. Item 10 should not add path translation, +shared machine identities, or an extra `<runtime-id>` path segment. + +### Prepare/JSON Surface + +Status: decided. + +Keep `workspace open --json` as a machine-facing receipt for the same open +operation. Do not add `--prepare-only` for Item 10. + +The JSON response should be useful to agents and desktop integrations, not just +a success boolean. It should include the workspace name, workspace root, +generated file paths, selected context, opened roots, skipped or missing roots, +opener, launch status, and warnings. + +Human-facing behavior remains the normal `workspace open` output. JSON mode is +for tools that need structured facts after OpenSpec has prepared the workspace +root and attempted the requested open. + +### Missing Paths At Open Time + +Status: decided. + +Workspace opening should be strict about the selected initiative/context and +forgiving about optional linked local paths. + +- If the selected initiative cannot be resolved, fail before launch. +- If the context store or initiative path is unavailable, fail before launch and + point to context-store registration/doctor guidance. +- If a linked repo or folder is missing, warn and skip that root; do not block a + context-only or partially linked open. +- Human output should name skipped links and suggest `workspace doctor` or + relink guidance. +- JSON output should include skipped or missing roots and warnings. + +### Codex Desktop + +Status: decided. + +Open the generated workspace root as the Codex Desktop project. Surface the +attached initiative path and linked repo/folder paths through generated guidance +and the `workspace open --json` response. + +Do not depend on Desktop multi-root automation for Item 10. If Desktop later has +a clearer multi-root contract, it can become an enhancement without changing the +workspace storage model. + +### Edit Boundaries + +Status: decided. + +Item 10 emits advisory boundaries only. Generated context should distinguish +coordination context from implementation targets, but it should not enforce +write restrictions. + +The generated view should label initiative/context-store files as shared +coordination context and linked repos/folders as local implementation context +when selected. Strong enforcement can come later. + +## First-Run UX Sketch + +Status: deferred beyond the first implementation slice. + +This sketch captures the eventual human interactive flow. Item 10 should not +depend on building a full guided setup wizard; the first implementation may use +explicit flags and structured errors first. + +```text +Found initiative: platform/billing-launch +No local workspace view exists for this runtime. + +Create a local view? +> Open context only + Link existing local repos/folders + Cancel +``` + +No option in this first-run flow should clone, branch, create worktrees, or +create submodules. + +## Machine-Readable Open Contract + +`workspace open --json` is the machine-readable contract for the generated +runtime context. Item 10 should not create a separate machine-readable view +file; the durable view record is `workspace.yaml`. + +The JSON response should tell agents: + +- schema version +- workspace name and workspace root +- selected initiative id, title, and path +- selected context store id and path +- generated file paths +- opened roots +- skipped or missing roots +- linked repo-local changes when known +- advisory edit boundaries +- next repair commands +- warnings and launch status when produced by `workspace open --json` + +If no implementation target is selected, `allowedEditRoots` should be empty or +explicitly advisory. + +The exact schema can evolve during implementation, but the JSON response should +make the generated view self-describing enough for agents and desktop +integrations without scraping human output. + +## Forward Compatibility + +The initial `context` record supports the selected context store and initiative. +Do not design the YAML parser so narrowly that future records cannot add fields +for configurable change homes, artifact homes, target bindings, or other +collection/view metadata. + +## Compatibility Notes + +The current beta workspace implementation creates a managed root with +`changes/`, `AGENTS.md`, `.gitignore`, +`.openspec-workspace/workspace.yaml`, `.openspec-workspace/local.yaml`, and a +durable `.code-workspace` file. + +Item 10's intended new shape is a root `workspace.yaml` plus generated runtime +files at the managed workspace root. Existing beta workspaces should be treated +as compatibility inputs. Migration or removal of all beta internals is deferred +unless the implementation slice intentionally scopes that migration. + +For the initiative-opening model, generated runtime files are derived artifacts, +not workspace truth. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md new file mode 100644 index 0000000000..a44944c96c --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md @@ -0,0 +1,43 @@ +# Let Workspaces Open Initiatives Tasks + +## Decisions + +- [x] Create Item 10 work-item tracking notes. +- [x] Lock the high-level direction: private local view record plus generated + runtime files. +- [x] Decide command UX. +- [x] Decide default open target. +- [x] Decide private local view record shape. +- [x] Decide private local view record storage namespace and keying. +- [x] Decide generated runtime file location and lifetime. +- [x] Decide runtime identity rules. +- [x] Decide prepare/JSON surface. +- [x] Decide Codex Desktop behavior. +- [x] Decide Item 10 edit-boundary semantics. + +## Implementation Scope To Confirm Later + +- [x] Add or adapt workspace local-view state for initiative opening. +- [x] Preserve non-initiative custom workspaces as first-class local views. +- [x] Resolve initiative context through existing `initiative show` semantics. +- [x] Implement workspace-name reuse and collision behavior for initiative open. +- [x] Generate opener-specific runtime files. +- [x] Return explicit machine-readable view context from `workspace open --json`. +- [x] Launch agent/editor with generated workspace root plus initiative context and + selected local repos/folders. +- [x] Warn and skip missing linked repos/folders at open time while failing on + missing selected initiative/context. +- [x] Add doctor guidance for missing context stores, missing local links, stale + view records, and advisory edit boundaries. +- [x] Ensure Item 10 opens known local paths only and does not clone, branch, + create worktrees, or use submodules. + +## Deferred + +- [ ] Multiple saved views per initiative. +- [ ] Shared/exported workspace templates. +- [ ] Repo auto-discovery or Git remote matching. +- [ ] Strong edit-boundary enforcement. +- [ ] Codex Desktop multi-root automation if the Desktop contract is not clear + enough for Item 10. +- [ ] Migration or removal of all existing beta workspace root artifacts. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-explore-configurable-change-homes/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/13-explore-configurable-change-homes/evidence.md new file mode 100644 index 0000000000..1397f42869 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/13-explore-configurable-change-homes/evidence.md @@ -0,0 +1,397 @@ +# Explore Initiative-Hosted Target-Bound Change Artifacts Evidence + +## Initial Research Notes + +- Current product direction says context stores sync shared truth, initiatives + coordinate work, and repo-local changes own implementation planning. +- Roadmap Item 8 currently assumes repo-local changes linked to initiatives. +- Existing planning-home behavior distinguishes repo-local and workspace + planning homes, but does not have a context-store-backed change home. +- New artifact workflow commands already consume resolved planning paths in + some places, which may be a useful seam for future change-home resolution. +- Older command surfaces still assume `openspec/changes/` under a local + OpenSpec project and need an explicit audit before any implementation slice. + +## Initial Framing + +Configurable change homes are a product-boundary question, not just a path +change. A context-store-hosted artifact would still need a clear target repo or +spec root before validation, apply, archive, or spec sync can run safely. + +The future exploration should keep "change home" as internal resolver language +and use clearer product language around initiative-hosted planning artifacts, +target-bound changes, implementation targets, and editable roots. + +## Agent-First Team UX Research Pass + +Date: 2026-05-23. + +Question explored: + +```text +What does a great agent-first developer experience look like for teams using +context stores, initiatives, workspaces, and repo-local changes? +``` + +### External Pattern Notes + +- Linear uses initiatives as higher-level coordination objects that group + projects and expose health, ownership, and active project rollups. +- Jira planning commonly uses initiatives above epics or other child work + items for multi-team planning. +- GitLab roadmaps show higher-level epics and milestones across groups or + projects. +- GitHub Projects emphasize flexible planning that stays connected to issues + and repo work. + +These patterns point toward a common split: + +```text +Higher-level object = coordination and rollup +Execution item = work owned closer to a team, project, repo, or issue +``` + +OpenSpec should keep that separation while making the agent handoff sharper +than human project-management tools can. + +### Clean Mental Model + +The strongest mental model from the research pass: + +```text +Initiative = shared coordination truth +Workspace = local lens over initiative + repos +Repo change = executable implementation plan +``` + +Expanded product rule: + +```text +Context stores remember. +Initiatives coordinate. +Workspaces open. +Repo-local changes implement. +``` + +The key invariant: + +```text +Work identity is not storage location. +Storage location is not edit permission. +``` + +This keeps three decisions separate for agents: + +- What work is the user talking about? +- Where should the planning artifact live? +- Which files or repos may be edited now? + +### Suggested Artifact Types + +Repo context: + +```text +openspec/changes/<change-id>/ +``` + +Use for repo-owned implementation plans. A repo-local change may reference an +initiative through portable metadata: + +```yaml +initiative: + store: platform + id: billing-launch +``` + +Workspace context: + +```text +<store>/initiatives/<initiative-id>/work-items/<work-id>/ +``` + +Use for shared initiative planning before repo ownership or implementation +targets are clear. These should be called initiative work items, planning +briefs, or proposals, not executable OpenSpec changes, until Item 13 defines a +full lifecycle for context-store-backed changes. + +Workspace-local changes: + +```text +<workspace>/changes/<change-id>/ +``` + +Keep as legacy or beta compatibility unless the user explicitly opts into the +workspace-planning flow. + +### Agent-First UX Scenarios + +Single repo team: + +- User asks the agent to create a proposal from inside the repo. +- Agent resolves the initiative if named. +- Agent creates a repo-local change linked to the initiative. +- Apply, validate, sync, and archive stay repo-local. + +Monorepo: + +- One repo-local change can cover several packages or capabilities. +- The agent may need an area or package hint. +- The repo remains the implementation owner; areas clarify scope but do not + become separate change homes. + +Multi-repo platform: + +- Workspace opens the shared initiative context plus local repo clones. +- The initiative coordinates the platform outcome. +- Each owning repo gets its own linked repo-local change when implementation + ownership is known. +- Workspace state should report available local repos, missing local paths, and + edit boundaries. + +Central architecture team: + +- Architects may update initiative requirements, designs, contracts, decisions, + and questions without owning implementation. +- The agent should offer to draft shared initiative context or ask for the + owning repo before creating a repo-local change. + +Ownership unknown: + +- The agent should not create an implementation change. +- It should add or update initiative-level questions, or return target options + with a request for a repo or area decision. + +Teammate onboarding: + +```text +Clone or register the context store. +Run context-store doctor. +Open or resolve the initiative. +Link local repos through workspace mappings. +Ask the agent to continue from the initiative. +``` + +### Ideal Agent JSON Blocks + +Agents need stable routing vocabulary across create, status, instructions, +resolve, and list: + +```json +{ + "workTarget": { + "kind": "repo-change | initiative-work-item | workspace-change", + "id": "add-billing-api", + "root": "/absolute/path", + "storePath": "initiatives/billing-launch/work-items/add-billing-api" + }, + "initiativeLink": { + "store": "platform", + "id": "billing-launch", + "root": "/absolute/store/initiatives/billing-launch" + }, + "invocationContext": { + "kind": "repo | workspace", + "root": "/absolute/current/context" + }, + "actionContext": { + "mode": "implementation-ready | planning-only | target-selection-required", + "sourceOfTruth": "repo | context-store | workspace-local", + "allowedEditRoots": [], + "requiresTargetSelection": true, + "constraints": [ + "Use resolved output paths from the CLI.", + "Do not infer editable repos from the current working directory." + ] + }, + "nextCommands": {} +} +``` + +The important fields are: + +- `workTarget`: the object the agent is acting on. +- `initiativeLink`: the canonical shared coordination context, when present. +- `invocationContext`: where the command was run. +- `actionContext`: what the agent may edit. +- `nextCommands`: follow-up commands the agent should run instead of inventing + paths. + +### Lifecycle Rules + +- Repo-local changes are implementation-ready when the repo is the allowed edit + root. +- Initiative work items are planning-only until they select or link repo-local + implementation changes. +- Workspace-local changes are compatibility artifacts, not the preferred new + shared planning model. +- Apply, archive, repo spec sync, and repo delta validation should remain + repo-local until context-store-backed change lifecycle is explicitly designed. +- If `allowedEditRoots` is empty or target selection is required, agents should + stop before editing implementation files. + +### Edge Cases To Design For + +- Same initiative id exists in multiple stores. +- Some registered stores are unreadable or out of sync. +- A workspace can see a repo path but the user has not selected it as an edit + target. +- The terminal is inside a workspace, but the intended work belongs in a linked + repo. +- The terminal is inside a linked repo, but the user wants shared initiative + planning first. +- A repo-local change references an initiative store that is not registered on + the current machine. +- A context-store work item uses a schema that another teammate does not have. +- A change id exists both as a repo-local change and an initiative work item. +- A central team edits initiative context while implementation teams edit + linked repo-local changes. + +### Suggested Direction From The Pass + +Keep Item 8 narrow: + +- Add initiative metadata to repo-local changes. +- Add `new change <id> --initiative <store>/<initiative> --json`. +- Use `initiative show` plus workspace/repo context as the agent handoff + backbone. +- Do not implement context-store-backed OpenSpec changes in Item 8. + +Use Item 13 to decide the larger model: + +- Whether initiative work items should become a first-class artifact. +- Whether "change home" remains internal language. +- How context-store-hosted work binds to repo targets, specs, validation, + apply, archive, and sync. +- How skills and generated guidance teach agents to trust CLI JSON instead of + hardcoded paths or current working directory assumptions. + +## Target-Bound Reframe Subagent Pass + +Date: 2026-05-23. + +Question explored: + +```text +Given the product tension around central versus repo-local change storage, how +should Item 13 be reframed before implementation work begins? +``` + +Three subagent passes reviewed Item 13 from product semantics, agent-first UX, +and lifecycle/implementation angles. + +### Product Semantics Findings + +- The visible work item should not be framed as generic configurable storage. + That makes the hard question sound like path plumbing. +- The sharper product question is whether initiative-hosted artifacts can + become executable OpenSpec changes after they are bound to a target repo or + spec root. +- Repo-local changes remain the default executable implementation artifact. +- Initiative-hosted artifacts start as planning-only work items, briefs, or + proposals. +- "Change home" can stay as internal resolver language, but should not be the + main user-facing concept. + +Recommended naming: + +```text +Explore Initiative-Hosted Target-Bound Change Artifacts +``` + +### Agent-First UX Findings + +Agents need stable CLI output that separates the artifact from the thing the +agent may edit: + +```text +Plan lives in: repo-local OpenSpec | initiative context +Editable target: selected repo path | none yet +Linked initiative: platform/billing-launch +``` + +Commands should report structured action context rather than making generated +skills infer paths: + +```json +{ + "workTarget": { + "kind": "repo-change | initiative-work-item | initiative-hosted-change", + "id": "add-billing-api", + "root": "/absolute/path" + }, + "initiativeLink": { + "store": "platform", + "id": "billing-launch" + }, + "implementationTarget": { + "kind": "repo", + "id": "billing-api", + "specRoot": "openspec" + }, + "actionContext": { + "mode": "implementation-ready | planning-only | target-selection-required | unsupported", + "sourceOfTruth": "repo | context-store | workspace-local", + "allowedEditRoots": [], + "constraints": [ + "Use CLI-reported paths.", + "Do not infer editable repos from the current working directory." + ] + }, + "nextCommands": {} +} +``` + +If `allowedEditRoots` is empty, the agent should stop before editing +implementation files. If target selection is required, the command should return +next-step options rather than silently creating an ambiguous implementation +change. + +### Lifecycle And Implementation Findings + +Local code still has strong repo-local assumptions: + +- `src/core/planning-home.ts` models planning homes as `repo | workspace`. +- `src/commands/workflow/new-change.ts` resolves storage from the current + planning home and does not yet expose `--initiative` or `--json`. +- `src/commands/validate.ts` validates changes and specs from + `process.cwd()/openspec/...`. +- `src/core/archive.ts` archives by reading `openspec/changes`, applying deltas + to `openspec/specs`, and moving the change into `openspec/changes/archive`. +- `src/core/artifact-graph/types.ts` metadata does not yet model initiative + links, target repo identity, artifact home, or edit boundaries. +- Generated skills and workflow templates still contain repo-local path + assumptions such as `openspec/changes/<name>/`. + +These are not bugs in the current repo-local model. They are evidence that an +initiative-hosted executable change is a lifecycle design, not a small path +switch. + +### Updated Recommendation + +Keep Item 8 narrow: + +- Create or link repo-local changes with initiative metadata. +- Add JSON output for the agent handoff. +- Do not implement context-store-hosted executable changes in Item 8. + +Use Item 13 to answer the bigger question: + +- What initiative-hosted artifacts exist before an implementation target is + known? +- What target metadata lets a shared artifact graduate into an executable + change? +- How do local workspace and registry mappings resolve target repo identity to + machine-local paths? +- Which lifecycle commands should refuse, hand off to a repo-local change, or + operate directly against a resolved target? +- How should command and skill output teach agents to trust CLI-reported paths, + edit roots, and next commands? + +Go/no-go criterion: + +```text +Do not implement initiative-hosted executable changes until create/link, +show/status/list/instructions, validate, apply, archive, spec sync, workspace +resolution, generated skills, and JSON output all share one target-resolution +model. +``` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-explore-configurable-change-homes/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/13-explore-configurable-change-homes/plan.md new file mode 100644 index 0000000000..582a54fe9a --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/13-explore-configurable-change-homes/plan.md @@ -0,0 +1,180 @@ +# Explore Initiative-Hosted Target-Bound Change Artifacts + +## Status + +Not started. Added as a future exploratory work item. Framing updated from +generic "configurable change homes" to the sharper question of when shared +initiative artifacts can become executable, target-bound OpenSpec changes. + +## Source Of Truth + +Start from `../../direction.md`, especially the current boundary: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Why This Exists + +The current initiative direction assumes OpenSpec changes usually live in the +local repo that owns implementation. That keeps validation, archive, and spec +sync close to the code that will change. + +Some coordinated work may need a shared home before the owning repo is obvious. +A team may want initiative-hosted planning artifacts, and later may want some +of those artifacts to become implementation-ready plans for a specific repo or +spec root. + +This is not just a storage preference. A shared artifact is planning-only until +it has an explicit portable target binding and lifecycle rules for validate, +apply, archive, spec sync, and conflict handling. + +## Goal + +Decide whether OpenSpec should support initiative-hosted artifacts that can +graduate into executable changes only after they are bound to an implementation +target. + +Repo-local changes remain the default executable implementation artifact. Item +13 should decide if, when, and how a context-store-hosted artifact can safely be +treated as a change. + +The answer should preserve three boundaries: + +- Initiatives coordinate shared context. +- Changes describe executable implementation plans. +- Workspaces open local views and must not imply edit permission. + +## Model To Explore + +```text +Initiative artifact + -> planning-only by default + -> may become target-bound later + +Repo-local change + -> home: repo/openspec/changes/<id>/ + -> target: implicit current repo/spec root + -> lifecycle: validate/apply/archive/spec sync are repo-local + +Initiative-hosted target-bound change + -> home: context-store/initiatives/<initiative>/changes/<id>/ + -> target: explicit repo/spec root identity + -> lifecycle: unsupported until target resolution is designed + +Agent output + -> reports the work target + -> reports where the artifact lives + -> reports the implementation target, if any + -> reports allowed edit roots for this machine +``` + +Keep "change home" as internal resolver language. User-facing and agent-facing +output should prefer clearer phrases like "plan lives in repo-local OpenSpec", +"plan lives with the initiative", and "editable target". + +## Core Invariants + +- Storage location does not imply ownership, edit permission, or lifecycle. +- Work identity, artifact home, execution target, and allowed edit roots are + separate decisions. +- Shared context-store files must not store machine-local checkout paths. +- A targetless initiative artifact is a brief, work item, or proposal, not an + implementation-ready OpenSpec change. +- A context-store-hosted artifact can be considered executable only after it has + explicit target metadata and lifecycle command support. +- Item 8 remains repo-local: `new change <id> --initiative ...` creates or links + a repo-local change only. + +## Questions To Answer + +- What exact artifact types exist under an initiative: work items, briefs, + target-bound changes, or something else? +- What portable target metadata is required before an initiative-hosted artifact + can be executable? +- How does local resolution map a target repo identity to a checkout path, + OpenSpec root, branch, and allowed edit roots? +- Should central target-bound changes require explicit opt-in such as + `--home initiative`, or can initiative/store policy choose that behavior? +- If config exists, what is the deterministic precedence across explicit CLI + flags, repo config, initiative preference, context-store default, user default, + and built-in repo-local behavior? +- How does `openspec new change` report work target, artifact home, + implementation target, initiative link, action context, and next commands in + JSON? +- How do validate, apply, archive, and spec sync behave when the artifact lives + in a context store but the target specs live in a repo? +- Should archive for an initiative-hosted target-bound change archive centrally, + materialize a repo-local handoff change, or refuse until a repo-local change + exists? +- Which command and skill surfaces still hardcode `openspec/changes/`, current + working directory, or repo-local edit assumptions? +- What compatibility behavior preserves existing repo-local and workspace-local + changes? + +## Agent-First Output Contract + +Any future command that creates, reads, or resolves this work should make the +agent's next move explicit: + +```json +{ + "workTarget": { + "kind": "repo-change | initiative-work-item | initiative-hosted-change", + "id": "add-billing-api", + "root": "/absolute/path/reported/by/cli" + }, + "initiativeLink": { + "store": "platform", + "id": "billing-launch" + }, + "implementationTarget": { + "kind": "repo", + "id": "billing-api", + "specRoot": "openspec" + }, + "actionContext": { + "mode": "implementation-ready | planning-only | target-selection-required | unsupported", + "sourceOfTruth": "repo | context-store | workspace-local", + "allowedEditRoots": [], + "constraints": [ + "Use CLI-reported paths.", + "Do not infer editable repos from the current working directory." + ] + }, + "nextCommands": {} +} +``` + +If `allowedEditRoots` is empty, the agent should not edit implementation files. +If target selection is required, the command should return options or next +commands instead of creating an ambiguous implementation plan. + +## Explicitly Out Of Scope + +- Implementing context-store-hosted executable changes before the model is + decided. +- Moving existing repo-local changes into a context store automatically. +- Making initiatives own implementation artifacts by default. +- Making workspace-level changes the new shared planning model. +- Cross-repo apply, archive, or validation orchestration. +- Storing machine-local checkout paths in shared context-store files. +- Adding global defaults that can surprise ordinary repo-local commands into + writing shared artifacts. + +## Go/No-Go Criteria + +Do not implement initiative-hosted executable changes until OpenSpec has one +target-resolution model that can cover: + +- create and link output +- status, show, list, and instructions output +- validate, apply, archive, and spec sync behavior +- workspace registry and local repo mapping behavior +- generated skill guidance and command examples +- JSON output for work target, artifact home, implementation target, edit roots, + unsupported lifecycle commands, and next commands diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-explore-configurable-change-homes/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/13-explore-configurable-change-homes/tasks.md new file mode 100644 index 0000000000..3cf8bec826 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/13-explore-configurable-change-homes/tasks.md @@ -0,0 +1,28 @@ +# Explore Initiative-Hosted Target-Bound Change Artifacts Tasks + +- [x] Create Item 13 work-item tracking notes. +- [x] Reframe Item 13 from generic change-home configuration to + initiative-hosted target-bound change artifacts. +- [ ] Audit commands, templates, validation, archive, apply, completion, and + docs for repo-local `openspec/changes/` assumptions. +- [ ] Define user-facing naming for initiative work items, briefs, + target-bound changes, artifact homes, and editable targets. +- [ ] Decide whether initiative-hosted artifacts can graduate into executable + changes, and which target metadata is required first. +- [ ] Decide the configuration or opt-in surface for repo-local versus + initiative-hosted artifacts. +- [ ] Define how `openspec new change` selects and reports the artifact home, + implementation target, initiative link, and action context. +- [ ] Define how initiative linking and workspace guidance discover artifact + homes and target repo mappings. +- [ ] Decide how initiative-hosted target-bound changes bind to repo specs, + implementation roots, branches, and local checkout paths. +- [ ] Decide validation, apply, archive, sync, and conflict behavior for + initiative-hosted target-bound changes. +- [ ] Define the agent JSON contract for work target, artifact home, + implementation target, allowed edit roots, unsupported lifecycle commands, and + next commands. +- [ ] Record compatibility behavior for existing repo-local and workspace-local + changes. +- [ ] Produce a recommendation, opt-in/config examples, affected command list, + and go/no-go criteria for implementation. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md new file mode 100644 index 0000000000..4f9c51f481 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md @@ -0,0 +1,28 @@ +# Proposed Initiative Next / Agent Handoff UX Evidence + +## Source + +This discussion item came from the GSD workspace comparison. + +GSD's useful lesson was not its storage model. It was the simple user loop: +create context, move to the next concrete step, and keep the agent from guessing +where it is in the workflow. + +OpenSpec should keep the current boundary: + +```text +Context stores sync truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +The possible gap is that `initiative show`, repo-local change linking, and +workspace opening may still require an agent to stitch together the next action +by hand. + +## Current Recommendation + +Keep this as a discussion draft until workspace initiative opening is clearer. +If accepted, the first version should be a small handoff/readiness command, not +status, progress, dashboarding, or workspace orchestration. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md new file mode 100644 index 0000000000..71deef1f57 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md @@ -0,0 +1,59 @@ +# Proposed Initiative Next / Agent Handoff UX + +## Status + +Discussion draft. Not locked into the numbered roadmap yet. + +## Why This Exists + +The GSD workspace comparison highlighted a UX gap: OpenSpec has increasingly +good discovery primitives, but agents still need to infer the next useful step +from several commands. + +The candidate idea is a tiny "what now?" handoff command after initiative +discovery from the current repo or workspace. It should not become a dashboard, +work-progress status view, or replacement for workspace local-view behavior. + +## Candidate Goal + +Help an agent answer: + +```text +What should I do next for this initiative from the current repo or workspace? +``` + +## Possible Command Shape + +```bash +openspec initiative next <id> --json +``` + +Possible response: + +```json +{ + "initiative": "billing-launch", + "next_action": "create_repo_change", + "reason": "initiative found, no linked local change exists for this repo", + "suggested_command": "openspec new change add-billing-api --initiative billing-launch" +} +``` + +## Discussion Points To Review + +- Should this become a numbered roadmap item before workspace initiative + opening? +- Is `initiative next` the right command name, or should this guidance live + inside workspace initiative opening or repo-local status? +- Should the command suggest exactly one next action, or return a ranked set of + possible actions? +- Should it inspect actual work progress, or stay limited to handoff readiness? +- How should it behave when no stores are registered, the initiative is + ambiguous, the local repo is unrelated, or linked changes already exist? + +## Boundaries + +- Do not add progress/status semantics in the first version. +- Do not create changes, clone repos, or mutate workspace state. +- Do not make workspace opening a prerequisite. +- Prefer agent-readable JSON over broad interactive UX in the first slice. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md new file mode 100644 index 0000000000..eb1616803f --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md @@ -0,0 +1,12 @@ +# Proposed Initiative Next / Agent Handoff UX Tasks + +These are discussion tasks only. Do not implement until the roadmap position and +scope are confirmed. + +- [ ] Decide whether to add this as a numbered roadmap item. +- [ ] Decide whether the command is `initiative next`, workspace initiative + opening guidance, or repo-local status guidance. +- [ ] Decide the minimal JSON output contract for agent handoff. +- [ ] Decide whether the command returns one next action or multiple options. +- [ ] Decide the error and empty-state behavior. +- [ ] Decide whether actual work progress/status is explicitly out of scope. diff --git a/src/cli/index.ts b/src/cli/index.ts index baa3e48fa1..d06fdddc54 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { createRequire } from 'module'; import ora from 'ora'; import path from 'path'; +import { fileURLToPath } from 'url'; import { promises as fs } from 'fs'; import { AI_TOOLS } from '../core/config.js'; import { UpdateCommand } from '../core/update.js'; @@ -20,6 +21,8 @@ import { registerWorkspaceCommand, runWorkspaceUpdateForRoot, } from '../commands/workspace.js'; +import { registerContextStoreCommand } from '../commands/context-store.js'; +import { registerInitiativeCommand } from '../commands/initiative.js'; import { findWorkspaceRoot } from '../core/workspace/index.js'; import { statusCommand, @@ -28,12 +31,14 @@ 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'; @@ -297,6 +302,8 @@ registerSpecCommand(program); registerConfigCommand(program); registerSchemaCommand(program); registerWorkspaceCommand(program); +registerContextStoreCommand(program); +registerInitiativeCommand(program); // Top-level validate command program @@ -510,7 +517,11 @@ newCmd .option('--description <text>', 'Description to add to README.md') .option('--goal <text>', 'Workspace product goal to store with the change') .option('--areas <names>', 'Comma-separated affected workspace link names') + .option('--initiative <id>', 'Link the repo-local change to an initiative') + .option('--store <id>', 'Context store id for --initiative') + .option('--store-path <path>', 'Existing local context store root for --initiative') .option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) + .option('--json', 'Output as JSON') .action(async (name: string, options: NewChangeOptions) => { try { await newChangeCommand(name, options); @@ -521,4 +532,32 @@ newCmd } }); -program.parse(); +// Set command group +const setCmd = program.command('set').description('Set checked-in OpenSpec metadata'); + +setCmd + .command('change <name>') + .description('Set repo-local change metadata') + .option('--initiative <id>', 'Link the repo-local change to an initiative') + .option('--store <id>', 'Context store id for --initiative') + .option('--store-path <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 { + program.parse(argv); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runCli(); +} diff --git a/src/commands/config.ts b/src/commands/config.ts index 25ddf48582..871d3a0851 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -25,7 +25,7 @@ import { hasProjectConfigDrift } from '../core/profile-sync-drift.js'; import { findWorkspaceRoot, hasWorkspaceSkillProfileDrift, - readOptionalWorkspaceLocalState, + readOptionalWorkspaceViewState, } from '../core/workspace/index.js'; type ProfileAction = 'both' | 'delivery' | 'workflows' | 'keep'; @@ -231,14 +231,14 @@ async function maybeWarnConfigDrift( ): Promise<void> { const workspaceContext = await resolveWorkspaceConfigProfileContext(); if (workspaceContext) { - let localState = null; + let viewState = null; try { - localState = await readOptionalWorkspaceLocalState(workspaceContext.root); + viewState = await readOptionalWorkspaceViewState(workspaceContext.root); } catch { return; } - if (hasWorkspaceSkillProfileDrift(localState)) { + if (hasWorkspaceSkillProfileDrift(viewState)) { console.log( colorize( 'Warning: Workspace-local agent skills are out of sync with the active global profile. Run `openspec workspace update` to sync.' diff --git a/src/commands/context-store.ts b/src/commands/context-store.ts new file mode 100644 index 0000000000..6c0f9136d6 --- /dev/null +++ b/src/commands/context-store.ts @@ -0,0 +1,402 @@ +import { Command } from 'commander'; + +import { + ContextStoreError, + doctorContextStores, + listContextStores, + prepareContextStoreSetup, + registerExistingContextStore, + setupPreparedContextStore, + type ContextStoreDiagnostic, + type ContextStoreDoctorResult, + type ContextStoreInfo, + type ContextStoreInspection, + type ContextStoreListResult, + type ContextStoreMutationResult, +} from '../core/context-store/index.js'; +import { isInteractive } from '../utils/interactive.js'; + +interface ContextStoreSetupOptions { + path?: string; + initGit?: boolean; + json?: boolean; +} + +interface ContextStoreRegisterOptions { + id?: string; + json?: boolean; +} + +interface ContextStoreJsonOptions { + json?: boolean; +} + +interface ContextStoreOutput { + id: string; + root: string; + metadata_path?: string; +} + +interface ContextStoreMutationOutput { + context_store: ContextStoreOutput | null; + registry: { + path: string; + registered: boolean; + } | null; + git: { + is_repository: boolean; + initialized: boolean; + } | null; + created_files: string[]; + status: ContextStoreDiagnostic[]; +} + +interface ContextStoreListOutput { + context_stores: ContextStoreOutput[]; + status: ContextStoreDiagnostic[]; +} + +interface ContextStoreDoctorStoreOutput extends ContextStoreOutput { + metadata: ContextStoreInspection['metadata']; + git: { + is_repository: boolean | null; + }; + status: ContextStoreDiagnostic[]; +} + +interface ContextStoreDoctorOutput { + context_stores: ContextStoreDoctorStoreOutput[]; + status: ContextStoreDiagnostic[]; +} + +function printJson(payload: unknown): void { + console.log(JSON.stringify(payload, null, 2)); +} + +function asErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function appendStatus<T extends { status: ContextStoreDiagnostic[] }>( + payload: T, + status: ContextStoreDiagnostic +): T { + return { + ...payload, + status: [...payload.status, status], + }; +} + +function toStoreOutput(store: ContextStoreInfo): ContextStoreOutput { + return { + id: store.id, + root: store.root, + ...(store.metadataPath ? { metadata_path: store.metadataPath } : {}), + }; +} + +function toMutationOutput(result: ContextStoreMutationResult): ContextStoreMutationOutput { + return { + context_store: toStoreOutput(result.store), + registry: { + path: result.registryCommit.path, + registered: true, + }, + git: { + is_repository: result.git.isRepository, + initialized: result.git.initialized, + }, + created_files: result.createdArtifacts, + status: [], + }; +} + +function toListOutput(result: ContextStoreListResult): ContextStoreListOutput { + return { + context_stores: result.stores.map(toStoreOutput), + status: [], + }; +} + +function toDoctorStoreOutput(store: ContextStoreInspection): ContextStoreDoctorStoreOutput { + return { + ...toStoreOutput(store), + metadata: store.metadata, + git: { + is_repository: store.git.isRepository, + }, + status: store.diagnostics, + }; +} + +function toDoctorOutput(result: ContextStoreDoctorResult): ContextStoreDoctorOutput { + return { + context_stores: result.stores.map(toDoctorStoreOutput), + status: result.diagnostics, + }; +} + +function asStatus(error: unknown): ContextStoreDiagnostic { + if (error instanceof ContextStoreError) { + return error.diagnostic; + } + + const message = asErrorMessage(error); + + return { + severity: 'error', + code: 'context_store_error', + message, + }; +} + +function isPromptCancellationError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === 'ExitPromptError' || error.message.includes('force closed the prompt with SIGINT')) + ); +} + +async function shouldInitializeGit(options: ContextStoreSetupOptions): Promise<boolean> { + if (options.initGit !== undefined) { + return options.initGit; + } + + if (options.json || !isInteractive()) { + return false; + } + + const { confirm } = await import('@inquirer/prompts'); + return confirm({ + message: 'Initialize Git repository?', + default: true, + }); +} + +function formatGitHuman(git: ContextStoreMutationOutput['git']): string { + if (!git) return 'unknown'; + if (git.initialized) return 'initialized'; + return git.is_repository ? 'repository detected' : 'not initialized'; +} + +function printMutationHuman(title: string, payload: ContextStoreMutationOutput): void { + if (!payload.context_store || !payload.registry || !payload.git) { + return; + } + + console.log(title); + console.log(''); + console.log(`ID: ${payload.context_store.id}`); + console.log(`Location: ${payload.context_store.root}`); + console.log(`Metadata: ${payload.context_store.metadata_path}`); + console.log(`Registry: ${payload.registry.path}`); + console.log(`Git: ${formatGitHuman(payload.git)}`); +} + +function printListHuman(payload: ContextStoreListOutput): void { + if (payload.context_stores.length === 0) { + console.log('No context stores registered.'); + console.log(''); + console.log('Next:'); + console.log(' openspec context-store setup team-context'); + console.log(' openspec context-store register /path/to/context-store'); + return; + } + + console.log(`OpenSpec context stores (${payload.context_stores.length})`); + console.log(''); + console.log(`${'ID'.padEnd(16)}Location`); + for (const store of payload.context_stores) { + console.log(`${store.id.padEnd(16)}${store.root}`); + } +} + +function formatMetadataHuman(store: ContextStoreDoctorOutput['context_stores'][number]): string { + if (store.metadata.valid) return 'ok'; + if (store.metadata.present === false) return 'missing'; + if (store.metadata.present === null) return 'unknown'; + return 'invalid'; +} + +function formatDoctorGitHuman(store: ContextStoreDoctorOutput['context_stores'][number]): string { + if (store.git.is_repository === null) return 'unknown'; + return store.git.is_repository ? 'repository detected' : 'not detected'; +} + +function printDoctorHuman(payload: ContextStoreDoctorOutput): void { + if (payload.context_stores.length === 0) { + console.log('No context stores registered.'); + return; + } + + console.log('Context store doctor'); + for (const store of payload.context_stores) { + console.log(''); + console.log(store.id); + console.log(` Location: ${store.root}`); + console.log(` Metadata: ${formatMetadataHuman(store)}`); + console.log(` Git: ${formatDoctorGitHuman(store)}`); + + if (store.status.length === 0) { + console.log(' Issues: none'); + continue; + } + + console.log(' Issues:'); + for (const status of store.status) { + console.log(` - ${status.message}`); + if (status.fix) { + console.log(` Fix: ${status.fix}`); + } + } + } +} + +class ContextStoreCommand { + async setup(id: string | undefined, options: ContextStoreSetupOptions = {}): Promise<void> { + try { + const prepared = await prepareContextStoreSetup({ + id, + path: options.path, + }); + const initGit = await shouldInitializeGit(options); + const payload = toMutationOutput(await setupPreparedContextStore(prepared, { + initGit, + })); + + if (options.json) { + printJson(payload); + return; + } + + printMutationHuman('Context store setup complete', payload); + } catch (error) { + this.handleFailure( + options.json, + { context_store: null, registry: null, git: null, created_files: [], status: [] }, + error + ); + } + } + + async register(inputPath: string | undefined, options: ContextStoreRegisterOptions = {}): Promise<void> { + try { + const payload = toMutationOutput(await registerExistingContextStore({ + path: inputPath, + id: options.id, + })); + + if (options.json) { + printJson(payload); + return; + } + + printMutationHuman('Context store registered', payload); + } catch (error) { + this.handleFailure( + options.json, + { context_store: null, registry: null, git: null, created_files: [], status: [] }, + error + ); + } + } + + async list(options: ContextStoreJsonOptions = {}): Promise<void> { + try { + const payload = toListOutput(await listContextStores()); + + if (options.json) { + printJson(payload); + return; + } + + printListHuman(payload); + } catch (error) { + this.handleFailure(options.json, { context_stores: [], status: [] }, error); + } + } + + async doctor(id: string | undefined, options: ContextStoreJsonOptions = {}): Promise<void> { + try { + const payload = toDoctorOutput(await doctorContextStores(id)); + + if (options.json) { + printJson(payload); + return; + } + + printDoctorHuman(payload); + } catch (error) { + this.handleFailure(options.json, { context_stores: [], status: [] }, error); + } + } + + private handleFailure<T extends { status: ContextStoreDiagnostic[] }>( + json: boolean | undefined, + payload: T, + error: unknown + ): void { + if (!json && isPromptCancellationError(error)) { + console.error('Cancelled.'); + process.exitCode = 130; + return; + } + + const status = asStatus(error); + if (json) { + printJson(appendStatus(payload, status)); + process.exitCode = 1; + return; + } + + console.error(`Error: ${status.message}`); + if (status.fix) { + console.error(`Fix: ${status.fix}`); + } + process.exitCode = 1; + } +} + +export function registerContextStoreCommand(program: Command): void { + const contextStoreCommand = new ContextStoreCommand(); + const contextStore = program + .command('context-store') + .description('Set up and inspect local context stores'); + + contextStore + .command('setup [id]') + .description('Create and register a local context store') + .option('--path <path>', 'Context store folder path; defaults to ./<id>') + .option('--init-git', 'Initialize a Git repository in the context store') + .option('--no-init-git', 'Do not initialize a Git repository') + .option('--json', 'Output as JSON') + .action(async (id: string | undefined, options: ContextStoreSetupOptions) => { + await contextStoreCommand.setup(id, options); + }); + + contextStore + .command('register [path]') + .description('Register an existing local context store') + .option('--id <id>', 'Context store id; defaults to metadata or folder name') + .option('--json', 'Output as JSON') + .action(async (inputPath: string | undefined, options: ContextStoreRegisterOptions) => { + await contextStoreCommand.register(inputPath, options); + }); + + contextStore + .command('list') + .alias('ls') + .description('List locally registered context stores') + .option('--json', 'Output as JSON') + .action(async (options: ContextStoreJsonOptions) => { + await contextStoreCommand.list(options); + }); + + contextStore + .command('doctor [id]') + .description('Check local context-store registration and metadata') + .option('--json', 'Output as JSON') + .action(async (id: string | undefined, options: ContextStoreJsonOptions) => { + await contextStoreCommand.doctor(id, options); + }); +} diff --git a/src/commands/initiative.ts b/src/commands/initiative.ts new file mode 100644 index 0000000000..71535a4ee1 --- /dev/null +++ b/src/commands/initiative.ts @@ -0,0 +1,504 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { + createInitiative, + INITIATIVE_FILE_NAMES, + type InitiativeResolutionDetails, + type InitiativeSelectorOptions, + type InitiativeViewReference, + type ContextStoreSelectorSource, + listInitiativeViewReferences, + mountInitiativesCollection, + initiativeDiagnosticFromError as coreInitiativeDiagnosticFromError, + resolveInitiativeViewReference as resolveCoreInitiativeViewReference, + selectContextStoreForInitiative, + type ListedInitiativeReference, + type SelectedContextStore, + type InitiativeState, + type InitiativeDiagnostic, + formatContextStoreSelector, +} from '../core/collections/initiatives/index.js'; + +interface ContextStoreOutput { + id: string; + root: string; + source: ContextStoreSelectorSource; +} + +interface InitiativeOutput extends InitiativeState { + store: string; + root: string; + store_path: string; +} + +interface InitiativeShowContextStoreOutput { + id: string; + root: string; +} + +interface InitiativeShowOutputItem { + version: 1; + id: string; + title: string; + summary: string; + created: string; + root: string; + store_path: string; + metadata_path: string; +} + +interface InitiativeCreateOutput { + context_store: ContextStoreOutput | null; + initiative: InitiativeOutput | null; + created_files: string[]; + status: InitiativeDiagnostic[]; +} + +interface InitiativeListOutput { + context_store: ContextStoreOutput | null; + context_stores: ContextStoreInitiativeOutput[]; + initiatives: InitiativeOutput[]; + status: InitiativeDiagnostic[]; +} + +interface ContextStoreInitiativeOutput { + context_store: ContextStoreOutput; + initiatives: InitiativeOutput[]; + status: InitiativeDiagnostic[]; +} + +interface InitiativeShowOutput { + context_store: InitiativeShowContextStoreOutput | null; + initiative: InitiativeShowOutputItem | null; + status: InitiativeDiagnostic[]; +} + +interface InitiativeCreateOptions extends InitiativeSelectorOptions { + title?: string; + summary?: string; +} + +type InitiativeListOptions = InitiativeSelectorOptions; +type InitiativeShowOptions = InitiativeSelectorOptions; + +export class InitiativeCliError extends Error { + readonly diagnostic: InitiativeDiagnostic; + + constructor( + message: string, + code: string, + options: { target?: string; fix?: string; details?: InitiativeResolutionDetails } = {} + ) { + super(message); + this.diagnostic = { + severity: 'error', + code, + message, + ...options, + }; + } +} + +function printJson(payload: unknown): void { + console.log(JSON.stringify(payload, null, 2)); +} + +export function initiativeDiagnosticFromError(error: unknown): InitiativeDiagnostic { + if (error instanceof InitiativeCliError) { + return error.diagnostic; + } + + return coreInitiativeDiagnosticFromError(error); +} + +function appendDiagnostic<T extends { status: InitiativeDiagnostic[] }>( + payload: T, + diagnostic: InitiativeDiagnostic +): T { + return { + ...payload, + status: [...payload.status, diagnostic], + }; +} + +function requireNonBlankOption( + value: string | undefined, + flagName: string, + target: string, + code: string +): string { + if (value === undefined || value.trim().length === 0) { + throw new InitiativeCliError(`Pass --${flagName} <value>.`, code, { + target, + fix: `openspec initiative create <id> --${flagName} <value>`, + }); + } + + return value.trim(); +} + +function requireInitiativeId( + id: string | undefined, + commandName: 'create' | 'show' +): string { + if (id === undefined || id.trim().length === 0) { + throw new InitiativeCliError('Pass an initiative id.', 'initiative_id_required', { + target: 'initiative.id', + fix: `openspec initiative ${commandName} <id>`, + }); + } + + return id.trim(); +} + +function toContextStoreOutput(selected: SelectedContextStore): ContextStoreOutput { + return { + id: selected.id, + root: selected.root, + source: selected.source, + }; +} + +function toInitiativeOutput( + selected: SelectedContextStore, + state: InitiativeState +): InitiativeOutput { + const collection = mountInitiativesCollection(selected.root); + + return { + ...state, + store: selected.id, + root: collection.resolvePath(state.id), + store_path: collection.toStorePath(state.id), + }; +} + +function listedInitiativeToOutput( + initiative: ListedInitiativeReference +): InitiativeOutput { + return { + version: 1, + id: initiative.id, + title: initiative.title, + summary: initiative.summary, + status: initiative.status, + created: initiative.created, + owners: initiative.owners, + metadata: initiative.metadata, + store: initiative.store, + root: initiative.root, + store_path: initiative.storePath, + }; +} + +function initiativeReferenceToShowOutput( + reference: InitiativeViewReference +): InitiativeShowOutputItem { + return { + version: 1, + id: reference.id, + title: reference.title, + summary: reference.summary, + created: reference.created, + root: reference.root, + store_path: reference.storePath, + metadata_path: reference.metadataPath, + }; +} + +function printCreateHuman(payload: InitiativeCreateOutput): void { + if (!payload.context_store || !payload.initiative) { + return; + } + + console.log(chalk.green('Created initiative')); + console.log(`ID: ${payload.initiative.id}`); + console.log(`Title: ${payload.initiative.title}`); + console.log(`Status: ${payload.initiative.status}`); + console.log(`Context store: ${payload.context_store.id}`); + console.log(`Location: ${payload.initiative.root}`); + console.log(''); + console.log(`Created files (${payload.created_files.length}):`); + for (const fileName of payload.created_files) { + console.log(` - ${fileName}`); + } + console.log(''); + console.log('Next useful commands:'); + console.log(` openspec initiative list ${formatContextStoreSelector(payload.context_store)}`); +} + +function printTableHeader(includeStore: boolean): void { + const idHeader = 'ID'.padEnd(22); + const storeHeader = includeStore ? `${'Store'.padEnd(12)}` : ''; + console.log(`${idHeader}${storeHeader}Title`); +} + +function printInitiativeRow(initiative: InitiativeOutput, includeStore: boolean): void { + const id = initiative.id.padEnd(22); + const store = includeStore ? `${initiative.store.padEnd(12)}` : ''; + console.log(`${id}${store}${initiative.title}`); +} + +function printListStatuses(statuses: InitiativeDiagnostic[]): void { + if (statuses.length === 0) { + return; + } + + console.log(''); + for (const status of statuses) { + console.log(status.message); + if (status.fix) { + console.log(`Run: ${status.fix}`); + } + } +} + +function printListHuman(payload: InitiativeListOutput): void { + if (payload.context_store) { + console.log(`OpenSpec initiatives in ${payload.context_store.id} (${payload.initiatives.length})`); + + if (payload.initiatives.length === 0) { + console.log(''); + console.log(`No initiatives found in ${payload.context_store.id}.`); + console.log(''); + console.log(`Location: ${payload.context_store.root}`); + return; + } + + console.log(''); + printTableHeader(false); + for (const initiative of payload.initiatives) { + printInitiativeRow(initiative, false); + } + console.log(''); + console.log(`Location: ${payload.context_store.root}`); + return; + } + + if (payload.context_stores.length === 0) { + console.log('No initiatives found because no context stores are registered.'); + return; + } + + if (payload.initiatives.length === 0) { + console.log('No initiatives found across registered context stores.'); + printListStatuses(payload.status); + return; + } + + console.log( + `OpenSpec initiatives (${payload.initiatives.length} across ${payload.context_stores.length} stores)` + ); + console.log(''); + printTableHeader(true); + for (const initiative of payload.initiatives) { + printInitiativeRow(initiative, true); + } + printListStatuses(payload.status); +} + +function printShowHuman(payload: InitiativeShowOutput): void { + if (!payload.context_store || !payload.initiative) { + return; + } + + console.log(`OpenSpec initiative: ${payload.initiative.title}`); + console.log(''); + console.log(`ID: ${payload.initiative.id}`); + console.log(`Summary: ${payload.initiative.summary}`); + console.log(`Context store: ${payload.context_store.id}`); + console.log(`Location: ${payload.initiative.root}`); + console.log(`Metadata: ${payload.initiative.metadata_path}`); +} + +function printDiagnosticMatches(diagnostic: InitiativeDiagnostic): void { + const matches = diagnostic.details?.matches ?? []; + if (matches.length === 0) { + return; + } + + console.error(''); + console.error(diagnostic.code === 'initiative_lookup_incomplete' ? 'Partial matches:' : 'Matches:'); + for (const match of matches) { + console.error(` ${match.context_store.id.padEnd(12)}${match.initiative.root}`); + } +} + +class InitiativeCommand { + async create(id: string | undefined, options: InitiativeCreateOptions = {}): Promise<void> { + try { + const initiativeId = requireInitiativeId(id, 'create'); + const title = requireNonBlankOption( + options.title, + 'title', + 'initiative.title', + 'initiative_title_required' + ); + const summary = requireNonBlankOption( + options.summary, + 'summary', + 'initiative.summary', + 'initiative_summary_required' + ); + const selected = await selectContextStoreForInitiative(options, 'create'); + const collection = mountInitiativesCollection(selected.root); + const state = await createInitiative({ + collection, + id: initiativeId, + title, + summary, + }); + const payload: InitiativeCreateOutput = { + context_store: toContextStoreOutput(selected), + initiative: toInitiativeOutput(selected, state), + created_files: [...INITIATIVE_FILE_NAMES], + status: [], + }; + + if (options.json) { + printJson(payload); + return; + } + + printCreateHuman(payload); + } catch (error) { + this.handleFailure( + options.json, + { context_store: null, initiative: null, created_files: [], status: [] }, + error + ); + } + } + + async list(options: InitiativeListOptions = {}): Promise<void> { + try { + const payload = await this.buildListPayload(options); + + if (options.json) { + printJson(payload); + return; + } + + printListHuman(payload); + } catch (error) { + this.handleFailure( + options.json, + { context_store: null, context_stores: [], initiatives: [], status: [] }, + error + ); + } + } + + async show(id: string | undefined, options: InitiativeShowOptions = {}): Promise<void> { + try { + const initiativeId = requireInitiativeId(id, 'show'); + const payload = await this.buildShowPayload(initiativeId, options); + + if (options.json) { + printJson(payload); + return; + } + + printShowHuman(payload); + } catch (error) { + this.handleFailure( + options.json, + { context_store: null, initiative: null, status: [] }, + error + ); + } + } + + private async buildListPayload(options: InitiativeListOptions): Promise<InitiativeListOutput> { + const listed = await listInitiativeViewReferences(options); + const contextStores = listed.contextStores.map((store) => ({ + context_store: toContextStoreOutput(store.contextStore), + initiatives: store.initiatives.map(listedInitiativeToOutput), + status: store.status, + })); + + return { + context_store: listed.contextStore ? toContextStoreOutput(listed.contextStore) : null, + context_stores: contextStores, + initiatives: listed.initiatives.map(listedInitiativeToOutput), + status: listed.status, + }; + } + + async buildShowPayload( + initiativeId: string, + options: InitiativeShowOptions + ): Promise<InitiativeShowOutput> { + const reference = await resolveCoreInitiativeViewReference(initiativeId, options); + return { + context_store: { + id: reference.store, + root: reference.storeRoot, + }, + initiative: initiativeReferenceToShowOutput(reference), + status: [], + }; + } + + private handleFailure<T extends { status: InitiativeDiagnostic[] }>( + json: boolean | undefined, + payload: T, + error: unknown + ): void { + const diagnostic = initiativeDiagnosticFromError(error); + + if (json) { + printJson(appendDiagnostic(payload, diagnostic)); + process.exitCode = 1; + return; + } + + console.error(`Error: ${diagnostic.message}`); + printDiagnosticMatches(diagnostic); + if (diagnostic.fix) { + console.error(`Fix: ${diagnostic.fix}`); + } + process.exitCode = 1; + } +} + +function addContextStoreSelectorOptions(command: Command): Command { + return command + .option('--store <id>', 'Context store id from the local context-store registry') + .option('--store-path <path>', 'Existing local context store root') + .option('--json', 'Output as JSON'); +} + +export function registerInitiativeCommand(program: Command): void { + const initiativeCommand = new InitiativeCommand(); + const initiative = program + .command('initiative') + .description('Create and list coordinated initiatives'); + + addContextStoreSelectorOptions( + initiative + .command('create [id]') + .description('Create an initiative in a context store') + .option('--title <title>', 'Initiative title') + .option('--summary <summary>', 'Initiative summary') + ).action(async (id: string | undefined, options: InitiativeCreateOptions) => { + await initiativeCommand.create(id, options); + }); + + addContextStoreSelectorOptions( + initiative + .command('show <id>') + .description('Show where an initiative lives and how to read it') + ).action(async (id: string | undefined, options: InitiativeShowOptions) => { + await initiativeCommand.show(id, options); + }); + + addContextStoreSelectorOptions( + initiative + .command('list') + .alias('ls') + .description('List initiatives across registered context stores') + ).action(async (options: InitiativeListOptions) => { + await initiativeCommand.list(options); + }); +} diff --git a/src/commands/workflow/index.ts b/src/commands/workflow/index.ts index 232b2dbe34..67b413a697 100644 --- a/src/commands/workflow/index.ts +++ b/src/commands/workflow/index.ts @@ -19,4 +19,7 @@ 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 new file mode 100644 index 0000000000..56fd5d6852 --- /dev/null +++ b/src/commands/workflow/initiative-link.ts @@ -0,0 +1,81 @@ +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 <id> 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 <id> 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 b3ca42e37e..71f6918a28 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -110,6 +110,7 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc changeName, schemaName, changeDir, + initiative, resolvedOutputPath, description, instruction, @@ -124,6 +125,11 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc console.log(`<artifact id="${artifactId}" change="${changeName}" schema="${schemaName}">`); console.log(); + if (initiative) { + console.log(`<initiative store="${initiative.store}" id="${initiative.id}" />`); + console.log(); + } + // Warning for blocked artifacts if (isBlocked) { const missing = dependencies.filter((d) => !d.done).map((d) => d.id); @@ -343,6 +349,7 @@ export async function generateApplyInstructions( changeName, changeDir, schemaName: context.schemaName, + ...(context.initiative ? { initiative: context.initiative } : {}), contextFiles, progress: { total, complete, remaining }, tasks, @@ -392,10 +399,13 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions } export function printApplyInstructionsText(instructions: ApplyInstructions): void { - const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions; + const { changeName, schemaName, initiative, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions; console.log(`## Apply: ${changeName}`); console.log(`Schema: ${schemaName}`); + if (initiative) { + console.log(`Initiative: ${initiative.store}/${initiative.id}`); + } console.log(); // Warning for blocked state diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 8a1d91d38c..b415552435 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -13,6 +13,17 @@ import { 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'; // ----------------------------------------------------------------------------- // Types @@ -23,6 +34,20 @@ export interface NewChangeOptions { goal?: string; areas?: string; schema?: string; + initiative?: string; + store?: string; + storePath?: string; + json?: boolean; +} + +interface NewChangeOutput { + change: { + id: string; + path: string; + metadataPath: string; + schema: string; + }; + initiative?: InitiativeLinkReference; } // ----------------------------------------------------------------------------- @@ -58,31 +83,77 @@ function validateWorkspaceAffectedAreas(planningHome: PlanningHome, affectedArea } } -export async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> { - if (!name) { - throw new Error('Missing required argument <name>'); - } +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 } : {}), + }; +} - const validation = validateChangeName(name); - if (!validation.valid) { - throw new Error(validation.error); +function printCreatedChangeHuman(payload: NewChangeOutput, planningHome: PlanningHome): void { + if (!payload.change) { + return; } - const planningHome = resolveCurrentPlanningHomeSync(); - const projectRoot = planningHome.root; - const affectedAreas = parseAffectedAreas(options.areas); - validateWorkspaceAffectedAreas(planningHome, affectedAreas); - - // Validate schema if provided - if (options.schema) { - validateSchemaExists(options.schema, projectRoot); + const location = formatChangeLocation(planningHome, payload.change.id); + const scope = planningHome.kind === 'workspace' ? 'workspace change' : 'change'; + console.log(`Created ${scope} '${payload.change.id}' at ${location}/`); + console.log(`Schema: ${payload.change.schema}`); + if (payload.initiative) { + console.log(`Initiative: ${formatInitiativeLink(payload.initiative)}`); } +} - const resolvedSchema = options.schema ?? planningHome.defaultSchema; - const schemaDisplay = ` with schema '${resolvedSchema}'`; - const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start(); +export async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> { + const spinner = options.json ? undefined : ora(); try { + if (!name) { + throw new Error('Missing required argument <name>'); + } + + const validation = validateChangeName(name); + if (!validation.valid) { + 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); + + initiative = await resolveInitiativeLinkReference(options.initiative, { + store: options.store, + storePath: options.storePath, + }); + } + + // Validate schema if provided + if (options.schema) { + validateSchemaExists(options.schema, projectRoot); + } + + const resolvedSchema = options.schema ?? planningHome.defaultSchema; + if (spinner) { + spinner.start(`Creating change '${name}' with schema '${resolvedSchema}'...`); + } + const workspaceGoal = planningHome.kind === 'workspace' ? options.goal ?? options.description : options.goal; @@ -93,6 +164,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha metadata: { ...(workspaceGoal ? { goal: workspaceGoal } : {}), ...(affectedAreas.length > 0 ? { affected_areas: affectedAreas } : {}), + ...(initiative ? { initiative } : {}), }, }); @@ -103,20 +175,34 @@ export async function newChangeCommand(name: string | undefined, options: NewCha await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8'); } - const location = formatChangeLocation(planningHome, name); - const scope = planningHome.kind === 'workspace' ? 'workspace change' : 'change'; - spinner.succeed(`Created ${scope} '${name}' at ${location}/ (schema: ${result.schema})`); + const payload = outputForCreatedChange(name, result.changeDir, result.schema, initiative); - if (planningHome.kind === 'workspace') { + if (options.json) { + printJson(payload); + return; + } + + 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 workspace specs or tasks as planning continues.'); + 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.'); } } catch (error) { - spinner.fail(`Failed to create change '${name}'`); + spinner?.stop(); + if (options.json) { + printJson({ + change: null, + status: [statusFromError(error)], + }); + process.exitCode = 1; + return; + } throw error; } } diff --git a/src/commands/workflow/set-change.ts b/src/commands/workflow/set-change.ts new file mode 100644 index 0000000000..edf97bfc46 --- /dev/null +++ b/src/commands/workflow/set-change.ts @@ -0,0 +1,148 @@ +/** + * 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<void> { + try { + if (!name) { + throw new Error('Missing required argument <name>'); + } + + 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 638bfcb3b1..b7d2a995c5 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -9,6 +9,7 @@ import chalk from 'chalk'; 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 { validateChangeName } from '../../utils/change-utils.js'; // ----------------------------------------------------------------------------- @@ -25,6 +26,7 @@ export interface ApplyInstructions { changeName: string; changeDir: string; schemaName: string; + initiative?: InitiativeLink; contextFiles: Record<string, string[]>; progress: { total: number; diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index f5739fef8f..7e21bd1b29 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -99,6 +99,9 @@ export function printStatusText(status: ChangeStatus): void { console.log(`Change: ${status.changeName}`); console.log(`Schema: ${status.schemaName}`); + if (status.initiative) { + console.log(`Initiative: ${status.initiative.store}/${status.initiative.id}`); + } if (status.planningHome) { const label = status.planningHome.kind === 'workspace' ? `workspace${status.planningHome.workspaceName ? ` (${status.planningHome.workspaceName})` : ''}` diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index 28d3c43d21..1733359b56 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -8,18 +8,15 @@ import { WorkspaceSkillInstallationReport, createWorkspaceSkillSkippedReport, generateWorkspaceAgentSkills, - getDefaultWorkspaceOpenerChoiceValue, getWorkspaceSkillCapableTools, getWorkspaceSkillToolIds, getWorkspaceOpenerLabel, - isWorkspaceAgentOpenerId, - listWorkspaceOpenerChoices, - parseWorkspacePreferredOpenerValue, parseWorkspaceSkillToolsValue, updateWorkspaceAgentSkills, - listWorkspaceRegistryEntries, - readOptionalWorkspaceLocalState, - writeWorkspaceLocalState, + listKnownWorkspaceEntries, + readWorkspaceViewState, + syncWorkspaceOpenSurface, + writeWorkspaceViewState, } from '../core/workspace/index.js'; import { isInteractive, resolveNoInteractive } from '../utils/interactive.js'; import { @@ -30,8 +27,6 @@ import { loadWorkspaceForList, parseSetupLinks, readWorkspaceForMutation, - readRegistry, - recordSelectedWorkspaceAfterMutation, resolveExistingDirectory, updateWorkspaceLink, validateLinkNameForCommand, @@ -42,11 +37,20 @@ import { selectWorkspaceRootForCommand, } from './workspace/selection.js'; import { - assertWorkspaceOpenerAvailable, - buildWorkspaceOpenCommandForState, launchWorkspaceOpenCommand, - readWorkspaceOpenState, } from './workspace/open.js'; +import { + buildWorkspaceOpenJsonPayload, + prepareWorkspaceOpen, + type PreparedWorkspaceOpen, +} from './workspace/open-view.js'; +import { + getPreferredWorkspaceSkillAgentId, + parseSetupOpenerOption, + promptPreferredOpener, +} from './workspace/opener-selection.js'; +import { workspacePromptTheme, workspaceSelectTheme } from './workspace/prompt-theme.js'; +import { registerWorkspaceCommandWith } from './workspace/registration.js'; import { WorkspaceCliError, WorkspaceLinkMutationPayload, @@ -68,31 +72,6 @@ function printJson(payload: unknown): void { console.log(JSON.stringify(payload, null, 2)); } -const workspacePromptTheme = { - prefix: '', - style: { - answer: (text: string) => chalk.cyan(text), - defaultAnswer: (text: string) => chalk.dim(text), - error: (text: string) => chalk.red(text), - help: (text: string) => chalk.dim(text), - highlight: (text: string) => chalk.cyan(text), - key: (text: string) => chalk.cyan(text), - message: (text: string) => chalk.bold(text), - }, -}; - -const workspaceSelectTheme = { - ...workspacePromptTheme, - icon: { - cursor: chalk.cyan('>'), - }, - style: { - ...workspacePromptTheme.style, - keysHelpTip: (keys: [key: string, action: string][]) => - chalk.dim(keys.map(([key, action]) => `${key}: ${action}`).join(' | ')), - }, -}; - function printWorkspaceSetupIntro(): void { console.log(chalk.bold('Workspace setup')); console.log(''); @@ -234,45 +213,6 @@ async function promptSetupLinks(): Promise<Record<string, string>> { } } -function formatOpenerChoiceName(choice: ReturnType<typeof listWorkspaceOpenerChoices>[number]): string { - return choice.unavailableNote ? `${choice.label} (${choice.unavailableNote})` : choice.label; -} - -async function promptPreferredOpener( - message: string, - openerChoices = listWorkspaceOpenerChoices() -): Promise<WorkspacePreferredOpener> { - const { select } = await import('@inquirer/prompts'); - const selectedValue = await select({ - message, - default: getDefaultWorkspaceOpenerChoiceValue(openerChoices), - choices: openerChoices.map((choice) => ({ - name: formatOpenerChoiceName(choice), - short: choice.label, - value: choice.value, - description: choice.unavailableNote ?? `Use ${choice.label}`, - })), - theme: workspaceSelectTheme, - }); - - return parseWorkspacePreferredOpenerValue(selectedValue); -} - -function parseSetupOpenerOption(opener: string | undefined): WorkspacePreferredOpener | undefined { - if (!opener) { - return undefined; - } - - try { - return parseWorkspacePreferredOpenerValue(opener); - } catch (error) { - throw new WorkspaceCliError(asErrorMessage(error), 'unsupported_workspace_opener', { - target: 'workspace.opener', - fix: 'Use --opener codex, --opener claude, --opener github-copilot, or --opener editor.', - }); - } -} - function parseSetupToolsOption(tools: string): string[] { try { return parseWorkspaceSkillToolsValue(tools); @@ -295,16 +235,6 @@ function parseUpdateToolsOption(tools: string): string[] { } } -function getPreferredWorkspaceSkillAgentId( - preferredOpener: WorkspacePreferredOpener | undefined -): string | null { - if (!preferredOpener || preferredOpener.kind !== 'agent') { - return null; - } - - return getWorkspaceSkillToolIds().includes(preferredOpener.id) ? preferredOpener.id : null; -} - async function promptWorkspaceSkillAgents( preferredOpener: WorkspacePreferredOpener | undefined ): Promise<string[]> { @@ -339,24 +269,6 @@ async function promptWorkspaceSkillAgents( }); } -function parseAgentOverride(agent: string): WorkspacePreferredOpener { - if (!isWorkspaceAgentOpenerId(agent)) { - throw new WorkspaceCliError( - `Unsupported workspace agent '${agent}'. Supported agents: codex, claude, github-copilot.`, - 'unsupported_workspace_agent', - { - target: 'workspace.opener', - fix: 'Use --agent codex, --agent claude, or --agent github-copilot.', - } - ); - } - - return { - kind: 'agent', - id: agent, - }; -} - function printStatusLines(statuses: WorkspaceStatus[]): void { for (const status of statuses) { const label = status.severity === 'warning' ? 'Warning' : 'Issue'; @@ -392,6 +304,15 @@ function collectWorkspaceIssues(workspace: WorkspaceListOutput): WorkspaceStatus function printDoctorHuman(result: { workspace: WorkspaceOutput; status: WorkspaceStatus[] }): void { console.log(`Workspace: ${result.workspace.name}`); console.log(`Location: ${result.workspace.root}`); + if (result.workspace.context) { + const selector = result.workspace.context.store_selector; + const suffix = selector.kind === 'path' ? ` via ${selector.path}` : ''; + console.log( + `Context: ${result.workspace.context.store}/${result.workspace.context.initiative}${suffix}` + ); + } else { + console.log('Context: (none)'); + } console.log(`Planning path: ${result.workspace.planning_path}`); console.log(''); printStatusLines(result.status); @@ -403,6 +324,15 @@ function printDoctorHuman(result: { workspace: WorkspaceOutput; status: Workspac const issues = collectWorkspaceIssues(result.workspace); + console.log(''); + console.log('Advisory edit boundaries:'); + if (result.workspace.context) { + console.log(' Initiative/context-store files are shared coordination context.'); + } else { + console.log(' No initiative coordination context is attached.'); + } + console.log(' Linked repos and folders are local implementation context when selected.'); + if (issues.length === 0) { console.log(''); console.log('No workspace issues found.'); @@ -558,13 +488,10 @@ async function writeWorkspaceSkillState( selectedAgentIds: string[], report: WorkspaceSkillInstallationReport ): Promise<void> { - const localState = (await readOptionalWorkspaceLocalState(workspaceRoot)) ?? { - version: 1 as const, - paths: {}, - }; + const viewState = await readWorkspaceViewState(workspaceRoot); - await writeWorkspaceLocalState(workspaceRoot, { - ...localState, + await writeWorkspaceViewState(workspaceRoot, { + ...viewState, workspace_skills: { selected_agents: selectedAgentIds, last_applied_profile: report.profile, @@ -575,112 +502,6 @@ async function writeWorkspaceSkillState( }); } -async function resolveWorkspaceOpenOpener( - localState: { preferred_opener?: WorkspacePreferredOpener }, - options: WorkspaceOpenOptions -): Promise<WorkspacePreferredOpener> { - if (options.agent && options.editor) { - throw new WorkspaceCliError( - 'workspace open accepts either --agent <tool> or --editor, not both.', - 'workspace_opener_conflict', - { - target: 'workspace.opener', - fix: 'Choose one opener override.', - } - ); - } - - if (options.agent) { - return parseAgentOverride(options.agent); - } - - if (options.editor) { - return parseWorkspacePreferredOpenerValue('editor'); - } - - if (localState.preferred_opener) { - return localState.preferred_opener; - } - - if (!resolveNoInteractive(options) && isInteractive(options)) { - const openerChoices = listWorkspaceOpenerChoices().filter((choice) => choice.available); - if (openerChoices.length === 0) { - throw new WorkspaceCliError( - 'No supported workspace opener is available on PATH.', - 'workspace_no_available_openers', - { - target: 'workspace.opener', - fix: "Install VS Code ('code'), Codex ('codex'), or Claude ('claude'), then retry.", - } - ); - } - - return promptPreferredOpener('Open with:', openerChoices); - } - - throw new WorkspaceCliError( - 'This workspace does not have a preferred opener yet.', - 'workspace_opener_unset', - { - target: 'workspace.opener', - fix: 'Pass --agent <tool> or --editor, or run workspace setup interactively to choose a default opener.', - } - ); -} - -function assertWorkspaceOpenSupportedOptions(options: WorkspaceOpenOptions): void { - if (options.prepareOnly) { - throw new WorkspaceCliError( - 'workspace open supports launching through a selected opener; preview output is reserved for a future context/query surface.', - 'workspace_open_prepare_only_unsupported', - { - target: 'workspace.open', - fix: 'Run openspec workspace open with --agent <tool> or --editor.', - } - ); - } - - if (options.json) { - throw new WorkspaceCliError( - 'workspace open supports launching through a selected opener; machine-readable context is reserved for a future context/query surface.', - 'workspace_open_json_unsupported', - { - target: 'workspace.open', - fix: 'Use openspec workspace doctor --json for current workspace status.', - } - ); - } - - if (options.change) { - throw new WorkspaceCliError( - 'workspace open currently supports root workspace open only; change-scoped open belongs to future workspace change planning.', - 'workspace_open_change_unsupported', - { - target: 'workspace.change', - fix: 'Open the root workspace, then start implementation from an explicit change workflow.', - } - ); - } -} - -function resolveOpenWorkspaceName( - positionalName: string | undefined, - options: WorkspaceOpenOptions -): string | undefined { - if (positionalName && options.workspace && positionalName !== options.workspace) { - throw new WorkspaceCliError( - `Conflicting workspace selectors: positional '${positionalName}' and --workspace '${options.workspace}'.`, - 'workspace_selection_conflict', - { - target: 'workspace.name', - fix: 'Use either the positional workspace name or --workspace with the same value.', - } - ); - } - - return positionalName ?? options.workspace; -} - function resolveUpdateWorkspaceName( positionalName: string | undefined, options: WorkspaceUpdateOptions @@ -699,23 +520,22 @@ function resolveUpdateWorkspaceName( return positionalName ?? options.workspace; } -function printWorkspaceOpenHuman( - selectedName: string, - selectedRoot: string, - opener: WorkspacePreferredOpener, - skipped: Awaited<ReturnType<typeof buildWorkspaceOpenCommandForState>>['skipped'] -): void { - console.log(`Opening workspace: ${selectedName}`); - console.log(`Location: ${selectedRoot}`); - console.log(`Opener: ${getWorkspaceOpenerLabel(opener)}`); +function printWorkspaceOpenHuman(prepared: PreparedWorkspaceOpen): void { + console.log(`Opening workspace: ${prepared.selected.name}`); + console.log(`Location: ${prepared.selected.root}`); + if (prepared.initiative) { + console.log(`Initiative: ${prepared.initiative.store}/${prepared.initiative.id}`); + console.log(`Initiative path: ${prepared.initiative.root}`); + } + console.log(`Opener: ${getWorkspaceOpenerLabel(prepared.opener)}`); - if (skipped.length === 0) { + if (prepared.skipped.length === 0) { return; } console.log(''); console.log('Skipped linked repos or folders:'); - for (const link of skipped) { + for (const link of prepared.skipped) { const location = link.path ?? '(no local path recorded)'; console.log(` ${link.name} -> ${location}`); } @@ -845,8 +665,7 @@ class WorkspaceCommand { async list(options: WorkspaceListOptions = {}): Promise<void> { try { - const registry = await readRegistry(); - const entries = listWorkspaceRegistryEntries(registry); + const entries = await listKnownWorkspaceEntries(); const workspaces = await Promise.all(entries.map((entry) => loadWorkspaceForList(entry))); const payload = { workspaces, status: [] as WorkspaceStatus[] }; @@ -975,25 +794,26 @@ class WorkspaceCommand { selected: SelectedWorkspace, options: WorkspaceUpdateOptions ): Promise<void> { - const { localState } = await readWorkspaceForMutation(selected); + const viewState = await readWorkspaceForMutation(selected); + await syncWorkspaceOpenSurface(selected.root, viewState); + const hasExplicitToolSelection = options.tools !== undefined; const selectedAgentIds = hasExplicitToolSelection ? parseUpdateToolsOption(options.tools ?? '') - : localState.workspace_skills?.selected_agents ?? []; + : viewState.workspace_skills?.selected_agents ?? []; const previousSkillState = hasExplicitToolSelection - ? localState.workspace_skills ?? { selected_agents: [] } - : localState.workspace_skills; + ? viewState.workspace_skills ?? { selected_agents: [] } + : viewState.workspace_skills; const skillReport = await updateWorkspaceAgentSkills( selected.root, selectedAgentIds, previousSkillState ); - const shouldStoreSelection = hasExplicitToolSelection || Boolean(localState.workspace_skills); + const shouldStoreSelection = hasExplicitToolSelection || Boolean(viewState.workspace_skills); if (shouldStoreSelection && !hasWorkspaceSkillFailures(skillReport)) { await writeWorkspaceSkillState(selected.root, selectedAgentIds, skillReport); - await recordSelectedWorkspaceAfterMutation(selected); } const doctorResult = await loadWorkspaceForDoctor(selected); @@ -1030,35 +850,23 @@ class WorkspaceCommand { options: WorkspaceOpenOptions = {} ): Promise<void> { try { - assertWorkspaceOpenSupportedOptions(options); - - const workspaceName = resolveOpenWorkspaceName(positionalName, options); - const selected = await selectWorkspaceForCommand( - { - ...options, - workspace: workspaceName, - }, - 'open', - { preferPositionalName: true } - ); - const state = await readWorkspaceOpenState(selected); - const opener = await resolveWorkspaceOpenOpener(state.localState, options); + const prepared = await prepareWorkspaceOpen(positionalName, options); - assertWorkspaceOpenerAvailable(opener, state.codeWorkspacePath); + if (!options.json) { + printStatusLines(prepared.selected.status); + if (prepared.selected.status.length > 0) { + console.log(''); + } + printWorkspaceOpenHuman(prepared); + } - const { command, skipped } = await buildWorkspaceOpenCommandForState( - opener, - selected.root, - state - ); + await launchWorkspaceOpenCommand(prepared.command, { + stdio: options.json ? 'ignore' : 'inherit', + }); - printStatusLines(selected.status); - if (selected.status.length > 0) { - console.log(''); + if (options.json) { + printJson(buildWorkspaceOpenJsonPayload(prepared)); } - printWorkspaceOpenHuman(selected.name, selected.root, opener, skipped); - - await launchWorkspaceOpenCommand(command); } catch (error) { this.handleFailure(options.json, { workspace: null, status: [] }, error); } @@ -1106,114 +914,6 @@ export async function runWorkspaceUpdateForRoot( await workspaceCommand.updateRoot(workspaceRoot, options); } -function collectOption(value: string, previous: string[]): string[] { - return [...previous, value]; -} - -function addWorkspaceSelectionOptions(command: Command): Command { - return command - .option('--workspace <name>', 'Workspace name from the local workspace registry') - .option('--json', 'Output as JSON') - .option('--no-interactive', 'Disable prompts'); -} - export function registerWorkspaceCommand(program: Command): void { - const workspaceCommand = new WorkspaceCommand(); - const workspace = program - .command('workspace') - .description('Set up and inspect coordination workspaces'); - - workspace - .command('setup') - .description('Set up a workspace and link existing repos or folders') - .option('--name <name>', 'Workspace name') - .option('--link <link>', 'Repo or folder link. Use <path> or <name>=<path>.', collectOption, []) - .option('--opener <id>', 'Preferred opener: codex, claude, github-copilot, or editor') - .option( - '--tools <tools>', - `Install OpenSpec skills for agents. Use "all", "none", or a comma-separated list of: ${getWorkspaceSkillToolIds().join(', ')}` - ) - .option('--json', 'Output as JSON') - .option('--no-interactive', 'Disable prompts') - .action(async (options: WorkspaceSetupOptions) => { - await workspaceCommand.setup(options); - }); - - workspace - .command('list') - .description('List known OpenSpec workspaces') - .option('--json', 'Output as JSON') - .action(async (options: WorkspaceListOptions) => { - await workspaceCommand.list(options); - }); - - workspace - .command('ls') - .description('List known OpenSpec workspaces') - .option('--json', 'Output as JSON') - .action(async (options: WorkspaceListOptions) => { - await workspaceCommand.list(options); - }); - - addWorkspaceSelectionOptions( - workspace - .command('link [nameOrPath] [path]') - .description('Link an existing repo or folder to a workspace') - ).action(async ( - nameOrPath: string | undefined, - linkPath: string | undefined, - options: WorkspaceLinkOptions - ) => { - await workspaceCommand.link(nameOrPath, linkPath, options); - }); - - addWorkspaceSelectionOptions( - workspace - .command('relink <name> <path>') - .description('Update the local path for an existing workspace link') - ).action(async ( - linkName: string | undefined, - linkPath: string | undefined, - options: WorkspaceLinkOptions - ) => { - await workspaceCommand.relink(linkName, linkPath, options); - }); - - addWorkspaceSelectionOptions( - workspace - .command('doctor') - .description('Check what a workspace can resolve on this machine') - ).action(async (options: WorkspaceLinkOptions) => { - await workspaceCommand.doctor(options); - }); - - workspace - .command('update [name]') - .description('Refresh workspace-local OpenSpec agent skills from the active global profile') - .option('--workspace <name>', 'Workspace name from the local workspace registry') - .option( - '--tools <tools>', - `Select agents for workspace skills. Use "all", "none", or a comma-separated list of: ${getWorkspaceSkillToolIds().join(', ')}. Global profile selects workflows; --tools selects agents.` - ) - .option('--json', 'Output as JSON') - .option('--no-interactive', 'Disable prompts') - .action(async (name: string | undefined, options: WorkspaceUpdateOptions) => { - await workspaceCommand.update(name, options); - }); - - workspace - .command('open [name]') - .description('Open a workspace in an agent or VS Code editor') - .option('--workspace <name>', 'Workspace name from the local workspace registry') - .option('--agent <tool>', 'Use an agent for this session: codex, claude, or github-copilot') - .option('--editor', 'Open the workspace in VS Code editor mode') - .option('--prepare-only', 'Unsupported: preview surfaces belong to a future context/query command') - .option('--json', 'Unsupported: machine-readable context belongs to a future context/query command') - .option('--change <id>', 'Unsupported: change-scoped open belongs to future workspace change planning') - .option('--no-interactive', 'Disable prompts') - .action(async (name: string | undefined, options: WorkspaceOpenOptions) => { - await workspaceCommand.open(name, options); - }); - - // Intentionally no public `workspace create` command in this slice. + registerWorkspaceCommandWith(program, new WorkspaceCommand()); } diff --git a/src/commands/workspace/context-status.ts b/src/commands/workspace/context-status.ts new file mode 100644 index 0000000000..73e13ea5bc --- /dev/null +++ b/src/commands/workspace/context-status.ts @@ -0,0 +1,93 @@ +import { + mountInitiativesCollection, + readInitiative, +} from '../../core/collections/initiatives/index.js'; +import { + formatContextStoreBinding, + formatContextStoreBindingSelector, + resolveContextStoreBinding, + type ContextStoreBindingWarning, +} from '../../core/context-store/index.js'; +import { + getWorkspaceContextInitiativeId, + type WorkspaceContextState, +} from '../../core/workspace/index.js'; +import { WorkspaceStatus, asErrorMessage, makeStatus } from './types.js'; + +function contextStoreBindingWarningToStatus( + warning: ContextStoreBindingWarning +): WorkspaceStatus { + return makeStatus('warning', warning.code, warning.message, { + target: warning.target ? `workspace.context.store.${warning.target}` : 'workspace.context.store', + ...(warning.fix ? { fix: warning.fix } : {}), + }); +} + +export async function collectWorkspaceContextStatuses( + context: WorkspaceContextState | null +): Promise<WorkspaceStatus[]> { + if (!context) { + return []; + } + + const initiativeId = getWorkspaceContextInitiativeId(context); + const contextStoreLabel = formatContextStoreBinding(context.store); + const selector = formatContextStoreBindingSelector(context.store); + let resolvedStore: Awaited<ReturnType<typeof resolveContextStoreBinding>>; + try { + resolvedStore = await resolveContextStoreBinding(context.store); + } catch (error) { + return [ + makeStatus( + 'error', + 'workspace_context_store_unavailable', + `Workspace context store '${contextStoreLabel}' could not be read: ${asErrorMessage(error)}`, + { + target: 'workspace.context.store', + fix: context.store.selector.kind === 'registry' + ? 'openspec context-store doctor' + : `Check the path in workspace.yaml or run openspec initiative show ${initiativeId} ${selector}`, + } + ), + ]; + } + + const statuses = resolvedStore.warnings.map(contextStoreBindingWarningToStatus); + + try { + const initiative = await readInitiative({ + collection: mountInitiativesCollection(resolvedStore.root), + id: initiativeId, + }); + + if (!initiative) { + return [ + ...statuses, + makeStatus( + 'error', + 'workspace_initiative_missing', + `Workspace initiative '${contextStoreLabel}/${initiativeId}' was not found.`, + { + target: 'workspace.context.initiative', + fix: `openspec initiative show ${initiativeId} ${selector}`, + } + ), + ]; + } + + return statuses; + } catch (error) { + return [ + ...statuses, + makeStatus( + 'error', + 'workspace_initiative_unavailable', + `Workspace initiative '${contextStoreLabel}/${initiativeId}' could not be read: ${asErrorMessage(error)}`, + { + target: 'workspace.context.initiative', + fix: `openspec initiative show ${initiativeId} ${selector}`, + } + ), + ]; + } +} diff --git a/src/commands/workspace/open-view.ts b/src/commands/workspace/open-view.ts new file mode 100644 index 0000000000..95e0cd9eff --- /dev/null +++ b/src/commands/workspace/open-view.ts @@ -0,0 +1,395 @@ +import { + InitiativeResolutionError, + InitiativeViewReference, + resolveInitiativeViewReference, + resolveSelectedInitiativeViewReference, +} from '../../core/collections/initiatives/index.js'; +import { + createPathContextStoreBinding, + createRegisteredContextStoreBinding, + formatContextStoreBinding, + resolveContextStoreBinding, + type ContextStoreBinding, + type ContextStoreBindingWarning, +} from '../../core/context-store/index.js'; +import { + WorkspaceContextState, + WorkspacePreferredOpener, + WorkspaceOpenResolvedContext, + createWorkspaceInitiativeContext, + getWorkspaceContextInitiativeId, + getWorkspaceOpenerLabel, +} from '../../core/workspace/index.js'; +import { + assertWorkspaceOpenerAvailable, + buildWorkspaceOpenCommandForState, + readWorkspaceOpenState, + type WorkspaceOpenCommandBuildResult, +} from './open.js'; +import { + selectOrCreateWorkspaceForInitiativeOpen, +} from './operations.js'; +import { + selectWorkspaceForCommand, +} from './selection.js'; +import { + SelectedWorkspace, + WorkspaceCliError, + WorkspaceOpenOptions, + WorkspaceStatus, + asErrorMessage, +} from './types.js'; +import { + resolveWorkspaceOpenOpener, + resolveWorkspaceOpenOpenerOverride, +} from './opener-selection.js'; + +export interface PreparedWorkspaceOpen extends WorkspaceOpenCommandBuildResult { + selected: SelectedWorkspace; + opener: WorkspacePreferredOpener; + initiative: InitiativeViewReference | null; + workspaceContext: WorkspaceContextState | null; + warnings: WorkspaceStatus[]; +} + +export interface WorkspaceOpenJsonPayload { + schema_version: 1; + workspace: { + name: string; + root: string; + }; + context: { + context_store: { + id: string; + root: string; + selector?: ContextStoreBinding['selector']; + }; + initiative: { + id: string; + title: string; + root: string; + metadata_path: string; + store_path: string; + }; + } | null; + generated_files: { + agents: string; + code_workspace: string; + }; + opened_roots: PreparedWorkspaceOpen['openedRoots']; + skipped_roots: Array<{ + kind: 'link'; + name: string; + path: string | null; + reason: PreparedWorkspaceOpen['skipped'][number]['reason']; + }>; + advisory_edit_boundaries: { + allowed_edit_roots: string[]; + coordination_roots: string[]; + enforcement: 'advisory'; + }; + opener: PreparedWorkspaceOpen['opener'] & { + label: string; + }; + launch: { + attempted: true; + status: 'succeeded'; + }; + warnings: WorkspaceStatus[]; + status: WorkspaceStatus[]; +} + +export function assertWorkspaceOpenSupportedOptions(options: WorkspaceOpenOptions): void { + if (!options.initiative && (options.store || options.storePath)) { + throw new WorkspaceCliError( + 'workspace open accepts --store or --store-path only with --initiative.', + 'workspace_open_store_without_initiative', + { + target: 'workspace.initiative', + fix: 'Use openspec workspace open --initiative <id> --store <store>.', + } + ); + } + + if (options.prepareOnly) { + throw new WorkspaceCliError( + 'workspace open supports launching through a selected opener; preview output is reserved for a future context/query surface.', + 'workspace_open_prepare_only_unsupported', + { + target: 'workspace.open', + fix: 'Run openspec workspace open with --agent <tool> or --editor.', + } + ); + } + + if (options.change) { + throw new WorkspaceCliError( + 'workspace open currently supports root workspace open only; change-scoped open belongs to future workspace change planning.', + 'workspace_open_change_unsupported', + { + target: 'workspace.change', + fix: 'Open the root workspace, then start implementation from an explicit change workflow.', + } + ); + } +} + +function resolveOpenWorkspaceName( + positionalName: string | undefined, + options: WorkspaceOpenOptions +): string | undefined { + if (positionalName && options.workspace && positionalName !== options.workspace) { + throw new WorkspaceCliError( + `Conflicting workspace selectors: positional '${positionalName}' and --workspace '${options.workspace}'.`, + 'workspace_selection_conflict', + { + target: 'workspace.name', + fix: 'Use either the positional workspace name or --workspace with the same value.', + } + ); + } + + return positionalName ?? options.workspace; +} + +function initiativeErrorAsWorkspaceError(error: unknown): WorkspaceCliError { + if (error instanceof InitiativeResolutionError) { + return new WorkspaceCliError(error.message, error.code, { + target: error.target, + fix: error.fix, + details: error.details, + }); + } + + return new WorkspaceCliError(asErrorMessage(error), 'initiative_error'); +} + +async function resolveWorkspaceOpenInitiative( + options: WorkspaceOpenOptions +): Promise<InitiativeViewReference | null> { + if (!options.initiative) { + return null; + } + + try { + return await resolveInitiativeViewReference(options.initiative, { + store: options.store, + storePath: options.storePath, + }); + } catch (error) { + throw initiativeErrorAsWorkspaceError(error); + } +} + +async function resolveStoredWorkspaceInitiative( + context: WorkspaceContextState +): Promise<{ initiative: InitiativeViewReference; warnings: WorkspaceStatus[] }> { + const initiativeId = getWorkspaceContextInitiativeId(context); + + try { + const resolvedStore = await resolveContextStoreBinding(context.store); + const selected = { + id: resolvedStore.id, + root: resolvedStore.root, + source: resolvedStore.source, + }; + const initiative = await resolveSelectedInitiativeViewReference(selected, initiativeId); + + return { + initiative, + warnings: resolvedStore.warnings.map(contextStoreBindingWarningToStatus), + }; + } catch (error) { + if (error instanceof InitiativeResolutionError) { + throw initiativeErrorAsWorkspaceError(error); + } + + throw new WorkspaceCliError( + `Workspace context store '${formatContextStoreBinding(context.store)}' could not be read: ${asErrorMessage(error)}`, + 'workspace_context_store_unavailable', + { + target: 'workspace.context.store', + fix: context.store.selector.kind === 'registry' + ? 'openspec context-store doctor' + : 'Check the path in workspace.yaml.', + } + ); + } +} + +function contextStoreBindingWarningToStatus( + warning: ContextStoreBindingWarning +): WorkspaceStatus { + return { + severity: 'warning', + code: warning.code, + message: warning.message, + target: warning.target ? `workspace.context.store.${warning.target}` : 'workspace.context.store', + ...(warning.fix ? { fix: warning.fix } : {}), + }; +} + +function contextStoreBindingFromInitiative( + initiative: InitiativeViewReference +): ContextStoreBinding { + return initiative.storeSource === 'path' + ? createPathContextStoreBinding({ + id: initiative.store, + path: initiative.storeRoot, + }) + : createRegisteredContextStoreBinding(initiative.store); +} + +function toWorkspaceOpenResolvedContext( + initiative: InitiativeViewReference +): WorkspaceOpenResolvedContext { + return { + contextStore: { + id: initiative.store, + root: initiative.storeRoot, + }, + initiative: { + id: initiative.id, + title: initiative.title, + root: initiative.root, + metadataPath: initiative.metadataPath, + storePath: initiative.storePath, + }, + }; +} + +function buildSkippedRootWarnings( + skipped: PreparedWorkspaceOpen['skipped'] +): WorkspaceStatus[] { + return skipped.map((link) => { + const location = link.path ?? '(no local path recorded)'; + return { + severity: 'warning', + code: 'workspace_open_link_skipped', + message: `Skipped linked repo or folder '${link.name}' because ${location} is not available.`, + target: `links.${link.name}.path`, + fix: `openspec workspace relink ${link.name} /path/to/${link.name}`, + }; + }); +} + +export async function prepareWorkspaceOpen( + positionalName: string | undefined, + options: WorkspaceOpenOptions +): Promise<PreparedWorkspaceOpen> { + assertWorkspaceOpenSupportedOptions(options); + + const workspaceName = resolveOpenWorkspaceName(positionalName, options); + const requestedInitiative = await resolveWorkspaceOpenInitiative(options); + const requestedContext = requestedInitiative + ? createWorkspaceInitiativeContext( + contextStoreBindingFromInitiative(requestedInitiative), + requestedInitiative.id + ) + : null; + const selected = requestedContext + ? ( + await selectOrCreateWorkspaceForInitiativeOpen({ + workspaceName, + context: requestedContext, + preferredOpener: resolveWorkspaceOpenOpenerOverride(options), + }) + ).selected + : await selectWorkspaceForCommand( + { + ...options, + workspace: workspaceName, + }, + 'open', + { preferPositionalName: true } + ); + const state = await readWorkspaceOpenState(selected); + const stored = !requestedInitiative && state.viewState.context + ? await resolveStoredWorkspaceInitiative(state.viewState.context) + : null; + const initiative = requestedInitiative ?? stored?.initiative ?? null; + const resolvedContext = initiative ? toWorkspaceOpenResolvedContext(initiative) : null; + const opener = await resolveWorkspaceOpenOpener(state.viewState, options); + + assertWorkspaceOpenerAvailable(opener, state.codeWorkspacePath); + + const buildResult = await buildWorkspaceOpenCommandForState( + opener, + selected.root, + state, + resolvedContext + ); + + return { + ...buildResult, + selected, + opener, + initiative, + workspaceContext: state.viewState.context, + warnings: [ + ...selected.status, + ...(stored?.warnings ?? []), + ...buildSkippedRootWarnings(buildResult.skipped), + ], + }; +} + +export function buildWorkspaceOpenJsonPayload( + prepared: PreparedWorkspaceOpen +): WorkspaceOpenJsonPayload { + const linkedEditRoots = prepared.openedRoots + .filter((root) => root.kind === 'link') + .map((root) => root.path); + + return { + schema_version: 1, + workspace: { + name: prepared.selected.name, + root: prepared.selected.root, + }, + context: prepared.initiative + ? { + context_store: { + id: prepared.initiative.store, + root: prepared.initiative.storeRoot, + ...(prepared.workspaceContext + ? { selector: prepared.workspaceContext.store.selector } + : {}), + }, + initiative: { + id: prepared.initiative.id, + title: prepared.initiative.title, + root: prepared.initiative.root, + metadata_path: prepared.initiative.metadataPath, + store_path: prepared.initiative.storePath, + }, + } + : null, + generated_files: { + agents: prepared.generated.agentsPath, + code_workspace: prepared.generated.codeWorkspacePath, + }, + opened_roots: prepared.openedRoots, + skipped_roots: prepared.skipped.map((link) => ({ + kind: 'link', + name: link.name, + path: link.path, + reason: link.reason, + })), + advisory_edit_boundaries: { + allowed_edit_roots: linkedEditRoots, + coordination_roots: prepared.initiative ? [prepared.initiative.root] : [], + enforcement: 'advisory', + }, + opener: { + ...prepared.opener, + label: getWorkspaceOpenerLabel(prepared.opener), + }, + launch: { + attempted: true, + status: 'succeeded', + }, + warnings: prepared.warnings, + status: [], + }; +} diff --git a/src/commands/workspace/open.ts b/src/commands/workspace/open.ts index 1745cfc787..9d122d8150 100644 --- a/src/commands/workspace/open.ts +++ b/src/commands/workspace/open.ts @@ -2,17 +2,17 @@ import { spawn as nodeSpawn } from 'node:child_process'; import { createRequire } from 'node:module'; import { - WorkspaceLocalState, WorkspacePreferredOpener, - WorkspaceSharedState, + WorkspaceViewState, + WorkspaceOpenResolvedContext, + WorkspaceOpenSurfaceGeneration, + WorkspaceSkippedOpenLink, getWorkspaceCodeWorkspacePath, getWorkspaceOpenerExecutable, getWorkspaceOpenerLabel, isWorkspaceExecutableAvailable, - readWorkspaceLocalState, - readWorkspaceSharedState, - resolveWorkspaceOpenLinks, - writeWorkspaceCodeWorkspaceFile, + readWorkspaceViewState, + syncWorkspaceOpenSurface, } from '../../core/workspace/index.js'; import { SelectedWorkspace, WorkspaceCliError, asErrorMessage } from './types.js'; @@ -21,8 +21,7 @@ const require = createRequire(import.meta.url); const spawn = require('cross-spawn') as typeof nodeSpawn; export interface WorkspaceOpenState { - sharedState: WorkspaceSharedState; - localState: WorkspaceLocalState; + viewState: WorkspaceViewState; codeWorkspacePath: string; } @@ -33,23 +32,35 @@ export interface WorkspaceOpenLaunchCommand { openerLabel: string; } +export type WorkspaceOpenedRoot = { + kind: 'workspace' | 'initiative' | 'link'; + name?: string; + path: string; +}; + +export interface WorkspaceOpenCommandBuildResult { + command: WorkspaceOpenLaunchCommand; + skipped: WorkspaceSkippedOpenLink[]; + generated: WorkspaceOpenSurfaceGeneration; + openedRoots: WorkspaceOpenedRoot[]; +} + export type WorkspaceOpenSpawn = typeof nodeSpawn; export interface WorkspaceOpenLaunchOptions { spawn?: WorkspaceOpenSpawn; isExecutableAvailable?: (executable: string) => boolean; + stdio?: 'inherit' | 'ignore'; } export async function readWorkspaceOpenState( selected: SelectedWorkspace ): Promise<WorkspaceOpenState> { - const sharedState = await readWorkspaceSharedState(selected.root); - const localState = await readWorkspaceLocalState(selected.root); + const viewState = await readWorkspaceViewState(selected.root); return { - sharedState, - localState, - codeWorkspacePath: getWorkspaceCodeWorkspacePath(selected.root, sharedState.name), + viewState, + codeWorkspacePath: getWorkspaceCodeWorkspacePath(selected.root, viewState.name), }; } @@ -57,7 +68,7 @@ export function buildWorkspaceOpenLaunchCommand( opener: WorkspacePreferredOpener, workspaceRoot: string, codeWorkspacePath: string, - linkedPaths: string[] + attachedPaths: string[] ): WorkspaceOpenLaunchCommand { const executable = getWorkspaceOpenerExecutable(opener); const openerLabel = getWorkspaceOpenerLabel(opener); @@ -74,7 +85,7 @@ export function buildWorkspaceOpenLaunchCommand( return { executable, args: [ - ...linkedPaths.flatMap((linkedPath) => ['--add-dir', linkedPath]), + ...attachedPaths.flatMap((linkedPath) => ['--add-dir', linkedPath]), WORKSPACE_OPEN_MINIMAL_PROMPT, ], cwd: workspaceRoot, @@ -111,22 +122,44 @@ export function assertWorkspaceOpenerAvailable( export async function buildWorkspaceOpenCommandForState( opener: WorkspacePreferredOpener, workspaceRoot: string, - state: WorkspaceOpenState -): Promise<{ - command: WorkspaceOpenLaunchCommand; - skipped: Awaited<ReturnType<typeof resolveWorkspaceOpenLinks>>['skipped']; -}> { - const openLinks = await resolveWorkspaceOpenLinks(state.sharedState, state.localState); - await writeWorkspaceCodeWorkspaceFile(state.codeWorkspacePath, openLinks.links); + state: WorkspaceOpenState, + resolvedContext?: WorkspaceOpenResolvedContext | null +): Promise<WorkspaceOpenCommandBuildResult> { + const openSurface = await syncWorkspaceOpenSurface( + workspaceRoot, + state.viewState, + resolvedContext + ); + const openedRoots = [ + { kind: 'workspace' as const, path: workspaceRoot }, + ...(resolvedContext + ? [ + { + kind: 'initiative' as const, + name: resolvedContext.initiative.id, + path: resolvedContext.initiative.root, + }, + ] + : []), + ...openSurface.links.map((link) => ({ + kind: 'link' as const, + name: link.name, + path: link.path, + })), + ]; return { command: buildWorkspaceOpenLaunchCommand( opener, workspaceRoot, state.codeWorkspacePath, - openLinks.links.map((link) => link.path) + openedRoots + .filter((root) => root.kind !== 'workspace') + .map((root) => root.path) ), - skipped: openLinks.skipped, + skipped: openSurface.skipped, + generated: openSurface.generated, + openedRoots, }; } @@ -139,7 +172,7 @@ export async function launchWorkspaceOpenCommand( await new Promise<void>((resolve, reject) => { const child = spawnCommand(command.executable, command.args, { cwd: command.cwd, - stdio: 'inherit', + stdio: options.stdio ?? 'inherit', shell: false, }); diff --git a/src/commands/workspace/opener-selection.ts b/src/commands/workspace/opener-selection.ts new file mode 100644 index 0000000000..3893576fa3 --- /dev/null +++ b/src/commands/workspace/opener-selection.ts @@ -0,0 +1,144 @@ +import { + WorkspacePreferredOpener, + getDefaultWorkspaceOpenerChoiceValue, + getWorkspaceSkillToolIds, + isWorkspaceAgentOpenerId, + listWorkspaceOpenerChoices, + parseWorkspacePreferredOpenerValue, +} from '../../core/workspace/index.js'; +import { isInteractive, resolveNoInteractive } from '../../utils/interactive.js'; +import { WorkspaceCliError, WorkspaceOpenOptions, asErrorMessage } from './types.js'; +import { workspaceSelectTheme } from './prompt-theme.js'; + +function formatOpenerChoiceName(choice: ReturnType<typeof listWorkspaceOpenerChoices>[number]): string { + return choice.unavailableNote ? `${choice.label} (${choice.unavailableNote})` : choice.label; +} + +export async function promptPreferredOpener( + message: string, + openerChoices = listWorkspaceOpenerChoices() +): Promise<WorkspacePreferredOpener> { + const { select } = await import('@inquirer/prompts'); + const selectedValue = await select({ + message, + default: getDefaultWorkspaceOpenerChoiceValue(openerChoices), + choices: openerChoices.map((choice) => ({ + name: formatOpenerChoiceName(choice), + short: choice.label, + value: choice.value, + description: choice.unavailableNote ?? `Use ${choice.label}`, + })), + theme: workspaceSelectTheme, + }); + + return parseWorkspacePreferredOpenerValue(selectedValue); +} + +export function parseSetupOpenerOption( + opener: string | undefined +): WorkspacePreferredOpener | undefined { + if (!opener) { + return undefined; + } + + try { + return parseWorkspacePreferredOpenerValue(opener); + } catch (error) { + throw new WorkspaceCliError(asErrorMessage(error), 'unsupported_workspace_opener', { + target: 'workspace.opener', + fix: 'Use --opener codex, --opener claude, --opener github-copilot, or --opener editor.', + }); + } +} + +export function parseWorkspaceAgentOverride(agent: string): WorkspacePreferredOpener { + if (!isWorkspaceAgentOpenerId(agent)) { + throw new WorkspaceCliError( + `Unsupported workspace agent '${agent}'. Supported agents: codex, claude, github-copilot.`, + 'unsupported_workspace_agent', + { + target: 'workspace.opener', + fix: 'Use --agent codex, --agent claude, or --agent github-copilot.', + } + ); + } + + return { + kind: 'agent', + id: agent, + }; +} + +export function getPreferredWorkspaceSkillAgentId( + preferredOpener: WorkspacePreferredOpener | undefined +): string | null { + if (!preferredOpener || preferredOpener.kind !== 'agent') { + return null; + } + + return getWorkspaceSkillToolIds().includes(preferredOpener.id) ? preferredOpener.id : null; +} + +export function resolveWorkspaceOpenOpenerOverride( + options: WorkspaceOpenOptions +): WorkspacePreferredOpener | undefined { + if (options.agent && options.editor) { + throw new WorkspaceCliError( + 'workspace open accepts either --agent <tool> or --editor, not both.', + 'workspace_opener_conflict', + { + target: 'workspace.opener', + fix: 'Choose one opener override.', + } + ); + } + + if (options.agent) { + return parseWorkspaceAgentOverride(options.agent); + } + + if (options.editor) { + return parseWorkspacePreferredOpenerValue('editor'); + } + + return undefined; +} + +export async function resolveWorkspaceOpenOpener( + localState: { preferred_opener?: WorkspacePreferredOpener }, + options: WorkspaceOpenOptions +): Promise<WorkspacePreferredOpener> { + const override = resolveWorkspaceOpenOpenerOverride(options); + if (override) { + return override; + } + + if (localState.preferred_opener) { + return localState.preferred_opener; + } + + if (!resolveNoInteractive(options) && isInteractive(options)) { + const openerChoices = listWorkspaceOpenerChoices().filter((choice) => choice.available); + if (openerChoices.length === 0) { + throw new WorkspaceCliError( + 'No supported workspace opener is available on PATH.', + 'workspace_no_available_openers', + { + target: 'workspace.opener', + fix: "Install VS Code ('code'), Codex ('codex'), or Claude ('claude'), then retry.", + } + ); + } + + return promptPreferredOpener('Open with:', openerChoices); + } + + throw new WorkspaceCliError( + 'This workspace does not have a preferred opener yet.', + 'workspace_opener_unset', + { + target: 'workspace.opener', + fix: 'Pass --agent <tool> or --editor, or run workspace setup interactively to choose a default opener.', + } + ); +} diff --git a/src/commands/workspace/operations.ts b/src/commands/workspace/operations.ts index 96c493105b..a384345e66 100644 --- a/src/commands/workspace/operations.ts +++ b/src/commands/workspace/operations.ts @@ -2,30 +2,34 @@ import * as nodeFs from 'node:fs'; import * as path from 'node:path'; import { - WorkspaceLocalState, WorkspacePreferredOpener, WorkspaceRegistryEntry, - WorkspaceRegistryState, - WorkspaceSharedState, + WorkspaceContextState, + WorkspaceViewState, + getWorkspaceContextInitiativeId, + getWorkspaceContextStoreId, getManagedWorkspaceRoot, hasWorkspaceSkillProfileDrift, getWorkspaceChangesDir, + getWorkspaceViewStatePath, isWorkspaceRoot, + listKnownWorkspaceEntries, parseWorkspaceSetupLinkInput, - readOptionalWorkspaceLocalState, - readWorkspaceRegistryState, - readWorkspaceSharedState, + readWorkspaceViewState, syncWorkspaceOpenSurface, validateWorkspaceLinkName, validateWorkspaceName, - writeWorkspaceLocalState, - writeWorkspaceRegistryState, - writeWorkspaceSharedState, + writeWorkspaceViewState, } from '../../core/workspace/index.js'; +import { + formatContextStoreBinding, + sameContextStoreBinding, +} from '../../core/context-store/index.js'; import { FileSystemUtils } from '../../utils/file-system.js'; import { SelectedWorkspace, WorkspaceCliError, + WorkspaceContextOutput, WorkspaceLinkMutationPayload, WorkspaceLinkOutput, WorkspaceListOutput, @@ -34,34 +38,10 @@ import { asErrorMessage, makeStatus, } from './types.js'; +import { collectWorkspaceContextStatuses } from './context-status.js'; const fs = nodeFs.promises; -function emptyRegistry(): WorkspaceRegistryState { - return { version: 1, workspaces: {} }; -} - -function emptyLocalState(): WorkspaceLocalState { - return { version: 1, paths: {} }; -} - -export async function readRegistry(): Promise<WorkspaceRegistryState> { - return (await readWorkspaceRegistryState()) ?? emptyRegistry(); -} - -async function recordWorkspaceInRegistry(name: string, workspaceRoot: string): Promise<void> { - const registry = await readRegistry(); - const recordedWorkspaceRoot = normalizeExistingPathForStorage(workspaceRoot); - - await writeWorkspaceRegistryState({ - version: 1, - workspaces: { - ...registry.workspaces, - [name]: recordedWorkspaceRoot, - }, - }); -} - export async function directoryExists(dirPath: string): Promise<boolean> { try { return (await fs.stat(dirPath)).isDirectory(); @@ -71,9 +51,7 @@ export async function directoryExists(dirPath: string): Promise<boolean> { } function normalizeExistingPathForStorage(existingPath: string): string { - return process.platform === 'win32' - ? FileSystemUtils.canonicalizeExistingPath(existingPath) - : existingPath; + return FileSystemUtils.canonicalizeExistingPath(existingPath); } export async function resolveExistingDirectory( @@ -110,18 +88,31 @@ export function inferLinkName(absolutePath: string): string { } function normalizeLinksForOutput( - sharedState: WorkspaceSharedState, - localState: WorkspaceLocalState | null + viewState: WorkspaceViewState ): WorkspaceLinkOutput[] { - return Object.keys(sharedState.links) + return Object.keys(viewState.links) .sort((a, b) => a.localeCompare(b)) .map((name) => ({ name, - path: localState?.paths[name] ?? null, + path: viewState.links[name] ?? null, status: [], })); } +function workspaceContextToOutput( + context: WorkspaceContextState | null +): WorkspaceContextOutput | null { + if (!context) { + return null; + } + + return { + store: getWorkspaceContextStoreId(context), + initiative: getWorkspaceContextInitiativeId(context), + store_selector: context.store.selector, + }; +} + function formatDuplicateLinkMessage( linkName: string, existingPath: string | null, @@ -155,6 +146,13 @@ function duplicateLinkError( ); } +function hasWorkspaceLink( + links: Record<string, string | null>, + linkName: string +): boolean { + return Object.prototype.hasOwnProperty.call(links, linkName); +} + function duplicateSetupLinkError( linkName: string, existingPath: string, @@ -207,7 +205,7 @@ function localStateInvalidStatus(error: unknown): WorkspaceStatus { `Machine-local paths could not be read: ${asErrorMessage(error)}`, { target: 'workspace.local_state', - fix: 'Repair or remove .openspec-workspace/local.yaml, then run openspec workspace relink <name> <path> for affected links.', + fix: 'Repair workspace.yaml, then run openspec workspace relink <name> <path> for affected links.', } ); } @@ -227,37 +225,27 @@ function workspaceSkillDriftStatus(workspaceName: string): WorkspaceStatus { function appendWorkspaceSkillDriftStatus( statuses: WorkspaceStatus[], workspaceName: string, - localState: WorkspaceLocalState | null + viewState: WorkspaceViewState | null ): void { - if (hasWorkspaceSkillProfileDrift(localState)) { + if (hasWorkspaceSkillProfileDrift(viewState)) { statuses.push(workspaceSkillDriftStatus(workspaceName)); } } -async function readLocalStateForMutation(workspaceRoot: string): Promise<WorkspaceLocalState> { - try { - return (await readOptionalWorkspaceLocalState(workspaceRoot)) ?? emptyLocalState(); - } catch (error) { - const status = localStateInvalidStatus(error); - throw new WorkspaceCliError(status.message, status.code, { - target: status.target, - fix: status.fix, - }); - } -} - export async function createManagedWorkspace( name: string, links: Record<string, string>, - preferredOpener?: WorkspacePreferredOpener + preferredOpener?: WorkspacePreferredOpener, + context: WorkspaceContextState | null = null, + tools?: string[] ): Promise<WorkspaceOutput> { const workspaceName = validateWorkspaceNameForSetup(name); - const workspaceRoot = getManagedWorkspaceRoot(workspaceName); - const registry = await readRegistry(); + const targetWorkspaceRoot = getManagedWorkspaceRoot(workspaceName); + let workspaceRoot = targetWorkspaceRoot; - if (registry.workspaces[workspaceName]) { + if (await directoryExists(targetWorkspaceRoot)) { throw new WorkspaceCliError( - `Workspace '${workspaceName}' is already recorded in the local workspace registry at ${registry.workspaces[workspaceName]}.`, + `Workspace '${workspaceName}' already exists at ${targetWorkspaceRoot}.`, 'workspace_already_exists', { target: 'workspace.name', @@ -265,41 +253,28 @@ export async function createManagedWorkspace( ); } - if (await directoryExists(workspaceRoot)) { - throw new WorkspaceCliError( - `Workspace '${workspaceName}' already exists at ${workspaceRoot}.`, - 'workspace_already_exists', - { - target: 'workspace.root', - } - ); - } - let createdWorkspaceRoot = false; try { - await FileSystemUtils.createDirectory(path.dirname(workspaceRoot)); - await fs.mkdir(workspaceRoot); + await FileSystemUtils.createDirectory(path.dirname(targetWorkspaceRoot)); + await fs.mkdir(targetWorkspaceRoot); createdWorkspaceRoot = true; + workspaceRoot = FileSystemUtils.canonicalizeExistingPath(targetWorkspaceRoot); await FileSystemUtils.createDirectory(getWorkspaceChangesDir(workspaceRoot)); - const sharedState: WorkspaceSharedState = { + const viewState: WorkspaceViewState = { version: 1, name: workspaceName, - links: Object.fromEntries(Object.keys(links).map((linkName) => [linkName, {}])), - }; - const localState: WorkspaceLocalState = { - version: 1, - paths: links, + context, + links, ...(preferredOpener ? { preferred_opener: preferredOpener } : {}), + ...(tools ? { tools } : {}), }; - await writeWorkspaceSharedState(workspaceRoot, sharedState); - await writeWorkspaceLocalState(workspaceRoot, localState); - await syncWorkspaceOpenSurface(workspaceRoot, sharedState, localState); - await recordWorkspaceInRegistry(workspaceName, workspaceRoot); + await writeWorkspaceViewState(workspaceRoot, viewState); + await syncWorkspaceOpenSurface(workspaceRoot, viewState); } catch (error) { if (createdWorkspaceRoot) { try { - await fs.rm(workspaceRoot, { recursive: true, force: true }); + await fs.rm(targetWorkspaceRoot, { recursive: true, force: true }); } catch { // Preserve the original creation failure; callers can retry or inspect the path. } @@ -318,6 +293,8 @@ export async function createManagedWorkspace( name: workspaceName, root: workspaceRoot, planning_path: getWorkspaceChangesDir(workspaceRoot), + state_path: getWorkspaceViewStatePath(workspaceRoot), + context: workspaceContextToOutput(context), links: Object.entries(links) .sort(([a], [b]) => a.localeCompare(b)) .map(([linkName, linkPath]) => ({ @@ -358,25 +335,26 @@ export async function loadWorkspaceForList( return { name: entry.name, root: entry.workspaceRoot, + context: null, links: [], status: [ makeStatus('error', 'workspace_root_missing', 'Workspace location does not exist.', { target: 'workspace.root', - fix: 'Remove or repair the local registry record.', + fix: 'Remove or repair the local workspace view.', }), ], }; } - let sharedState: WorkspaceSharedState; - let localState: WorkspaceLocalState | null = null; + let viewState: WorkspaceViewState; try { - sharedState = await readWorkspaceSharedState(entry.workspaceRoot); + viewState = await readWorkspaceViewState(entry.workspaceRoot); } catch (error) { return { name: entry.name, root: entry.workspaceRoot, + context: null, links: [], status: [ makeStatus( @@ -392,18 +370,14 @@ export async function loadWorkspaceForList( }; } - try { - localState = await readOptionalWorkspaceLocalState(entry.workspaceRoot); - } catch (error) { - workspaceStatus.push(localStateInvalidStatus(error)); - } - - appendWorkspaceSkillDriftStatus(workspaceStatus, sharedState.name, localState); + appendWorkspaceSkillDriftStatus(workspaceStatus, viewState.name, viewState); + workspaceStatus.push(...(await collectWorkspaceContextStatuses(viewState.context))); return { - name: sharedState.name, + name: viewState.name, root: entry.workspaceRoot, - links: normalizeLinksForOutput(sharedState, localState), + context: workspaceContextToOutput(viewState.context), + links: normalizeLinksForOutput(viewState), status: workspaceStatus, }; } @@ -421,6 +395,8 @@ export async function loadWorkspaceForDoctor( name: selected.name, root: selected.root, planning_path: planningPath, + state_path: getWorkspaceViewStatePath(selected.root), + context: null, links: [], status: [ makeStatus( @@ -429,7 +405,7 @@ export async function loadWorkspaceForDoctor( 'Selected workspace location does not exist or is not a valid workspace.', { target: 'workspace.root', - fix: 'Repair the local workspace registry record or choose another workspace.', + fix: 'Repair the local workspace view or choose another workspace.', } ), ], @@ -438,18 +414,18 @@ export async function loadWorkspaceForDoctor( }; } - let sharedState: WorkspaceSharedState; - let localState: WorkspaceLocalState; - let localStateInvalid = false; + let viewState: WorkspaceViewState; try { - sharedState = await readWorkspaceSharedState(selected.root); + viewState = await readWorkspaceViewState(selected.root); } catch (error) { return { workspace: { name: selected.name, root: selected.root, planning_path: planningPath, + state_path: getWorkspaceViewStatePath(selected.root), + context: null, links: [], status: [ makeStatus( @@ -467,74 +443,18 @@ export async function loadWorkspaceForDoctor( }; } - try { - const optionalLocalState = await readOptionalWorkspaceLocalState(selected.root); - localState = optionalLocalState ?? emptyLocalState(); - - if (!optionalLocalState) { - workspaceStatus.push( - makeStatus( - 'warning', - 'workspace_local_state_missing', - 'Machine-local paths are not recorded yet.', - { - target: 'workspace.local_state', - fix: 'Run openspec workspace relink <name> <path> for each linked repo or folder on this machine.', - } - ) - ); - } - } catch (error) { - localState = emptyLocalState(); - localStateInvalid = true; - workspaceStatus.push(localStateInvalidStatus(error)); - } + appendWorkspaceSkillDriftStatus(workspaceStatus, viewState.name, viewState); + workspaceStatus.push(...(await collectWorkspaceContextStatuses(viewState.context))); - if (!localStateInvalid) { - appendWorkspaceSkillDriftStatus(workspaceStatus, sharedState.name, localState); - } - - if (!(await directoryExists(planningPath))) { - workspaceStatus.push( - makeStatus( - 'error', - 'workspace_planning_path_missing', - 'Workspace planning path does not exist.', - { - target: 'workspace.planning_path', - fix: `Create ${planningPath} or recreate the workspace with openspec workspace setup.`, - } - ) - ); - } - - const sharedNames = new Set(Object.keys(sharedState.links)); - const localNames = new Set(Object.keys(localState.paths)); - const linkNames = [...new Set([...sharedNames, ...localNames])].sort((a, b) => - a.localeCompare(b) - ); + const linkNames = Object.keys(viewState.links).sort((a, b) => a.localeCompare(b)); const links: WorkspaceLinkOutput[] = []; for (const linkName of linkNames) { const linkStatus: WorkspaceStatus[] = []; - const localPath = localState.paths[linkName] ?? null; + const localPath = viewState.links[linkName] ?? null; let repoSpecsPath: string | null = null; - if (!sharedNames.has(linkName)) { - linkStatus.push( - makeStatus( - 'warning', - 'local_path_without_shared_link', - 'Local path is recorded without a shared workspace link.', - { - target: `links.${linkName}`, - fix: `Add a shared link with openspec workspace link ${linkName} ${localPath ?? '/path/to/folder'} or remove the local-only path from .openspec-workspace/local.yaml.`, - } - ) - ); - } - - if (sharedNames.has(linkName) && !localPath && !localStateInvalid) { + if (!localPath) { linkStatus.push( makeStatus( 'error', @@ -572,9 +492,11 @@ export async function loadWorkspaceForDoctor( return { workspace: { - name: sharedState.name, + name: viewState.name, root: selected.root, planning_path: planningPath, + state_path: getWorkspaceViewStatePath(selected.root), + context: workspaceContextToOutput(viewState.context), links, status: workspaceStatus, }, @@ -582,9 +504,7 @@ export async function loadWorkspaceForDoctor( }; } -export async function readWorkspaceForMutation( - selected: SelectedWorkspace -): Promise<{ sharedState: WorkspaceSharedState; localState: WorkspaceLocalState }> { +async function readWorkspaceViewForMutation(selected: SelectedWorkspace): Promise<WorkspaceViewState> { if (!(await directoryExists(selected.root)) || !(await isWorkspaceRoot(selected.root))) { throw new WorkspaceCliError( `Workspace location does not exist for '${selected.name}': ${selected.root}`, @@ -596,31 +516,40 @@ export async function readWorkspaceForMutation( ); } - return { - sharedState: await readWorkspaceSharedState(selected.root), - localState: await readLocalStateForMutation(selected.root), - }; + try { + return await readWorkspaceViewState(selected.root); + } catch (error) { + throw new WorkspaceCliError( + `Workspace state could not be read: ${asErrorMessage(error)}`, + 'workspace_state_invalid', + { + target: 'workspace.state', + fix: 'Repair workspace.yaml before using this workspace.', + } + ); + } } -export async function recordSelectedWorkspaceAfterMutation(selected: SelectedWorkspace): Promise<void> { - if (selected.unregisteredCurrentWorkspace) { - await recordWorkspaceInRegistry(selected.name, selected.root); - } +export async function readWorkspaceForMutation( + selected: SelectedWorkspace +): Promise<WorkspaceViewState> { + return readWorkspaceViewForMutation(selected); } function buildLinkMutationPayload( selected: SelectedWorkspace, - sharedState: WorkspaceSharedState, - localState: WorkspaceLocalState, + viewState: WorkspaceViewState, linkName: string, linkPath: string ): WorkspaceLinkMutationPayload { return { workspace: { - name: sharedState.name, + name: viewState.name, root: selected.root, planning_path: getWorkspaceChangesDir(selected.root), - links: normalizeLinksForOutput(sharedState, localState), + state_path: getWorkspaceViewStatePath(selected.root), + context: workspaceContextToOutput(viewState.context), + links: normalizeLinksForOutput(viewState), status: [], }, link: { @@ -641,36 +570,25 @@ export async function addWorkspaceLink( const pathInput = linkPath ?? nameOrPath; const resolvedPath = await resolveExistingDirectory(pathInput); const linkName = validateLinkNameForCommand(explicitName ?? inferLinkName(resolvedPath)); - const { sharedState, localState } = await readWorkspaceForMutation(selected); + const viewState = await readWorkspaceViewForMutation(selected); - if (sharedState.links[linkName]) { - throw duplicateLinkError(linkName, localState.paths[linkName] ?? null, resolvedPath); + if (hasWorkspaceLink(viewState.links, linkName)) { + throw duplicateLinkError(linkName, viewState.links[linkName] ?? null, resolvedPath); } - const updatedSharedState: WorkspaceSharedState = { - ...sharedState, + const updatedViewState: WorkspaceViewState = { + ...viewState, links: { - ...sharedState.links, - [linkName]: {}, - }, - }; - const updatedLocalState: WorkspaceLocalState = { - ...localState, - paths: { - ...localState.paths, + ...viewState.links, [linkName]: resolvedPath, }, }; - - await writeWorkspaceSharedState(selected.root, updatedSharedState); - await writeWorkspaceLocalState(selected.root, updatedLocalState); - await syncWorkspaceOpenSurface(selected.root, updatedSharedState, updatedLocalState); - await recordSelectedWorkspaceAfterMutation(selected); + await writeWorkspaceViewState(selected.root, updatedViewState); + await syncWorkspaceOpenSurface(selected.root, updatedViewState); return buildLinkMutationPayload( selected, - updatedSharedState, - updatedLocalState, + updatedViewState, linkName, resolvedPath ); @@ -683,26 +601,216 @@ export async function updateWorkspaceLink( ): Promise<WorkspaceLinkMutationPayload> { const linkName = validateLinkNameForCommand(linkNameInput); const resolvedPath = await resolveExistingDirectory(linkPath); - const { sharedState, localState } = await readWorkspaceForMutation(selected); + const viewState = await readWorkspaceViewForMutation(selected); - if (!sharedState.links[linkName]) { + if (!hasWorkspaceLink(viewState.links, linkName)) { throw new WorkspaceCliError(`Unknown workspace link '${linkName}'.`, 'unknown_link_name', { target: `links.${linkName}`, fix: 'Run openspec workspace doctor to see linked repos or folders.', }); } - const updatedLocalState: WorkspaceLocalState = { - ...localState, - paths: { - ...localState.paths, + const updatedViewState: WorkspaceViewState = { + ...viewState, + links: { + ...viewState.links, [linkName]: resolvedPath, }, }; + await writeWorkspaceViewState(selected.root, updatedViewState); + await syncWorkspaceOpenSurface(selected.root, updatedViewState); - await writeWorkspaceLocalState(selected.root, updatedLocalState); - await syncWorkspaceOpenSurface(selected.root, sharedState, updatedLocalState); - await recordSelectedWorkspaceAfterMutation(selected); + return buildLinkMutationPayload(selected, updatedViewState, linkName, resolvedPath); +} - return buildLinkMutationPayload(selected, sharedState, updatedLocalState, linkName, resolvedPath); +function sameWorkspaceContext( + left: WorkspaceContextState | null, + right: WorkspaceContextState +): boolean { + return ( + left !== null && + sameContextStoreBinding(left.store, right.store) && + getWorkspaceContextInitiativeId(left) === getWorkspaceContextInitiativeId(right) + ); +} + +function formatWorkspaceContext(context: WorkspaceContextState | null): string { + return context + ? `${formatContextStoreBinding(context.store)}/${getWorkspaceContextInitiativeId(context)}` + : 'no initiative context'; +} + +export function deriveWorkspaceNameForInitiative(initiativeId: string): string { + return validateWorkspaceNameForSetup(initiativeId); +} + +async function readExistingManagedWorkspaceView( + workspaceName: string +): Promise<{ root: string; state: WorkspaceViewState } | null> { + const workspaceRoot = getManagedWorkspaceRoot(workspaceName); + + if (!(await directoryExists(workspaceRoot))) { + return null; + } + + if (!(await isWorkspaceRoot(workspaceRoot))) { + throw new WorkspaceCliError( + `Workspace name '${workspaceName}' collides with a non-workspace directory at ${workspaceRoot}.`, + 'workspace_name_collision', + { + target: 'workspace.name', + fix: 'Choose an explicit unused workspace name.', + } + ); + } + + return { + root: workspaceRoot, + state: await readWorkspaceViewState(workspaceRoot), + }; +} + +function selectedWorkspaceFromManagedView( + root: string, + state: WorkspaceViewState +): SelectedWorkspace { + return { + name: state.name, + root, + status: [], + unregisteredCurrentWorkspace: false, + }; +} + +export async function selectOrCreateWorkspaceForInitiativeOpen(input: { + workspaceName?: string; + context: WorkspaceContextState; + preferredOpener?: WorkspacePreferredOpener; +}): Promise<{ selected: SelectedWorkspace; created: boolean; state: WorkspaceViewState }> { + if (input.workspaceName) { + const workspaceName = validateWorkspaceNameForSetup(input.workspaceName); + const existing = await readExistingManagedWorkspaceView(workspaceName); + + if (!existing) { + const workspace = await createManagedWorkspace( + workspaceName, + {}, + input.preferredOpener, + input.context + ); + return { + selected: { + name: workspace.name, + root: workspace.root, + status: [], + unregisteredCurrentWorkspace: false, + }, + created: true, + state: await readWorkspaceViewState(workspace.root), + }; + } + + if (sameWorkspaceContext(existing.state.context, input.context)) { + return { + selected: selectedWorkspaceFromManagedView(existing.root, existing.state), + created: false, + state: existing.state, + }; + } + + if (!existing.state.context) { + throw new WorkspaceCliError( + `Workspace '${workspaceName}' is not bound to an initiative.`, + 'workspace_context_bind_required', + { + target: 'workspace.context', + fix: 'Choose a new workspace name for this initiative or use a future workspace rebind/update surface.', + } + ); + } + + throw new WorkspaceCliError( + `Workspace '${workspaceName}' is already bound to ${formatWorkspaceContext(existing.state.context)}.`, + 'workspace_context_conflict', + { + target: 'workspace.context', + fix: 'Choose a different workspace name or open the initiative already bound to this workspace.', + } + ); + } + + const matches: Array<{ root: string; state: WorkspaceViewState }> = []; + + for (const entry of await listKnownWorkspaceEntries()) { + try { + const state = await readWorkspaceViewState(entry.workspaceRoot); + if (sameWorkspaceContext(state.context, input.context)) { + matches.push({ root: entry.workspaceRoot, state }); + } + } catch { + // Broken workspaces are surfaced by list/doctor; initiative open should not + // guess through unreadable local view records. + } + } + + if (matches.length === 1) { + const [match] = matches; + return { + selected: selectedWorkspaceFromManagedView(match.root, match.state), + created: false, + state: match.state, + }; + } + + if (matches.length > 1) { + const names = matches.map((match) => match.state.name).sort((a, b) => a.localeCompare(b)); + throw new WorkspaceCliError( + `Multiple workspaces are already bound to ${formatWorkspaceContext(input.context)}: ${names.join(', ')}.`, + 'workspace_initiative_selection_ambiguous', + { + target: 'workspace.name', + fix: 'Retry with an explicit workspace name.', + } + ); + } + + const derivedName = deriveWorkspaceNameForInitiative(getWorkspaceContextInitiativeId(input.context)); + const existingDerived = await readExistingManagedWorkspaceView(derivedName); + + if (existingDerived) { + if (sameWorkspaceContext(existingDerived.state.context, input.context)) { + return { + selected: selectedWorkspaceFromManagedView(existingDerived.root, existingDerived.state), + created: false, + state: existingDerived.state, + }; + } + + throw new WorkspaceCliError( + `Default workspace name '${derivedName}' is already used by a workspace with ${formatWorkspaceContext(existingDerived.state.context)}.`, + 'workspace_name_collision', + { + target: 'workspace.name', + fix: `Retry with an explicit workspace name: openspec workspace open <name> --initiative ${getWorkspaceContextStoreId(input.context)}/${getWorkspaceContextInitiativeId(input.context)}`, + } + ); + } + + const workspace = await createManagedWorkspace( + derivedName, + {}, + input.preferredOpener, + input.context + ); + + return { + selected: { + name: workspace.name, + root: workspace.root, + status: [], + unregisteredCurrentWorkspace: false, + }, + created: true, + state: await readWorkspaceViewState(workspace.root), + }; } diff --git a/src/commands/workspace/prompt-theme.ts b/src/commands/workspace/prompt-theme.ts new file mode 100644 index 0000000000..988e4cc0e2 --- /dev/null +++ b/src/commands/workspace/prompt-theme.ts @@ -0,0 +1,26 @@ +import chalk from 'chalk'; + +export const workspacePromptTheme = { + prefix: '', + style: { + answer: (text: string) => chalk.cyan(text), + defaultAnswer: (text: string) => chalk.dim(text), + error: (text: string) => chalk.red(text), + help: (text: string) => chalk.dim(text), + highlight: (text: string) => chalk.cyan(text), + key: (text: string) => chalk.cyan(text), + message: (text: string) => chalk.bold(text), + }, +}; + +export const workspaceSelectTheme = { + ...workspacePromptTheme, + icon: { + cursor: chalk.cyan('>'), + }, + style: { + ...workspacePromptTheme.style, + keysHelpTip: (keys: [key: string, action: string][]) => + chalk.dim(keys.map(([key, action]) => `${key}: ${action}`).join(' | ')), + }, +}; diff --git a/src/commands/workspace/registration.ts b/src/commands/workspace/registration.ts new file mode 100644 index 0000000000..77676136ac --- /dev/null +++ b/src/commands/workspace/registration.ts @@ -0,0 +1,151 @@ +import { Command } from 'commander'; + +import { getWorkspaceSkillToolIds } from '../../core/workspace/index.js'; +import { + WorkspaceLinkOptions, + WorkspaceListOptions, + WorkspaceOpenOptions, + WorkspaceSetupOptions, + WorkspaceUpdateOptions, +} from './types.js'; + +export interface WorkspaceCommandActions { + setup(options: WorkspaceSetupOptions): Promise<void>; + list(options: WorkspaceListOptions): Promise<void>; + link( + nameOrPath: string | undefined, + linkPath: string | undefined, + options: WorkspaceLinkOptions + ): Promise<void>; + relink( + linkNameInput: string | undefined, + linkPath: string | undefined, + options: WorkspaceLinkOptions + ): Promise<void>; + doctor(options: WorkspaceLinkOptions): Promise<void>; + update( + positionalName: string | undefined, + options: WorkspaceUpdateOptions + ): Promise<void>; + open( + positionalName: string | undefined, + options: WorkspaceOpenOptions + ): Promise<void>; +} + +function collectOption(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +function addWorkspaceSelectionOptions(command: Command): Command { + return command + .option('--workspace <name>', 'Workspace name from known local workspace views') + .option('--json', 'Output as JSON') + .option('--no-interactive', 'Disable prompts'); +} + +export function registerWorkspaceCommandWith( + program: Command, + workspaceCommand: WorkspaceCommandActions +): void { + const workspace = program + .command('workspace') + .description('Set up and inspect coordination workspaces'); + + workspace + .command('setup') + .description('Set up a workspace and link existing repos or folders') + .option('--name <name>', 'Workspace name') + .option('--link <link>', 'Repo or folder link. Use <path> or <name>=<path>.', collectOption, []) + .option('--opener <id>', 'Preferred opener: codex, claude, github-copilot, or editor') + .option( + '--tools <tools>', + `Install OpenSpec skills for agents. Use "all", "none", or a comma-separated list of: ${getWorkspaceSkillToolIds().join(', ')}` + ) + .option('--json', 'Output as JSON') + .option('--no-interactive', 'Disable prompts') + .action(async (options: WorkspaceSetupOptions) => { + await workspaceCommand.setup(options); + }); + + workspace + .command('list') + .description('List known OpenSpec workspaces') + .option('--json', 'Output as JSON') + .action(async (options: WorkspaceListOptions) => { + await workspaceCommand.list(options); + }); + + workspace + .command('ls') + .description('List known OpenSpec workspaces') + .option('--json', 'Output as JSON') + .action(async (options: WorkspaceListOptions) => { + await workspaceCommand.list(options); + }); + + addWorkspaceSelectionOptions( + workspace + .command('link [nameOrPath] [path]') + .description('Link an existing repo or folder to a workspace') + ).action(async ( + nameOrPath: string | undefined, + linkPath: string | undefined, + options: WorkspaceLinkOptions + ) => { + await workspaceCommand.link(nameOrPath, linkPath, options); + }); + + addWorkspaceSelectionOptions( + workspace + .command('relink <name> <path>') + .description('Update the local path for an existing workspace link') + ).action(async ( + linkName: string | undefined, + linkPath: string | undefined, + options: WorkspaceLinkOptions + ) => { + await workspaceCommand.relink(linkName, linkPath, options); + }); + + addWorkspaceSelectionOptions( + workspace + .command('doctor') + .description('Check what a workspace can resolve on this machine') + ).action(async (options: WorkspaceLinkOptions) => { + await workspaceCommand.doctor(options); + }); + + workspace + .command('update [name]') + .description('Refresh workspace-local OpenSpec guidance and agent skills') + .option('--workspace <name>', 'Workspace name from known local workspace views') + .option( + '--tools <tools>', + `Select agents for workspace skills. Use "all", "none", or a comma-separated list of: ${getWorkspaceSkillToolIds().join(', ')}. Global profile selects workflows; --tools selects agents.` + ) + .option('--json', 'Output as JSON') + .option('--no-interactive', 'Disable prompts') + .action(async (name: string | undefined, options: WorkspaceUpdateOptions) => { + await workspaceCommand.update(name, options); + }); + + workspace + .command('open [name]') + .description('Open a workspace in an agent or VS Code editor') + .option('--workspace <name>', 'Workspace name from known local workspace views') + .option('--initiative <id>', 'Open an initiative as a local workspace view') + .option('--store <id>', 'Context store id for --initiative') + .option('--store-path <path>', 'Existing local context store root for --initiative') + .option('--agent <tool>', 'Use an agent for this session: codex, claude, or github-copilot') + .option('--editor', 'Open the workspace in VS Code editor mode') + .option('--prepare-only', 'Unsupported: preview surfaces belong to a future context/query command') + .option('--json', 'Output generated workspace view context as JSON after launch') + .option('--change <id>', 'Unsupported: change-scoped open belongs to future workspace change planning') + .option('--no-interactive', 'Disable prompts') + .action(async (name: string | undefined, options: WorkspaceOpenOptions) => { + await workspaceCommand.open(name, options); + }); + + // Intentionally no public `workspace create` command in this slice. +} diff --git a/src/commands/workspace/selection.ts b/src/commands/workspace/selection.ts index 05dfa9dbd6..7a19816cdb 100644 --- a/src/commands/workspace/selection.ts +++ b/src/commands/workspace/selection.ts @@ -1,11 +1,12 @@ import { findWorkspaceRoot, - listWorkspaceRegistryEntries, - readWorkspaceSharedState, + listKnownWorkspaceEntries, + readWorkspaceViewState, + type WorkspaceRegistryEntry, } from '../../core/workspace/index.js'; import { FileSystemUtils } from '../../utils/file-system.js'; import { isInteractive, resolveNoInteractive } from '../../utils/interactive.js'; -import { readRegistry, validateWorkspaceNameForSetup } from './operations.js'; +import { validateWorkspaceNameForSetup } from './operations.js'; import { SelectedWorkspace, WorkspaceCliError, @@ -22,49 +23,56 @@ function normalizeRegistryRootForComparison(workspaceRoot: string): string { } } -function workspaceNotInRegistryWarning(): WorkspaceStatus { +function workspaceNotInKnownViewsWarning(): WorkspaceStatus { return makeStatus( 'warning', - 'workspace_not_in_local_registry', - 'This workspace is not recorded in the local workspace registry.', + 'workspace_not_in_known_views', + 'This workspace is not in the managed local workspace views list.', { target: 'workspace.root', - fix: 'Run a mutating workspace command from this workspace, such as workspace link or workspace relink, to record it locally.', + fix: 'Use openspec workspace list to inspect managed workspace views.', } ); } -function isRegisteredWorkspaceRoot( - registryRoot: string | undefined, +function sameWorkspaceRoot( + knownRoot: string | undefined, currentWorkspaceRoot: string ): boolean { return ( - registryRoot !== undefined && - normalizeRegistryRootForComparison(registryRoot) === + knownRoot !== undefined && + normalizeRegistryRootForComparison(knownRoot) === normalizeRegistryRootForComparison(currentWorkspaceRoot) ); } +function findKnownWorkspaceByName( + entries: WorkspaceRegistryEntry[], + workspaceName: string +): WorkspaceRegistryEntry | undefined { + return entries.find((entry) => entry.name === workspaceName); +} + async function selectedWorkspaceFromRoot( currentWorkspaceRoot: string, - registry: Awaited<ReturnType<typeof readRegistry>> + entries: WorkspaceRegistryEntry[] ): Promise<SelectedWorkspace> { - const sharedState = await readWorkspaceSharedState(currentWorkspaceRoot); - const registeredRoot = registry.workspaces[sharedState.name]; - const isRegistered = isRegisteredWorkspaceRoot(registeredRoot, currentWorkspaceRoot); + const viewState = await readWorkspaceViewState(currentWorkspaceRoot); + const knownRoot = findKnownWorkspaceByName(entries, viewState.name)?.workspaceRoot; + const isKnown = sameWorkspaceRoot(knownRoot, currentWorkspaceRoot); return { - name: sharedState.name, + name: viewState.name, root: currentWorkspaceRoot, - status: isRegistered ? [] : [workspaceNotInRegistryWarning()], - unregisteredCurrentWorkspace: !isRegistered, + status: isKnown ? [] : [workspaceNotInKnownViewsWarning()], + unregisteredCurrentWorkspace: !isKnown, }; } export async function selectWorkspaceRootForCommand( workspaceRoot: string ): Promise<SelectedWorkspace> { - const registry = await readRegistry(); + const entries = await listKnownWorkspaceEntries(); const currentWorkspaceRoot = await findWorkspaceRoot(workspaceRoot); if (!currentWorkspaceRoot) { @@ -78,7 +86,7 @@ export async function selectWorkspaceRootForCommand( ); } - return selectedWorkspaceFromRoot(currentWorkspaceRoot, registry); + return selectedWorkspaceFromRoot(currentWorkspaceRoot, entries); } export async function selectWorkspaceForCommand( @@ -86,13 +94,13 @@ export async function selectWorkspaceForCommand( commandName: string, selectionOptions: { preferPositionalName?: boolean } = {} ): Promise<SelectedWorkspace> { - const registry = await readRegistry(); + const entries = await listKnownWorkspaceEntries(); if (options.workspace) { const workspaceName = validateWorkspaceNameForSetup(options.workspace); - const registryRoot = registry.workspaces[workspaceName]; + const entry = findKnownWorkspaceByName(entries, workspaceName); - if (!registryRoot) { + if (!entry) { throw new WorkspaceCliError( `Unknown OpenSpec workspace '${workspaceName}'.`, 'workspace_not_found', @@ -105,7 +113,7 @@ export async function selectWorkspaceForCommand( return { name: workspaceName, - root: registryRoot, + root: entry.workspaceRoot, status: [], unregisteredCurrentWorkspace: false, }; @@ -114,11 +122,9 @@ export async function selectWorkspaceForCommand( const currentWorkspaceRoot = await findWorkspaceRoot(process.cwd()); if (currentWorkspaceRoot) { - return selectedWorkspaceFromRoot(currentWorkspaceRoot, registry); + return selectedWorkspaceFromRoot(currentWorkspaceRoot, entries); } - const entries = listWorkspaceRegistryEntries(registry); - if (entries.length === 0) { throw new WorkspaceCliError( "No known OpenSpec workspaces. Run 'openspec workspace setup' first.\nAfter at least one workspace is known locally, you can also pass --workspace <name>.", @@ -168,10 +174,22 @@ export async function selectWorkspaceForCommand( value: entry.name, })), }); + const selectedEntry = findKnownWorkspaceByName(entries, selectedName); + + if (!selectedEntry) { + throw new WorkspaceCliError( + `Unknown OpenSpec workspace '${selectedName}'.`, + 'workspace_not_found', + { + target: 'workspace.name', + fix: 'Run openspec workspace list to see known workspaces.', + } + ); + } return { name: selectedName, - root: registry.workspaces[selectedName], + root: selectedEntry.workspaceRoot, status: [], unregisteredCurrentWorkspace: false, }; diff --git a/src/commands/workspace/types.ts b/src/commands/workspace/types.ts index e680cc901d..8d5cb32d5a 100644 --- a/src/commands/workspace/types.ts +++ b/src/commands/workspace/types.ts @@ -1,3 +1,5 @@ +import type { ContextStoreSelector } from '../../core/context-store/index.js'; + export type StatusSeverity = 'error' | 'warning'; export interface WorkspaceStatus { @@ -6,6 +8,7 @@ export interface WorkspaceStatus { message: string; target?: string; fix?: string; + details?: Record<string, unknown>; } export interface WorkspaceLinkOutput { @@ -15,10 +18,18 @@ export interface WorkspaceLinkOutput { status: WorkspaceStatus[]; } +export interface WorkspaceContextOutput { + store: string; + initiative: string; + store_selector: ContextStoreSelector; +} + export interface WorkspaceOutput { name: string; root: string; planning_path: string; + state_path?: string; + context?: WorkspaceContextOutput | null; links: WorkspaceLinkOutput[]; status: WorkspaceStatus[]; } @@ -26,6 +37,7 @@ export interface WorkspaceOutput { export interface WorkspaceListOutput { name: string; root: string; + context?: WorkspaceContextOutput | null; links: WorkspaceLinkOutput[]; status: WorkspaceStatus[]; } @@ -59,6 +71,9 @@ export interface WorkspaceOpenOptions extends WorkspaceSelectionOptions { editor?: boolean; prepareOnly?: boolean; change?: string; + initiative?: string; + store?: string; + storePath?: string; } export interface WorkspaceListOptions { @@ -85,7 +100,11 @@ export interface WorkspaceLinkMutationPayload { export class WorkspaceCliError extends Error { readonly status: WorkspaceStatus; - constructor(message: string, code: string, options: { target?: string; fix?: string } = {}) { + constructor( + message: string, + code: string, + options: { target?: string; fix?: string; details?: Record<string, unknown> } = {} + ) { super(message); this.status = { severity: 'error', @@ -100,7 +119,7 @@ export function makeStatus( severity: StatusSeverity, code: string, message: string, - options: { target?: string; fix?: string } = {} + options: { target?: string; fix?: string; details?: Record<string, unknown> } = {} ): WorkspaceStatus { return { severity, diff --git a/src/core/artifact-graph/index.ts b/src/core/artifact-graph/index.ts index 24ab2d383a..0917a47ce0 100644 --- a/src/core/artifact-graph/index.ts +++ b/src/core/artifact-graph/index.ts @@ -44,7 +44,9 @@ export { type ArtifactStatus, type ChangeStatus, type ArtifactPathSummary, - type PlanningHomeSummary, - type AffectedAreasSummary, - type ActionContext, } from './instruction-loader.js'; +export type { + PlanningHomeSummary, + AffectedAreasSummary, + ActionContext, +} from '../change-status-policy.js'; diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index 323c4df323..3387fd6a5e 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -6,8 +6,18 @@ import { detectCompleted } from './state.js'; import { resolveArtifactOutputs } from './outputs.js'; import { readChangeMetadata, resolveSchemaForChange } from '../../utils/change-metadata.js'; import { FileSystemUtils } from '../../utils/file-system.js'; +import { + buildActionContext, + buildNextSteps, + summarizeAffectedAreas, + summarizePlanningHome, + type ActionContext, + type AffectedAreasSummary, + type PlanningHomeSummary, +} from '../change-status-policy.js'; import { readProjectConfig, validateConfigRules } from '../project-config.js'; import type { PlanningHome } from '../planning-home.js'; +import type { ChangeMetadata, InitiativeLink } from '../change-metadata/index.js'; import type { Artifact, CompletedSet } from './types.js'; // Session-level cache for validation warnings (avoid repeating same warnings) @@ -44,6 +54,10 @@ export interface ChangeContext { projectRoot: string; /** Resolved planning home for this change */ planningHome?: PlanningHome; + /** Parsed change metadata, when present */ + metadata?: ChangeMetadata; + /** Stored initiative link, when this change is linked to shared context */ + initiative?: InitiativeLink; } export interface LoadChangeContextOptions { @@ -65,6 +79,8 @@ export interface ArtifactInstructions { changeDir: string; /** Resolved planning home for this change */ planningHome?: PlanningHomeSummary; + /** Stored initiative link, when this change is linked to shared context */ + initiative?: InitiativeLink; /** Output path pattern (e.g., "proposal.md") */ outputPath: string; /** Absolute output path or glob pattern resolved under the change directory */ @@ -125,6 +141,8 @@ export interface ChangeStatus { schemaName: string; /** Resolved planning home for this change */ planningHome?: PlanningHomeSummary; + /** Stored initiative link, when this change is linked to shared context */ + initiative?: InitiativeLink; /** Full path to the change root */ changeRoot: string; /** Absolute artifact path details keyed by artifact ID */ @@ -149,30 +167,6 @@ export interface ArtifactPathSummary { existingOutputPaths: string[]; } -export interface PlanningHomeSummary { - kind: 'repo' | 'workspace'; - root: string; - changesDir: string; - defaultSchema: string; - workspaceName?: string; -} - -export interface AffectedAreasSummary { - known: string[]; - unresolved: boolean; - invalid: string[]; -} - -export interface ActionContext { - mode: 'repo-local' | 'workspace-planning'; - sourceOfTruth: 'repo' | 'workspace'; - planningArtifacts: string[]; - linkedContext: Array<{ name: string }>; - allowedEditRoots: string[]; - requiresAffectedAreaSelection: boolean; - constraints: string[]; -} - /** * Loads a template from a schema's templates directory. * @@ -240,8 +234,10 @@ export function loadChangeContext( options.changeDir ?? path.join(projectRoot, 'openspec', 'changes', changeName) ); - // Resolve schema: explicit > metadata > default - const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName, projectRoot); + const metadata = readChangeMetadata(changeDir, projectRoot) ?? undefined; + const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName, projectRoot, { + metadata: metadata ?? null, + }); const schema = resolveSchema(resolvedSchemaName, projectRoot); const graph = ArtifactGraph.fromSchema(schema); @@ -255,6 +251,8 @@ export function loadChangeContext( changeDir, projectRoot, ...(options.planningHome ? { planningHome: options.planningHome } : {}), + ...(metadata ? { metadata } : {}), + ...(metadata?.initiative ? { initiative: metadata.initiative } : {}), }; } @@ -328,6 +326,7 @@ export function generateInstructions( schemaName: context.schemaName, changeDir: context.changeDir, planningHome: summarizePlanningHome(context.planningHome), + ...(context.initiative ? { initiative: context.initiative } : {}), outputPath: artifact.generates, resolvedOutputPath: path.join(context.changeDir, artifact.generates), existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates), @@ -375,110 +374,6 @@ function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[ return unlocks.sort(); } -function summarizePlanningHome(planningHome: PlanningHome | undefined): PlanningHomeSummary | undefined { - if (!planningHome) { - return undefined; - } - - return { - kind: planningHome.kind, - root: planningHome.root, - changesDir: planningHome.changesDir, - defaultSchema: planningHome.defaultSchema, - ...(planningHome.workspace ? { workspaceName: planningHome.workspace.name } : {}), - }; -} - -function getWorkspaceSpecAreaSegments(context: ChangeContext): string[] { - if (context.planningHome?.kind !== 'workspace') { - return []; - } - - const specArtifact = context.graph.getArtifact('specs'); - if (!specArtifact) { - return []; - } - - return resolveArtifactOutputs(context.changeDir, specArtifact.generates) - .map((outputPath) => path.relative(path.join(context.changeDir, 'specs'), outputPath)) - .filter((relativePath) => relativePath.length > 0 && !relativePath.startsWith('..')) - .map((relativePath) => relativePath.split(path.sep)[0]) - .filter((areaName) => areaName.length > 0); -} - -function getAffectedAreasSummary(context: ChangeContext): AffectedAreasSummary | undefined { - if (context.planningHome?.kind !== 'workspace') { - return undefined; - } - - const metadata = readChangeMetadata(context.changeDir, context.projectRoot); - const known = Array.from( - new Set([...(metadata?.affected_areas ?? []), ...getWorkspaceSpecAreaSegments(context)]) - ).sort((a, b) => a.localeCompare(b)); - const validAreas = new Set(context.planningHome.workspace?.links ?? []); - const invalid = known.filter((areaName) => validAreas.size > 0 && !validAreas.has(areaName)); - - return { - known, - unresolved: known.length === 0, - invalid, - }; -} - -function buildActionContext(context: ChangeContext, artifactIds: string[]): ActionContext { - if (context.planningHome?.kind === 'workspace') { - return { - mode: 'workspace-planning', - sourceOfTruth: 'workspace', - planningArtifacts: artifactIds, - linkedContext: (context.planningHome.workspace?.links ?? []).map((name) => ({ name })), - allowedEditRoots: [], - requiresAffectedAreaSelection: true, - constraints: [ - 'Use workspace-level planning artifacts as the source of truth.', - 'Treat linked repos and folders as exploration context until an affected area is selected.', - 'Do not make implementation edits without an explicit allowed edit root.', - ], - }; - } - - return { - mode: 'repo-local', - sourceOfTruth: 'repo', - planningArtifacts: artifactIds, - linkedContext: [], - allowedEditRoots: [context.projectRoot], - requiresAffectedAreaSelection: false, - constraints: ['Repo-local change artifacts and implementation edits are scoped to this project.'], - }; -} - -function buildNextSteps( - context: ChangeContext, - artifactStatuses: ArtifactStatus[], - affectedAreas: AffectedAreasSummary | undefined -): string[] { - const readyArtifact = artifactStatuses.find((artifact) => artifact.status === 'ready'); - const steps: string[] = []; - - if (readyArtifact) { - steps.push( - `Run openspec instructions ${readyArtifact.id} --change "${context.changeName}" --json before writing that artifact.` - ); - } else if (context.graph.isComplete(context.completed)) { - steps.push('All planning artifacts are complete; review tasks before implementation.'); - } - - if (context.planningHome?.kind === 'workspace') { - if (affectedAreas?.unresolved) { - steps.push('Identify affected areas in workspace specs or coordination tasks as planning continues.'); - } - steps.push('Select an affected area and allowed edit root before implementation edits.'); - } - - return steps; -} - /** * Formats the status of all artifacts in a change. * @@ -530,19 +425,35 @@ export function formatChangeStatus(context: ChangeContext): ChangeStatus { const buildOrder = context.graph.getBuildOrder(); const orderMap = new Map(buildOrder.map((id, idx) => [id, idx])); artifactStatuses.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)); - const affectedAreas = getAffectedAreasSummary(context); + const affectedAreas = summarizeAffectedAreas({ + planningHome: context.planningHome, + metadata: context.metadata, + }); + const isComplete = context.graph.isComplete(context.completed); + const artifactIds = artifactStatuses.map((artifact) => artifact.id); return { changeName: context.changeName, schemaName: context.schemaName, planningHome: summarizePlanningHome(context.planningHome), + ...(context.initiative ? { initiative: context.initiative } : {}), changeRoot: context.changeDir, artifactPaths, affectedAreas, - isComplete: context.graph.isComplete(context.completed), + isComplete, applyRequires, - nextSteps: buildNextSteps(context, artifactStatuses, affectedAreas), - actionContext: buildActionContext(context, artifactStatuses.map((artifact) => artifact.id)), + nextSteps: buildNextSteps({ + changeName: context.changeName, + planningHome: context.planningHome, + artifactStatuses, + affectedAreas, + allArtifactsComplete: isComplete, + }), + actionContext: buildActionContext({ + planningHome: context.planningHome, + projectRoot: context.projectRoot, + artifactIds, + }), artifacts: artifactStatuses, }; } diff --git a/src/core/artifact-graph/types.ts b/src/core/artifact-graph/types.ts index 03b34cc3bc..c2d2128e45 100644 --- a/src/core/artifact-graph/types.ts +++ b/src/core/artifact-graph/types.ts @@ -35,30 +35,6 @@ export type Artifact = z.infer<typeof ArtifactSchema>; export type ApplyPhase = z.infer<typeof ApplyPhaseSchema>; export type SchemaYaml = z.infer<typeof SchemaYamlSchema>; -// Per-change metadata schema -// Note: schema field is validated at parse time against available schemas -// using a lazy import to avoid circular dependencies -export const ChangeMetadataSchema = z.object({ - // Required: which workflow schema this change uses - schema: z.string().min(1, { message: 'schema is required' }), - - // Optional: creation timestamp (ISO date string) - created: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/, { - message: 'created must be YYYY-MM-DD format', - }) - .optional(), - - // Optional workspace planning metadata. These fields are intentionally - // lightweight and do not replace the normal proposal/specs/design/tasks - // artifacts as the source of planning detail. - goal: z.string().min(1).optional(), - affected_areas: z.array(z.string().min(1)).optional(), -}); - -export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>; - // Runtime state types (not Zod - internal only) // Slice 1: Simple completion tracking via filesystem diff --git a/src/core/change-metadata/index.ts b/src/core/change-metadata/index.ts new file mode 100644 index 0000000000..8868041f90 --- /dev/null +++ b/src/core/change-metadata/index.ts @@ -0,0 +1 @@ +export * from './schema.js'; diff --git a/src/core/change-metadata/schema.ts b/src/core/change-metadata/schema.ts new file mode 100644 index 0000000000..9d7cc93749 --- /dev/null +++ b/src/core/change-metadata/schema.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +const KebabIdentifierSchema = (label: string): z.ZodString => + z.string().superRefine((value, ctx) => { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value)) { + ctx.addIssue({ + code: 'custom', + message: `${label} must be kebab-case with lowercase letters, numbers, and single hyphen separators`, + }); + } + }); + +export const InitiativeLinkSchema = z.object({ + store: KebabIdentifierSchema('Context store id'), + id: KebabIdentifierSchema('Initiative id'), +}).strict(); + +export type InitiativeLink = z.infer<typeof InitiativeLinkSchema>; + +// Per-change metadata schema. The schema field is validated against available +// workflow schemas when metadata is read or written. +export const ChangeMetadataSchema = z.object({ + schema: z.string().min(1, { message: 'schema is required' }), + created: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, { + message: 'created must be YYYY-MM-DD format', + }) + .optional(), + goal: z.string().min(1).optional(), + affected_areas: z.array(z.string().min(1)).optional(), + initiative: InitiativeLinkSchema.optional(), +}); + +export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>; diff --git a/src/core/change-status-policy.ts b/src/core/change-status-policy.ts new file mode 100644 index 0000000000..896c1bb610 --- /dev/null +++ b/src/core/change-status-policy.ts @@ -0,0 +1,135 @@ +import type { ChangeMetadata } from './change-metadata/index.js'; +import type { PlanningHome } from './planning-home.js'; + +export interface PlanningHomeSummary { + kind: 'repo' | 'workspace'; + root: string; + changesDir: string; + defaultSchema: string; + workspaceName?: string; +} + +export interface AffectedAreasSummary { + known: string[]; + unresolved: boolean; + invalid: string[]; +} + +export interface ActionContext { + mode: 'repo-local' | 'workspace-planning'; + sourceOfTruth: 'repo' | 'workspace-local'; + planningArtifacts: string[]; + linkedContext: Array<{ name: string }>; + allowedEditRoots: string[]; + requiresAffectedAreaSelection: boolean; + constraints: string[]; +} + +export interface ChangeStatusPolicyArtifact { + id: string; + status: 'done' | 'ready' | 'blocked'; +} + +export interface AffectedAreasInput { + planningHome?: PlanningHome; + metadata?: ChangeMetadata; +} + +export interface ChangeNextStepsInput { + changeName: string; + planningHome?: PlanningHome; + artifactStatuses: ChangeStatusPolicyArtifact[]; + affectedAreas?: AffectedAreasSummary; + allArtifactsComplete: boolean; +} + +export interface ActionContextInput { + planningHome?: PlanningHome; + projectRoot: string; + artifactIds: string[]; +} + +export function summarizePlanningHome( + planningHome: PlanningHome | undefined +): PlanningHomeSummary | undefined { + if (!planningHome) { + return undefined; + } + + return { + kind: planningHome.kind, + root: planningHome.root, + changesDir: planningHome.changesDir, + defaultSchema: planningHome.defaultSchema, + ...(planningHome.workspace ? { workspaceName: planningHome.workspace.name } : {}), + }; +} + +export function summarizeAffectedAreas(input: AffectedAreasInput): AffectedAreasSummary | undefined { + if (input.planningHome?.kind !== 'workspace') { + return undefined; + } + + const known = Array.from( + new Set(input.metadata?.affected_areas ?? []) + ).sort((a, b) => a.localeCompare(b)); + const validAreas = new Set(input.planningHome.workspace?.links ?? []); + const invalid = known.filter((areaName) => validAreas.size > 0 && !validAreas.has(areaName)); + + return { + known, + unresolved: known.length === 0, + invalid, + }; +} + +export function buildActionContext(input: ActionContextInput): ActionContext { + if (input.planningHome?.kind === 'workspace') { + return { + mode: 'workspace-planning', + sourceOfTruth: 'workspace-local', + planningArtifacts: input.artifactIds, + linkedContext: (input.planningHome.workspace?.links ?? []).map((name) => ({ name })), + allowedEditRoots: [], + requiresAffectedAreaSelection: true, + constraints: [ + '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.', + 'Do not make implementation edits without an explicit allowed edit root.', + ], + }; + } + + return { + mode: 'repo-local', + sourceOfTruth: 'repo', + planningArtifacts: input.artifactIds, + linkedContext: [], + allowedEditRoots: [input.projectRoot], + requiresAffectedAreaSelection: false, + constraints: ['Repo-local change artifacts and implementation edits are scoped to this project.'], + }; +} + +export function buildNextSteps(input: ChangeNextStepsInput): string[] { + const readyArtifact = input.artifactStatuses.find((artifact) => artifact.status === 'ready'); + const steps: string[] = []; + + if (readyArtifact) { + steps.push( + `Run openspec instructions ${readyArtifact.id} --change "${input.changeName}" --json before writing that artifact.` + ); + } else if (input.allArtifactsComplete) { + steps.push('All planning artifacts are complete; review tasks before implementation.'); + } + + if (input.planningHome?.kind === 'workspace') { + if (input.affectedAreas?.unresolved) { + steps.push('Identify affected areas in change metadata or coordination tasks as planning continues.'); + } + steps.push('Select an affected area and allowed edit root before implementation edits.'); + } + + return steps; +} diff --git a/src/core/collections/index.ts b/src/core/collections/index.ts new file mode 100644 index 0000000000..b79534b4a9 --- /dev/null +++ b/src/core/collections/index.ts @@ -0,0 +1,2 @@ +export * from './runtime.js'; +export * from './initiatives/index.js'; diff --git a/src/core/collections/initiatives/collection.ts b/src/core/collections/initiatives/collection.ts new file mode 100644 index 0000000000..fabe2a72f6 --- /dev/null +++ b/src/core/collections/initiatives/collection.ts @@ -0,0 +1,23 @@ +import { + createCollectionRegistry, + mountCollections, + type CollectionRegistry, + type MountedCollection, +} from '../runtime.js'; +import { INITIATIVE_COLLECTION_ID } from './schema.js'; + +export function createInitiativesCollectionRegistry(): CollectionRegistry { + return createCollectionRegistry([ + { + id: INITIATIVE_COLLECTION_ID, + mount: INITIATIVE_COLLECTION_ID, + }, + ]); +} + +export function mountInitiativesCollection(storeRoot: string): MountedCollection { + return mountCollections({ + storeRoot, + collections: createInitiativesCollectionRegistry(), + }).require(INITIATIVE_COLLECTION_ID); +} diff --git a/src/core/collections/initiatives/index.ts b/src/core/collections/initiatives/index.ts new file mode 100644 index 0000000000..da4d8db244 --- /dev/null +++ b/src/core/collections/initiatives/index.ts @@ -0,0 +1,5 @@ +export * from './collection.js'; +export * from './schema.js'; +export * from './templates.js'; +export * from './operations.js'; +export * from './resolution.js'; diff --git a/src/core/collections/initiatives/operations.ts b/src/core/collections/initiatives/operations.ts new file mode 100644 index 0000000000..79a5077eaa --- /dev/null +++ b/src/core/collections/initiatives/operations.ts @@ -0,0 +1,314 @@ +import * as nodeFs from 'node:fs'; + +import type { MountedCollection } from '../runtime.js'; +import { + INITIATIVE_COLLECTION_ID, + INITIATIVE_FILE_NAME, + parseInitiativeState, + serializeInitiativeState, + validateInitiativeId, + type InitiativeMetadata, + type InitiativeState, + type InitiativeStatus, +} from './schema.js'; +import { + buildDefaultInitiativeFiles, + type InitiativeTemplateFile, +} from './templates.js'; + +const fs = nodeFs.promises; + +export interface InitiativeDirectoryEntry { + name: string; + isDirectory(): boolean; +} + +export interface InitiativeOperationsFileSystem { + mkdir(dirPath: string, options: { recursive?: boolean }): Promise<void>; + writeFile( + filePath: string, + content: string, + options: { flag?: nodeFs.OpenMode } + ): Promise<void>; + readFile(filePath: string): Promise<string>; + readdir( + dirPath: string, + options: { withFileTypes: true } + ): Promise<readonly InitiativeDirectoryEntry[]>; + rm(dirPath: string, options: { recursive?: boolean; force?: boolean }): Promise<void>; +} + +export interface InitiativeOperationDependencies { + fileSystem?: InitiativeOperationsFileSystem; +} + +export interface CreateInitiativeInput extends InitiativeOperationDependencies { + collection: MountedCollection; + id: string; + title: string; + summary: string; + status?: InitiativeStatus; + owners?: string[]; + metadata?: InitiativeMetadata; + getCurrentDate?: () => string; + buildTemplateFiles?: (state: InitiativeState) => readonly InitiativeTemplateFile[]; +} + +export interface ListInitiativesInput extends InitiativeOperationDependencies { + collection: MountedCollection; +} + +export interface ReadInitiativeInput extends InitiativeOperationDependencies { + collection: MountedCollection; + id: string; +} + +const nodeFileSystem: InitiativeOperationsFileSystem = { + async mkdir(dirPath, options) { + await fs.mkdir(dirPath, options); + }, + + async writeFile(filePath, content, options) { + await fs.writeFile(filePath, content, { + encoding: 'utf-8', + flag: options.flag ?? 'w', + }); + }, + + async readFile(filePath) { + return fs.readFile(filePath, 'utf-8'); + }, + + async readdir(dirPath, options) { + return fs.readdir(dirPath, options); + }, + + async rm(dirPath, options) { + await fs.rm(dirPath, options); + }, +}; + +function getCurrentDate(): string { + return new Date().toISOString().split('T')[0]; +} + +function getFileSystem(fileSystem?: InitiativeOperationsFileSystem): InitiativeOperationsFileSystem { + return fileSystem ?? nodeFileSystem; +} + +function isFileNotFoundError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function isPathExistsError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function assertInitiativesCollection(collection: MountedCollection): void { + if (collection.collectionId !== INITIATIVE_COLLECTION_ID) { + throw new Error( + `Expected mounted '${INITIATIVE_COLLECTION_ID}' collection, got '${collection.collectionId}'` + ); + } +} + +function resolveInitiativeFilePath( + collection: MountedCollection, + initiativeId: string, + fileName: string +): string { + return collection.resolvePath(`${initiativeId}/${fileName}`); +} + +function normalizeCreateState(input: CreateInitiativeInput): InitiativeState { + return parseInitiativeState(serializeInitiativeState({ + version: 1, + id: validateInitiativeId(input.id), + title: input.title, + summary: input.summary, + status: input.status ?? 'exploring', + created: (input.getCurrentDate ?? getCurrentDate)(), + owners: input.owners ?? [], + metadata: input.metadata ?? {}, + })); +} + +async function writeExclusiveFile( + fileSystem: InitiativeOperationsFileSystem, + filePath: string, + content: string +): Promise<void> { + await fileSystem.writeFile(filePath, content, { flag: 'wx' }); +} + +async function cleanupCreatedInitiative( + fileSystem: InitiativeOperationsFileSystem, + initiativeRoot: string, + originalError: unknown, + initiativeId: string +): Promise<never> { + try { + await fileSystem.rm(initiativeRoot, { recursive: true, force: true }); + } catch (cleanupError) { + throw new Error( + `Failed to create initiative '${initiativeId}' and cleanup failed: ${errorMessage(originalError)}; cleanup: ${errorMessage(cleanupError)}` + ); + } + + throw new Error(`Failed to create initiative '${initiativeId}': ${errorMessage(originalError)}`); +} + +export async function createInitiative(input: CreateInitiativeInput): Promise<InitiativeState> { + assertInitiativesCollection(input.collection); + + const state = normalizeCreateState(input); + const fileSystem = getFileSystem(input.fileSystem); + const initiativeRoot = input.collection.resolvePath(state.id); + const buildTemplateFiles = input.buildTemplateFiles ?? buildDefaultInitiativeFiles; + + try { + await fileSystem.mkdir(input.collection.resolvePath(), { recursive: true }); + await fileSystem.mkdir(initiativeRoot, { recursive: false }); + } catch (error) { + if (isPathExistsError(error)) { + throw new Error(`Initiative '${state.id}' already exists at ${initiativeRoot}`); + } + + throw new Error(`Failed to create initiative '${state.id}': ${errorMessage(error)}`); + } + + try { + await writeExclusiveFile( + fileSystem, + resolveInitiativeFilePath(input.collection, state.id, INITIATIVE_FILE_NAME), + serializeInitiativeState(state) + ); + + for (const templateFile of buildTemplateFiles(state)) { + await writeExclusiveFile( + fileSystem, + resolveInitiativeFilePath(input.collection, state.id, templateFile.fileName), + templateFile.content + ); + } + } catch (error) { + await cleanupCreatedInitiative(fileSystem, initiativeRoot, error, state.id); + } + + return state; +} + +export async function readInitiative(input: ReadInitiativeInput): Promise<InitiativeState | null> { + assertInitiativesCollection(input.collection); + + const initiativeId = validateInitiativeId(input.id); + const fileSystem = getFileSystem(input.fileSystem); + const initiativeFilePath = resolveInitiativeFilePath( + input.collection, + initiativeId, + INITIATIVE_FILE_NAME + ); + + let content: string; + try { + content = await fileSystem.readFile(initiativeFilePath); + } catch (error) { + if (isFileNotFoundError(error)) { + return null; + } + + throw new Error( + `Invalid initiative '${initiativeId}': failed to read ${INITIATIVE_FILE_NAME}: ${errorMessage(error)}` + ); + } + + let state: InitiativeState; + try { + state = parseInitiativeState(content); + } catch (error) { + throw new Error(`Invalid initiative '${initiativeId}': ${errorMessage(error)}`); + } + + if (state.id !== initiativeId) { + throw new Error( + `Invalid initiative '${initiativeId}': ${INITIATIVE_FILE_NAME} id '${state.id}' must match folder name` + ); + } + + return state; +} + +export async function listInitiatives(input: ListInitiativesInput): Promise<InitiativeState[]> { + assertInitiativesCollection(input.collection); + + const fileSystem = getFileSystem(input.fileSystem); + let entries: readonly InitiativeDirectoryEntry[]; + + try { + entries = await fileSystem.readdir(input.collection.resolvePath(), { withFileTypes: true }); + } catch (error) { + if (isFileNotFoundError(error)) { + return []; + } + + throw new Error(`Failed to list initiatives: ${errorMessage(error)}`); + } + + const initiatives: InitiativeState[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const initiativeFilePath = resolveInitiativeFilePath( + input.collection, + entry.name, + INITIATIVE_FILE_NAME + ); + + let content: string; + try { + content = await fileSystem.readFile(initiativeFilePath); + } catch (error) { + if (isFileNotFoundError(error)) { + continue; + } + + throw new Error( + `Invalid initiative '${entry.name}': failed to read ${INITIATIVE_FILE_NAME}: ${errorMessage(error)}` + ); + } + + let state: InitiativeState; + try { + state = parseInitiativeState(content); + } catch (error) { + throw new Error(`Invalid initiative '${entry.name}': ${errorMessage(error)}`); + } + + if (state.id !== entry.name) { + throw new Error( + `Invalid initiative '${entry.name}': ${INITIATIVE_FILE_NAME} id '${state.id}' must match folder name` + ); + } + + initiatives.push(state); + } + + return initiatives.sort((a, b) => a.id.localeCompare(b.id)); +} diff --git a/src/core/collections/initiatives/resolution.ts b/src/core/collections/initiatives/resolution.ts new file mode 100644 index 0000000000..04d1b4f73f --- /dev/null +++ b/src/core/collections/initiatives/resolution.ts @@ -0,0 +1,675 @@ +import { + ContextStoreError, + formatContextStoreSelector, + listRegisteredContextStores, + resolveSelectedContextStore, + type ContextStoreSelectorOptions, + type ContextStoreSelectorSource, + type SelectedContextStore, +} from '../../context-store/index.js'; +import { mountInitiativesCollection } from './collection.js'; +import { listInitiatives, readInitiative } from './operations.js'; +import { INITIATIVE_FILE_NAME, type InitiativeState } from './schema.js'; + +export interface InitiativeSelectorOptions extends ContextStoreSelectorOptions { + json?: boolean; +} + +export type { ContextStoreSelectorSource, SelectedContextStore }; +export { formatContextStoreSelector }; + +export interface InitiativeResolutionMatch { + context_store: { + id: string; + root: string; + }; + initiative: { + id: string; + title: string; + root: string; + }; +} + +export interface InitiativeResolutionDetails extends Record<string, unknown> { + matches?: InitiativeResolutionMatch[]; +} + +export class InitiativeResolutionError extends Error { + readonly code: string; + readonly target?: string; + readonly fix?: string; + readonly details?: InitiativeResolutionDetails; + + constructor( + message: string, + code: string, + options: { target?: string; fix?: string; details?: InitiativeResolutionDetails } = {} + ) { + super(message); + this.code = code; + this.target = options.target; + this.fix = options.fix; + this.details = options.details; + } +} + +export interface InitiativeViewReference { + store: string; + storeSource: ContextStoreSelectorSource; + storeRoot: string; + id: string; + title: string; + summary: string; + created: string; + root: string; + storePath: string; + metadataPath: string; +} + +export interface ListedInitiativeReference extends InitiativeViewReference { + status: InitiativeState['status']; + owners: InitiativeState['owners']; + metadata: InitiativeState['metadata']; +} + +export type InitiativeDiagnosticSeverity = 'error' | 'warning'; + +export interface InitiativeDiagnostic { + severity: InitiativeDiagnosticSeverity; + code: string; + message: string; + target?: string; + fix?: string; + details?: InitiativeResolutionDetails; +} + +export interface ContextStoreInitiativeListReference { + contextStore: SelectedContextStore; + initiatives: ListedInitiativeReference[]; + status: InitiativeDiagnostic[]; +} + +export interface InitiativeListReferenceResult { + contextStore: SelectedContextStore | null; + contextStores: ContextStoreInitiativeListReference[]; + initiatives: ListedInitiativeReference[]; + status: InitiativeDiagnostic[]; +} + +function asErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function makeDiagnostic( + severity: InitiativeDiagnosticSeverity, + code: string, + message: string, + options: { target?: string; fix?: string; details?: InitiativeResolutionDetails } = {} +): InitiativeDiagnostic { + return { + severity, + code, + message, + ...options, + }; +} + +const INITIATIVE_ALREADY_EXISTS_PREFIX = "Initiative '"; +const INITIATIVE_ALREADY_EXISTS_MARKER = "' already exists"; + +export function initiativeDiagnosticFromError(error: unknown): InitiativeDiagnostic { + if (error instanceof InitiativeResolutionError) { + return makeDiagnostic('error', error.code, error.message, { + target: error.target, + fix: error.fix, + details: error.details, + }); + } + + const message = asErrorMessage(error); + + if ( + message.startsWith(INITIATIVE_ALREADY_EXISTS_PREFIX) && + message.includes( + INITIATIVE_ALREADY_EXISTS_MARKER, + INITIATIVE_ALREADY_EXISTS_PREFIX.length + ) + ) { + return makeDiagnostic('error', 'initiative_already_exists', message, { + target: 'initiative.id', + fix: 'Choose a new initiative id or list existing initiatives first.', + }); + } + + if (message.startsWith('Initiative id ')) { + return makeDiagnostic('error', 'invalid_initiative_id', message, { + target: 'initiative.id', + fix: 'Use kebab-case with lowercase letters, numbers, and single hyphen separators.', + }); + } + + if (message.startsWith('Invalid initiative')) { + return makeDiagnostic('error', 'invalid_initiative', message, { + target: 'initiative', + fix: 'Fix the initiative folder state and retry.', + }); + } + + return makeDiagnostic('error', 'initiative_error', message); +} + +function requireInitiativeId( + id: string | undefined, + commandName: 'create' | 'show' +): string { + if (id === undefined || id.trim().length === 0) { + throw new InitiativeResolutionError('Pass an initiative id.', 'initiative_id_required', { + target: 'initiative.id', + fix: `openspec initiative ${commandName} <id>`, + }); + } + + return id.trim(); +} + +export function parseInitiativeReference( + reference: string | undefined, + options: InitiativeSelectorOptions +): { initiativeId: string; options: InitiativeSelectorOptions } { + const initiativeId = requireInitiativeId(reference, 'show'); + const parts = initiativeId.split('/'); + + if (parts.length === 1) { + return { initiativeId, options }; + } + + if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) { + throw new InitiativeResolutionError( + `Invalid initiative reference '${initiativeId}'.`, + 'invalid_initiative_reference', + { + target: 'initiative.id', + fix: 'Use <initiative-id>, <store>/<initiative-id>, or <initiative-id> --store <store>.', + } + ); + } + + if (options.store !== undefined || options.storePath !== undefined) { + throw new InitiativeResolutionError( + 'Pass either --initiative <store>/<id> or a context store selector, not both.', + 'context_store_selector_conflict', + { + target: 'context_store', + fix: 'Use --initiative <store>/<id> or --initiative <id> --store <store>.', + } + ); + } + + return { + initiativeId: parts[1], + options: { + ...options, + store: parts[0], + }, + }; +} + +function contextStoreErrorAsInitiativeError(error: unknown): InitiativeResolutionError { + if (error instanceof ContextStoreError) { + return new InitiativeResolutionError(error.message, error.diagnostic.code, { + target: error.diagnostic.target, + fix: error.diagnostic.fix, + }); + } + + const message = asErrorMessage(error); + + if (message.startsWith('Context store id ')) { + return new InitiativeResolutionError(message, 'invalid_context_store_id', { + target: 'context_store.id', + fix: 'Use kebab-case with lowercase letters, numbers, and single hyphen separators.', + }); + } + + return new InitiativeResolutionError(message, 'invalid_context_store', { + target: 'context_store', + fix: 'Fix the context store registry or pass --store-path <path>.', + }); +} + +export async function resolveRegisteredInitiativeContextStore( + storeId: string +): Promise<SelectedContextStore> { + return selectContextStoreForInitiative({ store: storeId }, 'show'); +} + +export async function resolvePathInitiativeContextStore( + storePath: string +): Promise<SelectedContextStore> { + return selectContextStoreForInitiative({ storePath }, 'show'); +} + +export async function selectContextStoreForInitiative( + options: InitiativeSelectorOptions, + commandName: 'create' | 'list' | 'show' +): Promise<SelectedContextStore> { + try { + return await resolveSelectedContextStore(options, `initiative ${commandName}`); + } catch (error) { + throw contextStoreErrorAsInitiativeError(error); + } +} + +function toInitiativeViewReference( + selected: SelectedContextStore, + state: InitiativeState +): InitiativeViewReference { + const collection = mountInitiativesCollection(selected.root); + + return { + store: selected.id, + storeSource: selected.source, + storeRoot: selected.root, + id: state.id, + title: state.title, + summary: state.summary, + created: state.created, + root: collection.resolvePath(state.id), + storePath: collection.toStorePath(state.id), + metadataPath: collection.resolvePath(`${state.id}/${INITIATIVE_FILE_NAME}`), + }; +} + +function toResolutionMatch( + selected: SelectedContextStore, + state: InitiativeState +): InitiativeResolutionMatch { + const reference = toInitiativeViewReference(selected, state); + + return { + context_store: { + id: reference.store, + root: reference.storeRoot, + }, + initiative: { + id: reference.id, + title: reference.title, + root: reference.root, + }, + }; +} + +function toListedInitiativeReference( + selected: SelectedContextStore, + state: InitiativeState +): ListedInitiativeReference { + return { + ...toInitiativeViewReference(selected, state), + status: state.status, + owners: state.owners, + metadata: state.metadata, + }; +} + +async function readSelectedInitiative( + selected: SelectedContextStore, + initiativeId: string +): Promise<InitiativeState | null> { + return readInitiative({ + collection: mountInitiativesCollection(selected.root), + id: initiativeId, + }); +} + +export async function resolveSelectedInitiativeViewReference( + selected: SelectedContextStore, + initiativeId: string +): Promise<InitiativeViewReference> { + const state = await readSelectedInitiative(selected, initiativeId); + + if (!state) { + throw new InitiativeResolutionError( + `Initiative '${initiativeId}' was not found in context store '${selected.id}'.`, + 'initiative_not_found', + { + target: 'initiative.id', + fix: `openspec initiative list ${formatContextStoreSelector(selected)}`, + } + ); + } + + return toInitiativeViewReference(selected, state); +} + +export async function listSelectedInitiativeViewReferences( + selected: SelectedContextStore +): Promise<ContextStoreInitiativeListReference> { + const collection = mountInitiativesCollection(selected.root); + const initiatives = await listInitiatives({ collection }); + + return { + contextStore: selected, + initiatives: initiatives.map((initiative) => toListedInitiativeReference(selected, initiative)), + status: [], + }; +} + +interface InitiativeStoreListFound { + kind: 'listed'; + listed: ContextStoreInitiativeListReference; +} + +interface InitiativeStoreUnreadable { + kind: 'store_unreadable'; + entryId: string; + error: unknown; +} + +interface InitiativeStoreListInvalid { + kind: 'initiative_collection_invalid'; + selected: SelectedContextStore; + error: unknown; + diagnostic: InitiativeDiagnostic; +} + +type InitiativeStoreListOutcome = + | InitiativeStoreListFound + | InitiativeStoreUnreadable + | InitiativeStoreListInvalid; + +interface InitiativeStoreLookupMatch { + kind: 'match'; + selected: SelectedContextStore; + state: InitiativeState; + diagnostic: InitiativeResolutionMatch; +} + +interface InitiativeStoreLookupMissing { + kind: 'missing'; + selected: SelectedContextStore; +} + +interface InitiativeStoreInitiativeInvalid { + kind: 'initiative_invalid'; + selected: SelectedContextStore; + error: unknown; +} + +type InitiativeStoreLookupOutcome = + | InitiativeStoreLookupMatch + | InitiativeStoreLookupMissing + | InitiativeStoreUnreadable + | InitiativeStoreInitiativeInvalid; + +async function scanRegisteredStoreForInitiativeList( + entryId: string +): Promise<InitiativeStoreListOutcome> { + let selected: SelectedContextStore; + + try { + selected = await resolveRegisteredInitiativeContextStore(entryId); + } catch (error) { + return { + kind: 'store_unreadable', + entryId, + error, + }; + } + + try { + return { + kind: 'listed', + listed: await listSelectedInitiativeViewReferences(selected), + }; + } catch (error) { + return { + kind: 'initiative_collection_invalid', + selected, + error, + diagnostic: initiativeDiagnosticFromError(error), + }; + } +} + +async function scanRegisteredStoreForInitiative( + entryId: string, + initiativeId: string +): Promise<InitiativeStoreLookupOutcome> { + let selected: SelectedContextStore; + + try { + selected = await resolveRegisteredInitiativeContextStore(entryId); + } catch (error) { + return { + kind: 'store_unreadable', + entryId, + error, + }; + } + + try { + const state = await readSelectedInitiative(selected, initiativeId); + if (!state) { + return { + kind: 'missing', + selected, + }; + } + + return { + kind: 'match', + selected, + state, + diagnostic: toResolutionMatch(selected, state), + }; + } catch (error) { + return { + kind: 'initiative_invalid', + selected, + error, + }; + } +} + +async function scanRegisteredStoresForInitiativeLists(): Promise<InitiativeStoreListOutcome[]> { + const registeredStores = await listRegisteredContextStores(); + return Promise.all( + registeredStores.map((entry) => scanRegisteredStoreForInitiativeList(entry.id)) + ); +} + +async function scanRegisteredStoresForInitiative( + initiativeId: string +): Promise<InitiativeStoreLookupOutcome[]> { + const registeredStores = await listRegisteredContextStores(); + return Promise.all( + registeredStores.map((entry) => scanRegisteredStoreForInitiative(entry.id, initiativeId)) + ); +} + +export async function listInitiativeViewReferences( + options: InitiativeSelectorOptions = {} +): Promise<InitiativeListReferenceResult> { + if (options.store !== undefined || options.storePath !== undefined) { + const selected = await selectContextStoreForInitiative(options, 'list'); + const listed = await listSelectedInitiativeViewReferences(selected); + + return { + contextStore: listed.contextStore, + contextStores: [listed], + initiatives: listed.initiatives, + status: [], + }; + } + + const outcomes = await scanRegisteredStoresForInitiativeLists(); + if (outcomes.length === 0) { + return { + contextStore: null, + contextStores: [], + initiatives: [], + status: [], + }; + } + + const contextStores = outcomes + .filter((outcome): outcome is InitiativeStoreListFound => outcome.kind === 'listed') + .map((outcome) => outcome.listed); + const invalidCollections = outcomes.filter( + (outcome): outcome is InitiativeStoreListInvalid => + outcome.kind === 'initiative_collection_invalid' + ); + const unreadable = outcomes.filter( + (outcome): outcome is InitiativeStoreUnreadable => outcome.kind === 'store_unreadable' + ); + const contextStoreResults: ContextStoreInitiativeListReference[] = [ + ...contextStores, + ...invalidCollections.map((outcome) => ({ + contextStore: outcome.selected, + initiatives: [], + status: [outcome.diagnostic], + })), + ]; + + if (contextStores.length === 0 && invalidCollections.length > 0) { + throw new InitiativeResolutionError( + 'No initiatives could be read because registered context stores contain invalid initiatives.', + 'initiative_collections_invalid', + { + target: 'initiative', + fix: 'Fix the invalid initiative folder state and retry.', + } + ); + } + + if (contextStoreResults.length === 0) { + throw new InitiativeResolutionError( + 'No initiatives could be read from registered context stores.', + 'context_stores_unreadable', + { + target: 'context_store', + fix: 'openspec context-store doctor', + } + ); + } + + const status: InitiativeDiagnostic[] = []; + + if (unreadable.length > 0) { + status.push(makeDiagnostic( + 'warning', + 'context_stores_partially_unreadable', + 'Some registered context stores could not be read.', + { + target: 'context_store', + fix: 'openspec context-store doctor', + } + )); + } + + if (invalidCollections.length > 0) { + status.push(makeDiagnostic( + 'warning', + 'initiative_collections_partially_invalid', + 'Some registered context stores contain invalid initiatives.', + { + target: 'initiative', + fix: 'Fix the invalid initiative folder state and retry.', + } + )); + } + + return { + contextStore: null, + contextStores: contextStoreResults, + initiatives: contextStoreResults + .flatMap((store) => store.initiatives) + .sort((left, right) => left.store.localeCompare(right.store) || left.id.localeCompare(right.id)), + status, + }; +} + +export async function resolveInitiativeViewReference( + reference: string | undefined, + options: InitiativeSelectorOptions = {} +): Promise<InitiativeViewReference> { + const parsed = parseInitiativeReference(reference, options); + + if (parsed.options.store !== undefined || parsed.options.storePath !== undefined) { + const selected = await selectContextStoreForInitiative(parsed.options, 'show'); + return resolveSelectedInitiativeViewReference(selected, parsed.initiativeId); + } + + const outcomes = await scanRegisteredStoresForInitiative(parsed.initiativeId); + const matches = outcomes.filter( + (outcome): outcome is InitiativeStoreLookupMatch => outcome.kind === 'match' + ); + const unreadable = outcomes.filter( + (outcome): outcome is InitiativeStoreUnreadable => outcome.kind === 'store_unreadable' + ); + const invalidInitiatives = outcomes.filter( + (outcome): outcome is InitiativeStoreInitiativeInvalid => + outcome.kind === 'initiative_invalid' + ); + + if (invalidInitiatives.length > 0) { + throw invalidInitiatives[0].error; + } + + if (unreadable.length > 0) { + throw new InitiativeResolutionError( + `Initiative lookup for '${parsed.initiativeId}' is incomplete because some context stores could not be read.`, + 'initiative_lookup_incomplete', + { + target: 'context_store', + fix: 'openspec context-store doctor', + ...(matches.length > 0 + ? { details: { matches: matches.map((match) => match.diagnostic) } } + : {}), + } + ); + } + + if (matches.length === 0) { + throw new InitiativeResolutionError( + `Initiative '${parsed.initiativeId}' was not found in registered context stores.`, + 'initiative_not_found', + { + target: 'initiative.id', + fix: 'openspec initiative list', + } + ); + } + + if (matches.length > 1) { + throw new InitiativeResolutionError( + `Initiative '${parsed.initiativeId}' exists in multiple context stores.`, + 'initiative_ambiguous', + { + target: 'initiative.id', + fix: `openspec initiative show ${parsed.initiativeId} --store <store>`, + details: { matches: matches.map((match) => match.diagnostic) }, + } + ); + } + + const [match] = matches; + return toInitiativeViewReference(match.selected, match.state); +} + +export interface InitiativeLinkReference { + store: string; + id: string; +} + +export async function resolveInitiativeLinkReference( + reference: string | undefined, + options: InitiativeSelectorOptions = {} +): Promise<InitiativeLinkReference> { + const initiative = await resolveInitiativeViewReference(reference, options); + + return { + store: initiative.store, + id: initiative.id, + }; +} diff --git a/src/core/collections/initiatives/schema.ts b/src/core/collections/initiatives/schema.ts new file mode 100644 index 0000000000..423fc7103f --- /dev/null +++ b/src/core/collections/initiatives/schema.ts @@ -0,0 +1,179 @@ +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { z } from 'zod'; + +export const INITIATIVE_COLLECTION_ID = 'initiatives'; +export const INITIATIVE_FILE_NAME = 'initiative.yaml'; +export const INITIATIVE_REQUIREMENTS_FILE_NAME = 'requirements.md'; +export const INITIATIVE_DESIGN_FILE_NAME = 'design.md'; +export const INITIATIVE_DECISIONS_FILE_NAME = 'decisions.md'; +export const INITIATIVE_QUESTIONS_FILE_NAME = 'questions.md'; +export const INITIATIVE_TASKS_FILE_NAME = 'tasks.md'; + +export const INITIATIVE_MARKDOWN_FILE_NAMES = [ + INITIATIVE_REQUIREMENTS_FILE_NAME, + INITIATIVE_DESIGN_FILE_NAME, + INITIATIVE_DECISIONS_FILE_NAME, + INITIATIVE_QUESTIONS_FILE_NAME, + INITIATIVE_TASKS_FILE_NAME, +] as const; + +export const INITIATIVE_FILE_NAMES = [ + INITIATIVE_FILE_NAME, + ...INITIATIVE_MARKDOWN_FILE_NAMES, +] as const; + +export type InitiativeMarkdownFileName = typeof INITIATIVE_MARKDOWN_FILE_NAMES[number]; +export type InitiativeFileName = typeof INITIATIVE_FILE_NAMES[number]; + +export const INITIATIVE_STATUSES = [ + 'exploring', + 'active', + 'complete', + 'archived', +] as const; + +export type InitiativeStatus = typeof INITIATIVE_STATUSES[number]; + +export type InitiativeMetadataValue = + | string + | number + | boolean + | null + | InitiativeMetadataValue[] + | { [key: string]: InitiativeMetadataValue }; + +export type InitiativeMetadata = Record<string, InitiativeMetadataValue>; + +const INITIATIVE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; + +function assertNoNul(value: string, label: string): void { + if (value.includes('\0')) { + throw new Error(`${label} must not contain NUL bytes`); + } +} + +function nonBlankString(label: string): z.ZodString { + return z.string().refine((value) => value.trim().length > 0, { + message: `${label} must not be empty`, + }); +} + +const InitiativeMetadataValueSchema: z.ZodType<InitiativeMetadataValue> = z.lazy(() => + z.union([ + z.string(), + z.number().finite(), + z.boolean(), + z.null(), + z.array(InitiativeMetadataValueSchema), + z.record(z.string(), InitiativeMetadataValueSchema), + ]) +); + +const InitiativeMetadataSchema = z.record(z.string(), InitiativeMetadataValueSchema); + +const InitiativeStateSchema = z.object({ + version: z.literal(1), + id: z.string(), + title: nonBlankString('title'), + summary: nonBlankString('summary'), + status: z.enum(INITIATIVE_STATUSES), + created: z.string().regex(INITIATIVE_DATE_PATTERN, { + message: 'created must be YYYY-MM-DD format', + }), + owners: z.array(nonBlankString('owner')).default([]), + metadata: InitiativeMetadataSchema.default({}), +}).strict(); + +export type InitiativeStateInput = z.input<typeof InitiativeStateSchema>; +export type InitiativeState = z.output<typeof InitiativeStateSchema>; + +function formatZodIssues(error: z.ZodError): string { + return error.issues + .map((issue) => { + const location = issue.path.length > 0 ? issue.path.join('.') : 'root'; + return `${location}: ${issue.message}`; + }) + .join('; '); +} + +function parseYamlObject(content: string, label: string): unknown { + try { + return parseYaml(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ${label}: ${message}`); + } +} + +export function validateInitiativeId(id: string): string { + assertNoNul(id, 'Initiative id'); + + if (id.length === 0) { + throw new Error('Initiative id must not be empty'); + } + + if (id === '.' || id === '..') { + throw new Error(`Initiative id must not be '${id}'`); + } + + if (/[\\/]/u.test(id)) { + throw new Error('Initiative id must not contain path separators'); + } + + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) { + throw new Error( + 'Initiative id must be kebab-case with lowercase letters, numbers, and single hyphen separators' + ); + } + + return id; +} + +export function isValidInitiativeId(id: string): boolean { + try { + validateInitiativeId(id); + return true; + } catch { + return false; + } +} + +function parseInitiativeStateInput(raw: unknown): InitiativeState { + const result = InitiativeStateSchema.safeParse(raw); + + if (!result.success) { + throw new Error(`Invalid initiative state: ${formatZodIssues(result.error)}`); + } + + validateInitiativeId(result.data.id); + + return { + version: 1, + id: result.data.id, + title: result.data.title, + summary: result.data.summary, + status: result.data.status, + created: result.data.created, + owners: result.data.owners, + metadata: result.data.metadata, + }; +} + +export function parseInitiativeState(content: string): InitiativeState { + return parseInitiativeStateInput(parseYamlObject(content, 'initiative state')); +} + +export function serializeInitiativeState(state: InitiativeStateInput): string { + const parsedState = parseInitiativeStateInput(state); + + return stringifyYaml({ + version: 1, + id: parsedState.id, + title: parsedState.title, + summary: parsedState.summary, + status: parsedState.status, + created: parsedState.created, + owners: parsedState.owners, + metadata: parsedState.metadata, + }); +} diff --git a/src/core/collections/initiatives/templates.ts b/src/core/collections/initiatives/templates.ts new file mode 100644 index 0000000000..c125179f21 --- /dev/null +++ b/src/core/collections/initiatives/templates.ts @@ -0,0 +1,111 @@ +import { + INITIATIVE_DECISIONS_FILE_NAME, + INITIATIVE_DESIGN_FILE_NAME, + INITIATIVE_MARKDOWN_FILE_NAMES, + INITIATIVE_QUESTIONS_FILE_NAME, + INITIATIVE_REQUIREMENTS_FILE_NAME, + INITIATIVE_TASKS_FILE_NAME, + type InitiativeMarkdownFileName, + type InitiativeState, +} from './schema.js'; + +export interface InitiativeTemplateFile { + fileName: InitiativeMarkdownFileName; + content: string; +} + +function withTrailingNewline(content: string): string { + return content.endsWith('\n') ? content : `${content}\n`; +} + +export function buildInitiativeRequirementsTemplate(state: InitiativeState): string { + return withTrailingNewline(`# Requirements + +## Product Intent + +${state.summary} + +## Accepted Requirements + +- TBD + +## Out Of Scope + +- TBD +`); +} + +export function buildInitiativeDesignTemplate(state: InitiativeState): string { + return withTrailingNewline(`# Design + +## Context + +${state.summary} + +## Approach + +TBD + +## Affected Areas + +- TBD + +## Dependencies + +- TBD + +## Risks + +- TBD +`); +} + +export function buildInitiativeDecisionsTemplate(state: InitiativeState): string { + return withTrailingNewline(`# Decisions + +## Accepted Decisions + +### ${state.created}: ${state.title} + +- Decision: TBD +- Why: TBD +- Implications: TBD +`); +} + +export function buildInitiativeQuestionsTemplate(): string { + return withTrailingNewline(`# Questions + +## Open Questions + +- TBD + +## Resolved Questions + +- TBD +`); +} + +export function buildInitiativeTasksTemplate(): string { + return withTrailingNewline(`# Tasks + +## Coordination Tasks + +- [ ] TBD +`); +} + +export function buildDefaultInitiativeFiles(state: InitiativeState): InitiativeTemplateFile[] { + const templates: Record<InitiativeMarkdownFileName, string> = { + [INITIATIVE_REQUIREMENTS_FILE_NAME]: buildInitiativeRequirementsTemplate(state), + [INITIATIVE_DESIGN_FILE_NAME]: buildInitiativeDesignTemplate(state), + [INITIATIVE_DECISIONS_FILE_NAME]: buildInitiativeDecisionsTemplate(state), + [INITIATIVE_QUESTIONS_FILE_NAME]: buildInitiativeQuestionsTemplate(), + [INITIATIVE_TASKS_FILE_NAME]: buildInitiativeTasksTemplate(), + }; + + return INITIATIVE_MARKDOWN_FILE_NAMES.map((fileName) => ({ + fileName, + content: templates[fileName], + })); +} diff --git a/src/core/collections/runtime.ts b/src/core/collections/runtime.ts new file mode 100644 index 0000000000..708729c290 --- /dev/null +++ b/src/core/collections/runtime.ts @@ -0,0 +1,316 @@ +import * as path from 'node:path'; + +import { FileSystemUtils } from '../../utils/file-system.js'; + +export type CollectionMetadata = Readonly<Record<string, unknown>>; +export type CollectionHooks = Readonly<Record<string, unknown>>; + +export interface CollectionDefinition<THandle = unknown> { + id: string; + mount: string; + metadata?: CollectionMetadata; + hooks?: CollectionHooks; + createHandle?: (context: MountedCollectionContext) => THandle; +} + +export interface CollectionRegistry { + list(): readonly CollectionDefinition[]; + get<THandle = unknown>(collectionId: string): CollectionDefinition<THandle> | undefined; + require<THandle = unknown>(collectionId: string): CollectionDefinition<THandle>; +} + +export interface MountedCollectionContext { + storeRoot: string; + collectionId: string; + mount: string; + mountRoot: string; + resolvePath(relativePath?: string): string; + toStorePath(relativePath?: string): string; +} + +export interface MountedCollection<THandle = unknown> { + collectionId: string; + mount: string; + mountRoot: string; + context: MountedCollectionContext; + handle: THandle | undefined; + resolvePath(relativePath?: string): string; + toStorePath(relativePath?: string): string; +} + +export interface MountedCollectionRegistry { + list(): readonly MountedCollection[]; + get<THandle = unknown>(collectionId: string): MountedCollection<THandle> | undefined; + require<THandle = unknown>(collectionId: string): MountedCollection<THandle>; +} + +export interface MountCollectionsInput { + storeRoot: string; + collections: CollectionRegistry; +} + +function assertNoNul(value: string, label: string): void { + if (value.includes('\0')) { + throw new Error(`${label} must not contain NUL bytes`); + } +} + +function validateKebabSegment(value: string, label: string): string { + assertNoNul(value, label); + + if (value.length === 0) { + throw new Error(`${label} must not be empty`); + } + + if (value === '.' || value === '..') { + throw new Error(`${label} must not be '${value}'`); + } + + if (/[\\/]/u.test(value)) { + throw new Error(`${label} must not contain path separators`); + } + + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value)) { + throw new Error( + `${label} must be kebab-case with lowercase letters, numbers, and single hyphen separators` + ); + } + + return value; +} + +export function validateCollectionId(id: string): string { + return validateKebabSegment(id, 'Collection id'); +} + +export function validateMount(mount: string): string { + assertNoNul(mount, 'Collection mount'); + + if (mount.startsWith('.')) { + throw new Error(`Collection mount '${mount}' is reserved`); + } + + return validateKebabSegment(mount, 'Collection mount'); +} + +function isWindowsDrivePath(value: string): boolean { + return /^[A-Za-z]:/u.test(value); +} + +function isUncPath(value: string): boolean { + return value.startsWith('\\\\') || value.startsWith('//'); +} + +export function parseCollectionPath(input = ''): string { + assertNoNul(input, 'Collection path'); + + if (input.length === 0) { + return ''; + } + + if (input.includes('\\')) { + throw new Error('Collection path must use forward slashes'); + } + + if (isWindowsDrivePath(input)) { + throw new Error('Collection path must not be a Windows drive path'); + } + + if (isUncPath(input) || path.posix.isAbsolute(input)) { + throw new Error('Collection path must be relative'); + } + + const segments = input.split('/'); + + for (const segment of segments) { + if (segment.length === 0) { + throw new Error('Collection path must not contain empty segments'); + } + + if (segment === '.' || segment === '..') { + throw new Error('Collection path must not contain dot segments'); + } + } + + return segments.join('/'); +} + +function compareCollectionDefinitions( + a: CollectionDefinition, + b: CollectionDefinition +): number { + return a.id.localeCompare(b.id); +} + +export function createCollectionRegistry( + definitions: readonly CollectionDefinition[] +): CollectionRegistry { + const byId = new Map<string, CollectionDefinition>(); + const mountOwners = new Map<string, string>(); + + for (const definition of definitions) { + const id = validateCollectionId(definition.id); + const mount = validateMount(definition.mount); + + if (byId.has(id)) { + throw new Error(`Duplicate collection id '${id}'`); + } + + const existingMountOwner = mountOwners.get(mount); + if (existingMountOwner) { + throw new Error( + `Duplicate collection mount '${mount}' for '${existingMountOwner}' and '${id}'` + ); + } + + const normalizedDefinition = { + ...definition, + id, + mount, + }; + + byId.set(id, normalizedDefinition); + mountOwners.set(mount, id); + } + + const sortedDefinitions = Array.from(byId.values()).sort(compareCollectionDefinitions); + + return { + list() { + return [...sortedDefinitions]; + }, + + get<THandle = unknown>(collectionId: string): CollectionDefinition<THandle> | undefined { + const id = validateCollectionId(collectionId); + return byId.get(id) as CollectionDefinition<THandle> | undefined; + }, + + require<THandle = unknown>(collectionId: string): CollectionDefinition<THandle> { + const definition = this.get<THandle>(collectionId); + + if (!definition) { + throw new Error(`Unknown collection '${collectionId}'`); + } + + return definition; + }, + }; +} + +function isWindowsLikePath(candidatePath: string): boolean { + return /^[A-Za-z]:[\\/]/u.test(candidatePath) || candidatePath.startsWith('\\\\'); +} + +function relativePath(fromPath: string, toPath: string): string { + if (isWindowsLikePath(fromPath) || isWindowsLikePath(toPath)) { + return path.win32.relative(path.win32.normalize(fromPath), path.win32.normalize(toPath)); + } + + return path.posix.relative(fromPath.replace(/\\/g, '/'), toPath.replace(/\\/g, '/')); +} + +function isRelativePathAbsolute(value: string, windowsLike: boolean): boolean { + return windowsLike ? path.win32.isAbsolute(value) : path.posix.isAbsolute(value); +} + +function isSameOrDescendant(rootPath: string, candidatePath: string): boolean { + const windowsLike = isWindowsLikePath(rootPath) || isWindowsLikePath(candidatePath); + const relative = relativePath(rootPath, candidatePath); + const escapesRoot = /^\.\.(?:[\\/]|$)/u.test(relative); + + return ( + relative === '' || + (!escapesRoot && !isRelativePathAbsolute(relative, windowsLike)) + ); +} + +function getMountRoot(storeRoot: string, mount: string): string { + return FileSystemUtils.joinPath(storeRoot, validateMount(mount)); +} + +function resolvePathInsideMount(mountRoot: string, relativePath?: string): string { + const collectionPath = parseCollectionPath(relativePath); + const resolvedPath = collectionPath.length > 0 + ? FileSystemUtils.joinPath(mountRoot, collectionPath) + : mountRoot; + + if (!isSameOrDescendant(mountRoot, resolvedPath)) { + throw new Error(`Collection path escapes mount: ${relativePath ?? ''}`); + } + + return resolvedPath; +} + +function toStorePath(mount: string, relativePath?: string): string { + const collectionPath = parseCollectionPath(relativePath); + return collectionPath.length > 0 + ? `${validateMount(mount)}/${collectionPath}` + : validateMount(mount); +} + +function createMountedCollection<THandle>( + storeRoot: string, + definition: CollectionDefinition<THandle> +): MountedCollection<THandle> { + const mountRoot = getMountRoot(storeRoot, definition.mount); + const resolveMountedPath = (relativePath?: string) => + resolvePathInsideMount(mountRoot, relativePath); + const resolveStorePath = (relativePath?: string) => toStorePath(definition.mount, relativePath); + + const context: MountedCollectionContext = { + storeRoot, + collectionId: definition.id, + mount: definition.mount, + mountRoot, + resolvePath: resolveMountedPath, + toStorePath: resolveStorePath, + }; + + return { + collectionId: definition.id, + mount: definition.mount, + mountRoot, + context, + handle: definition.createHandle?.(context), + resolvePath: resolveMountedPath, + toStorePath: resolveStorePath, + }; +} + +export function mountCollections(input: MountCollectionsInput): MountedCollectionRegistry { + if (input.storeRoot.length === 0) { + throw new Error('Context store root must not be empty'); + } + + const byId = new Map<string, MountedCollection>(); + + for (const definition of input.collections.list()) { + const mountedCollection = createMountedCollection(input.storeRoot, definition); + byId.set(mountedCollection.collectionId, mountedCollection); + } + + const sortedCollections = Array.from(byId.values()).sort((a, b) => + a.collectionId.localeCompare(b.collectionId) + ); + + return { + list() { + return [...sortedCollections]; + }, + + get<THandle = unknown>(collectionId: string): MountedCollection<THandle> | undefined { + const id = validateCollectionId(collectionId); + return byId.get(id) as MountedCollection<THandle> | undefined; + }, + + require<THandle = unknown>(collectionId: string): MountedCollection<THandle> { + const mountedCollection = this.get<THandle>(collectionId); + + if (!mountedCollection) { + throw new Error(`Unknown mounted collection '${collectionId}'`); + } + + return mountedCollection; + }, + }; +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 9b629b5f75..85c05d08bc 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -1,49 +1,28 @@ -import { CommandDefinition, FlagDefinition } from './types.js'; - -/** - * Common flags used across multiple commands - */ -const COMMON_FLAGS = { - json: { - name: 'json', - description: 'Output as JSON', - } as FlagDefinition, - jsonValidation: { - name: 'json', - description: 'Output validation results as JSON', - } as FlagDefinition, - strict: { - name: 'strict', - description: 'Enable strict validation mode', - } as FlagDefinition, - noInteractive: { - name: 'no-interactive', - description: 'Disable interactive prompts', - } as FlagDefinition, - type: { - name: 'type', - description: 'Specify item type when ambiguous', - takesValue: true, - values: ['change', 'spec'], - } as FlagDefinition, -} as const; - -/** - * Registry of all OpenSpec CLI commands with their flags and metadata. - * This registry is used to generate shell completion scripts. - */ +import { COMMON_FLAGS } from './shared-flags.js'; +import type { CommandDefinition } from './types.js'; export const COMMAND_REGISTRY: CommandDefinition[] = [ { name: 'init', description: 'Initialize OpenSpec in your project', acceptsPositional: true, positionalType: 'path', + positionals: [{ name: 'path', type: 'path', optional: true }], flags: [ { name: 'tools', description: 'Configure AI tools non-interactively (e.g., "all", "none", or comma-separated tool IDs)', takesValue: true, }, + { + name: 'force', + description: 'Auto-cleanup legacy files without prompting', + }, + { + name: 'profile', + description: 'Override global config profile (core or custom)', + takesValue: true, + values: ['core', 'custom'], + }, ], }, { @@ -51,7 +30,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Update OpenSpec instruction files', acceptsPositional: true, positionalType: 'path', - flags: [], + positionals: [{ name: 'path', type: 'path', optional: true }], + flags: [ + { + name: 'force', + description: 'Force update even when tools are up to date', + }, + ], }, { name: 'list', @@ -65,6 +50,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'changes', description: 'List changes explicitly (default)', }, + { + name: 'sort', + description: 'Sort order: "recent" (default) or "name"', + takesValue: true, + values: ['recent', 'name'], + }, + COMMON_FLAGS.json, ], }, { @@ -77,6 +69,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Validate changes and specs', acceptsPositional: true, positionalType: 'change-or-spec-id', + positionals: [{ name: 'item-name', type: 'change-or-spec-id', optional: true }], flags: [ { name: 'all', @@ -106,6 +99,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Show a change or spec', acceptsPositional: true, positionalType: 'change-or-spec-id', + positionals: [{ name: 'item-name', type: 'change-or-spec-id', optional: true }], flags: [ COMMON_FLAGS.json, COMMON_FLAGS.type, @@ -139,6 +133,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Archive a completed change and update main specs', acceptsPositional: true, positionalType: 'change-id', + positionals: [{ name: 'change-name', type: 'change-id', optional: true }], flags: [ { name: 'yes', @@ -155,6 +150,144 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, ], }, + { + name: 'status', + description: 'Display artifact completion status for a change', + flags: [ + { + name: 'change', + description: 'Change name to show status for', + takesValue: true, + }, + { + name: 'schema', + description: 'Schema override', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'instructions', + description: 'Output enriched instructions for creating an artifact or applying tasks', + acceptsPositional: true, + positionals: [{ name: 'artifact', optional: true }], + flags: [ + { + name: 'change', + description: 'Change name', + takesValue: true, + }, + { + name: 'schema', + description: 'Schema override', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'templates', + description: 'Show resolved template paths for all artifacts in a schema', + flags: [ + { + name: 'schema', + description: 'Schema to use', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'schemas', + description: 'List available workflow schemas with descriptions', + flags: [ + COMMON_FLAGS.json, + ], + }, + { + name: 'new', + description: 'Create new items', + flags: [], + subcommands: [ + { + name: 'change', + description: 'Create a new change directory', + acceptsPositional: true, + positionals: [{ name: 'name' }], + flags: [ + { + name: 'description', + description: 'Description to add to README.md', + takesValue: true, + }, + { + name: 'goal', + description: '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', + takesValue: true, + }, + { + name: 'schema', + description: 'Workflow schema to use', + 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, + ], + }, + ], + }, { name: 'workspace', description: 'Set up and inspect coordination workspaces', @@ -208,20 +341,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Link an existing repo or folder to a workspace', acceptsPositional: true, positionals: [ - { - name: 'name-or-path', - type: 'path', - optional: true, - }, - { - name: 'path', - type: 'path', - }, + { name: 'name-or-path', type: 'path', optional: true }, + { name: 'path', type: 'path', optional: true }, ], flags: [ { name: 'workspace', - description: 'Workspace name from the local workspace registry', + description: 'Workspace name from local workspace views', takesValue: true, }, COMMON_FLAGS.json, @@ -233,18 +359,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Update the local path for an existing workspace link', acceptsPositional: true, positionals: [ - { - name: 'name', - }, - { - name: 'path', - type: 'path', - }, + { name: 'name' }, + { name: 'path', type: 'path' }, ], flags: [ { name: 'workspace', - description: 'Workspace name from the local workspace registry', + description: 'Workspace name from local workspace views', takesValue: true, }, COMMON_FLAGS.json, @@ -257,7 +378,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ flags: [ { name: 'workspace', - description: 'Workspace name from the local workspace registry', + description: 'Workspace name from local workspace views', takesValue: true, }, COMMON_FLAGS.json, @@ -266,18 +387,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, { name: 'update', - description: 'Refresh workspace-local OpenSpec agent skills from the active global profile', + description: 'Refresh workspace-local OpenSpec guidance and agent skills', acceptsPositional: true, - positionals: [ - { - name: 'name', - optional: true, - }, - ], + positionals: [{ name: 'name', optional: true }], flags: [ { name: 'workspace', - description: 'Workspace name from the local workspace registry', + description: 'Workspace name from local workspace views', takesValue: true, }, { @@ -293,16 +409,26 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'open', description: 'Open a workspace in an agent or VS Code editor', acceptsPositional: true, - positionals: [ - { - name: 'name', - optional: true, - }, - ], + positionals: [{ name: 'name', optional: true }], flags: [ { name: 'workspace', - description: 'Workspace name from the local workspace registry', + description: 'Workspace name from local workspace views', + takesValue: true, + }, + { + name: 'initiative', + description: 'Open an initiative as a local workspace view', + 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, }, { @@ -315,15 +441,181 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'editor', description: 'Open the workspace in VS Code editor mode', }, + { + name: 'prepare-only', + description: 'Unsupported: preview surfaces belong to a future context/query command', + }, + COMMON_FLAGS.json, + { + name: 'change', + description: 'Unsupported: change-scoped open belongs to future workspace change planning', + takesValue: true, + }, COMMON_FLAGS.noInteractive, ], }, ], }, + { + name: 'context-store', + description: 'Set up and inspect context stores', + flags: [], + subcommands: [ + { + name: 'setup', + description: 'Create or register a local context store', + acceptsPositional: true, + positionals: [{ name: 'id', optional: true }], + flags: [ + { + name: 'path', + description: 'Directory to use for the context store', + takesValue: true, + }, + { + name: 'init-git', + description: 'Initialize a Git repository in the context store', + }, + { + name: 'no-init-git', + description: 'Skip Git repository initialization', + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'register', + description: 'Register an existing context store directory', + acceptsPositional: true, + positionals: [{ name: 'path', type: 'path', optional: true }], + flags: [ + { + name: 'id', + description: 'Context store id', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'list', + description: 'List registered context stores', + flags: [ + COMMON_FLAGS.json, + ], + }, + { + name: 'ls', + description: 'List registered context stores', + flags: [ + COMMON_FLAGS.json, + ], + }, + { + name: 'doctor', + description: 'Check local context-store registration and metadata', + acceptsPositional: true, + positionals: [{ name: 'id', optional: true }], + flags: [ + COMMON_FLAGS.json, + ], + }, + ], + }, + { + name: 'initiative', + description: 'Create and list coordinated initiatives', + flags: [], + subcommands: [ + { + name: 'create', + description: 'Create an initiative in a context store', + acceptsPositional: true, + positionals: [{ name: 'id', optional: true }], + flags: [ + { + name: 'store', + description: 'Context store id from the local context-store registry', + takesValue: true, + }, + { + name: 'store-path', + description: 'Existing local context store root', + takesValue: true, + }, + { + name: 'title', + description: 'Initiative title', + takesValue: true, + }, + { + name: 'summary', + description: 'Initiative summary', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'show', + description: 'Show where an initiative lives and how to read it', + acceptsPositional: true, + positionals: [{ name: 'id' }], + flags: [ + { + name: 'store', + description: 'Context store id from the local context-store registry', + takesValue: true, + }, + { + name: 'store-path', + description: 'Existing local context store root', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'list', + description: 'List initiatives across registered context stores', + flags: [ + { + name: 'store', + description: 'Context store id from the local context-store registry', + takesValue: true, + }, + { + name: 'store-path', + description: 'Existing local context store root', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'ls', + description: 'List initiatives across registered context stores', + flags: [ + { + name: 'store', + description: 'Context store id from the local context-store registry', + takesValue: true, + }, + { + name: 'store-path', + description: 'Existing local context store root', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, + ], + }, { name: 'feedback', description: 'Submit feedback about OpenSpec', acceptsPositional: true, + positionals: [{ name: 'message' }], flags: [ { name: 'body', @@ -342,6 +634,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Show a change proposal', acceptsPositional: true, positionalType: 'change-id', + positionals: [{ name: 'change-name', type: 'change-id', optional: true }], flags: [ COMMON_FLAGS.json, { @@ -371,6 +664,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Validate a change proposal', acceptsPositional: true, positionalType: 'change-id', + positionals: [{ name: 'change-name', type: 'change-id', optional: true }], flags: [ COMMON_FLAGS.strict, COMMON_FLAGS.jsonValidation, @@ -389,6 +683,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Show a specification', acceptsPositional: true, positionalType: 'spec-id', + positionals: [{ name: 'spec-id', type: 'spec-id', optional: true }], flags: [ COMMON_FLAGS.json, { @@ -424,6 +719,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Validate a specification', acceptsPositional: true, positionalType: 'spec-id', + positionals: [{ name: 'spec-id', type: 'spec-id', optional: true }], flags: [ COMMON_FLAGS.strict, COMMON_FLAGS.jsonValidation, @@ -442,6 +738,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Generate completion script for a shell (outputs to stdout)', acceptsPositional: true, positionalType: 'shell', + positionals: [{ name: 'shell', type: 'shell', optional: true }], flags: [], }, { @@ -449,6 +746,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Install completion script for a shell', acceptsPositional: true, positionalType: 'shell', + positionals: [{ name: 'shell', type: 'shell', optional: true }], flags: [ { name: 'verbose', @@ -461,6 +759,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Uninstall completion script for a shell', acceptsPositional: true, positionalType: 'shell', + positionals: [{ name: 'shell', type: 'shell', optional: true }], flags: [ { name: 'yes', @@ -499,12 +798,14 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'get', description: 'Get a specific value (raw, scriptable)', acceptsPositional: true, + positionals: [{ name: 'key' }], flags: [], }, { name: 'set', description: 'Set a value (auto-coerce types)', acceptsPositional: true, + positionals: [{ name: 'key' }, { name: 'value' }], flags: [ { name: 'string', @@ -520,6 +821,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'unset', description: 'Remove a key (revert to default)', acceptsPositional: true, + positionals: [{ name: 'key' }], flags: [], }, { @@ -545,6 +847,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ { name: 'profile', description: 'Configure workflow profile (interactive picker or preset shortcut)', + acceptsPositional: true, + positionals: [{ name: 'preset', optional: true }], flags: [], }, ], @@ -559,6 +863,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Show where a schema resolves from', acceptsPositional: true, positionalType: 'schema-name', + positionals: [{ name: 'name', type: 'schema-name', optional: true }], flags: [ COMMON_FLAGS.json, { @@ -572,6 +877,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Validate a schema structure and templates', acceptsPositional: true, positionalType: 'schema-name', + positionals: [{ name: 'name', type: 'schema-name', optional: true }], flags: [ COMMON_FLAGS.json, { @@ -585,6 +891,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Copy an existing schema to project for customization', acceptsPositional: true, positionalType: 'schema-name', + positionals: [ + { name: 'source', type: 'schema-name' }, + { name: 'name', optional: true }, + ], flags: [ COMMON_FLAGS.json, { @@ -597,6 +907,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'init', description: 'Create a new project-local schema', acceptsPositional: true, + positionals: [{ name: 'name' }], flags: [ COMMON_FLAGS.json, { diff --git a/src/core/completions/shared-flags.ts b/src/core/completions/shared-flags.ts new file mode 100644 index 0000000000..1ff64b297c --- /dev/null +++ b/src/core/completions/shared-flags.ts @@ -0,0 +1,29 @@ +import type { FlagDefinition } from './types.js'; + +/** + * Common flags used across multiple commands. + */ +export const COMMON_FLAGS = { + json: { + name: 'json', + description: 'Output as JSON', + } as FlagDefinition, + jsonValidation: { + name: 'json', + description: 'Output validation results as JSON', + } as FlagDefinition, + strict: { + name: 'strict', + description: 'Enable strict validation mode', + } as FlagDefinition, + noInteractive: { + name: 'no-interactive', + description: 'Disable interactive prompts', + } as FlagDefinition, + type: { + name: 'type', + description: 'Specify item type when ambiguous', + takesValue: true, + values: ['change', 'spec'], + } as FlagDefinition, +} as const; diff --git a/src/core/context-store/binding.ts b/src/core/context-store/binding.ts new file mode 100644 index 0000000000..f0aae28c55 --- /dev/null +++ b/src/core/context-store/binding.ts @@ -0,0 +1,334 @@ +import { + getContextStoreMetadataPath, + readOptionalContextStoreMetadataState, + resolveGitContextStoreBackendConfig, + validateContextStoreId, + type ContextStorePathOptions, +} from './foundation.js'; +import { ContextStoreError } from './errors.js'; +import { + resolveRegisteredContextStore, + type ResolvedContextStore, +} from './registry.js'; + +export type ContextStoreSelector = + | { + kind: 'registry'; + id: string; + } + | { + kind: 'path'; + path: string; + observed_id?: string; + }; + +export type ContextStoreSelectorSource = 'registry' | 'path'; + +export interface ContextStoreSelectorOptions { + store?: string; + storePath?: string; +} + +export interface SelectedContextStore { + id: string; + root: string; + source: ContextStoreSelectorSource; +} + +export interface ContextStoreBinding { + id: string; + selector: ContextStoreSelector; +} + +export interface ContextStoreBindingWarning { + code: string; + message: string; + target?: string; + fix?: string; +} + +export interface ResolvedContextStoreBinding { + binding: ContextStoreBinding; + id: string; + root: string; + source: 'registry' | 'path'; + registered?: ResolvedContextStore; + warnings: ContextStoreBindingWarning[]; +} + +export function createRegisteredContextStoreBinding(id: string): ContextStoreBinding { + const validatedId = validateContextStoreId(id); + + return { + id: validatedId, + selector: { + kind: 'registry', + id: validatedId, + }, + }; +} + +export function createPathContextStoreBinding(input: { + id: string; + path: string; +}): ContextStoreBinding { + const id = validateContextStoreId(input.id); + + if (input.path.length === 0) { + throw new Error('Context store binding path must not be empty.'); + } + + return { + id, + selector: { + kind: 'path', + path: input.path, + observed_id: id, + }, + }; +} + +export function normalizeContextStoreBinding(binding: ContextStoreBinding): ContextStoreBinding { + const id = validateContextStoreId(binding.id); + + if (binding.selector.kind === 'registry') { + return createRegisteredContextStoreBinding(binding.selector.id); + } + + if (binding.selector.path.length === 0) { + throw new Error('Context store binding path must not be empty.'); + } + + return { + id, + selector: { + kind: 'path', + path: binding.selector.path, + ...(binding.selector.observed_id + ? { observed_id: validateContextStoreId(binding.selector.observed_id) } + : {}), + }, + }; +} + +export function sameContextStoreBinding( + left: ContextStoreBinding, + right: ContextStoreBinding +): boolean { + const normalizedLeft = normalizeContextStoreBinding(left); + const normalizedRight = normalizeContextStoreBinding(right); + + if (normalizedLeft.selector.kind !== normalizedRight.selector.kind) { + return false; + } + + if ( + normalizedLeft.selector.kind === 'registry' && + normalizedRight.selector.kind === 'registry' + ) { + return normalizedLeft.selector.id === normalizedRight.selector.id; + } + + if ( + normalizedLeft.selector.kind === 'path' && + normalizedRight.selector.kind === 'path' + ) { + return normalizedLeft.selector.path === normalizedRight.selector.path; + } + + return false; +} + +export function formatContextStoreBinding(binding: ContextStoreBinding): string { + const normalized = normalizeContextStoreBinding(binding); + + if (normalized.selector.kind === 'registry') { + return normalized.selector.id; + } + + return `${normalized.id} via ${normalized.selector.path}`; +} + +export function formatContextStoreBindingSelector(binding: ContextStoreBinding): string { + const normalized = normalizeContextStoreBinding(binding); + + return normalized.selector.kind === 'registry' + ? `--store ${normalized.selector.id}` + : `--store-path ${normalized.selector.path}`; +} + +export function formatContextStoreSelector(selected: SelectedContextStore): string { + return selected.source === 'registry' + ? `--store ${selected.id}` + : `--store-path ${selected.root}`; +} + +export function createContextStoreBindingFromSelected( + selected: SelectedContextStore +): ContextStoreBinding { + return selected.source === 'registry' + ? createRegisteredContextStoreBinding(selected.id) + : createPathContextStoreBinding({ + id: selected.id, + path: selected.root, + }); +} + +function validateSelectorConflict( + options: ContextStoreSelectorOptions, + commandName: string +): void { + if (options.store !== undefined && options.storePath !== undefined) { + throw new ContextStoreError( + 'Pass either --store <id> or --store-path <path>, not both.', + 'context_store_selector_conflict', + { + target: 'context_store', + fix: `openspec ${commandName} --store <id>`, + } + ); + } +} + +export function requireContextStoreSelector( + options: ContextStoreSelectorOptions, + commandName: string +): void { + validateSelectorConflict(options, commandName); + + if (options.store === undefined && options.storePath === undefined) { + throw new ContextStoreError( + 'Pass --store <id> or --store-path <path>.', + 'context_store_required', + { + target: 'context_store', + fix: `openspec ${commandName} --store <id>`, + } + ); + } +} + +export async function resolveSelectedContextStore( + options: ContextStoreSelectorOptions, + commandName: string, + pathOptions: ContextStorePathOptions = {} +): Promise<SelectedContextStore> { + requireContextStoreSelector(options, commandName); + + if (options.store !== undefined) { + const resolved = await resolveRegisteredContextStore({ + id: options.store, + globalDataDir: pathOptions.globalDataDir, + }); + + return { + id: resolved.id, + root: resolved.storeRoot, + source: 'registry', + }; + } + + const storePath = options.storePath ?? ''; + let root: string; + + try { + const backend = await resolveGitContextStoreBackendConfig({ + localPath: storePath, + }); + root = backend.local_path; + } catch (error) { + throw new ContextStoreError( + error instanceof Error ? error.message : String(error), + 'invalid_context_store_path', + { + target: 'context_store.path', + fix: 'Pass an existing context store root.', + } + ); + } + + let metadata: Awaited<ReturnType<typeof readOptionalContextStoreMetadataState>>; + + try { + metadata = await readOptionalContextStoreMetadataState(root); + } catch (error) { + throw new ContextStoreError( + error instanceof Error ? error.message : String(error), + 'invalid_context_store_metadata', + { + target: 'context_store.metadata', + fix: `Fix ${getContextStoreMetadataPath(root)} before using this store.`, + } + ); + } + + if (!metadata) { + throw new ContextStoreError( + `Context store metadata not found at ${getContextStoreMetadataPath(root)}`, + 'context_store_metadata_not_found', + { + target: 'context_store.metadata', + fix: 'Pass a context store root that contains .openspec-store/store.yaml.', + } + ); + } + + return { + id: metadata.id, + root, + source: 'path', + }; +} + +export async function resolveContextStoreBinding( + binding: ContextStoreBinding, + options: ContextStorePathOptions = {} +): Promise<ResolvedContextStoreBinding> { + const normalized = normalizeContextStoreBinding(binding); + + if (normalized.selector.kind === 'registry') { + const registered = await resolveRegisteredContextStore({ + id: normalized.selector.id, + globalDataDir: options.globalDataDir, + }); + + return { + binding: normalized, + id: registered.id, + root: registered.storeRoot, + source: 'registry', + registered, + warnings: [], + }; + } + + const backend = await resolveGitContextStoreBackendConfig({ + localPath: normalized.selector.path, + }); + const root = backend.local_path; + const metadata = await readOptionalContextStoreMetadataState(root); + + if (!metadata) { + throw new Error(`Context store metadata not found at ${getContextStoreMetadataPath(root)}`); + } + + const warnings: ContextStoreBindingWarning[] = []; + const observedId = normalized.selector.observed_id ?? normalized.id; + + if (metadata.id !== observedId) { + warnings.push({ + code: 'context_store_binding_id_changed', + message: `Context store at ${root} now reports id '${metadata.id}' instead of '${observedId}'.`, + target: 'metadata.id', + fix: `Review ${getContextStoreMetadataPath(root)} or re-open the workspace with the intended context store.`, + }); + } + + return { + binding: normalized, + id: metadata.id, + root, + source: 'path', + warnings, + }; +} diff --git a/src/core/context-store/errors.ts b/src/core/context-store/errors.ts new file mode 100644 index 0000000000..708e23e731 --- /dev/null +++ b/src/core/context-store/errors.ts @@ -0,0 +1,42 @@ +export type ContextStoreDiagnosticSeverity = 'error' | 'warning'; + +export interface ContextStoreDiagnostic { + severity: ContextStoreDiagnosticSeverity; + code: string; + message: string; + target?: string; + fix?: string; +} + +export class ContextStoreError extends Error { + readonly diagnostic: ContextStoreDiagnostic; + + constructor( + message: string, + code: string, + options: { target?: string; fix?: string } = {} + ) { + super(message); + this.name = 'ContextStoreError'; + this.diagnostic = { + severity: 'error', + code, + message, + ...options, + }; + } +} + +export function makeContextStoreDiagnostic( + severity: ContextStoreDiagnosticSeverity, + code: string, + message: string, + options: { target?: string; fix?: string } = {} +): ContextStoreDiagnostic { + return { + severity, + code, + message, + ...options, + }; +} diff --git a/src/core/context-store/foundation.ts b/src/core/context-store/foundation.ts new file mode 100644 index 0000000000..0ce78d39d6 --- /dev/null +++ b/src/core/context-store/foundation.ts @@ -0,0 +1,479 @@ +import * as nodeFs from 'node:fs'; +import * as path from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { z } from 'zod'; + +import { getGlobalDataDir } from '../global-config.js'; +import { FileSystemUtils } from '../../utils/file-system.js'; +import { ContextStoreError } from './errors.js'; + +const fs = nodeFs.promises; + +export const CONTEXT_STORE_METADATA_DIR_NAME = '.openspec-store'; +export const CONTEXT_STORE_METADATA_FILE_NAME = 'store.yaml'; +export const CONTEXT_STORES_DIR_NAME = 'context-stores'; +export const CONTEXT_STORE_REGISTRY_FILE_NAME = 'registry.yaml'; + +export interface ContextStorePathOptions { + globalDataDir?: string; +} + +export interface ContextStoreGitBackendConfig { + type: 'git'; + local_path: string; + remote?: string; + branch?: string; +} + +export type ContextStoreBackendConfig = ContextStoreGitBackendConfig; + +export interface ContextStoreRegistryEntryState { + backend: ContextStoreBackendConfig; +} + +export interface ContextStoreRegistryState { + version: 1; + stores: Record<string, ContextStoreRegistryEntryState>; +} + +export interface ContextStoreRegistryEntry { + id: string; + backend: ContextStoreBackendConfig; +} + +export interface ContextStoreMetadataState { + version: 1; + id: string; +} + +export interface ResolveGitContextStoreBackendInput { + localPath: string; + remote?: string; + branch?: string; +} + +function joinContextStorePath(basePath: string, ...segments: string[]): string { + return FileSystemUtils.joinPath(basePath, ...segments); +} + +export function getContextStoresDir(options: ContextStorePathOptions = {}): string { + return joinContextStorePath(options.globalDataDir ?? getGlobalDataDir(), CONTEXT_STORES_DIR_NAME); +} + +export function getContextStoreRegistryPath(options: ContextStorePathOptions = {}): string { + return joinContextStorePath(getContextStoresDir(options), CONTEXT_STORE_REGISTRY_FILE_NAME); +} + +export function getContextStoreMetadataDir(storeRoot: string): string { + return joinContextStorePath(storeRoot, CONTEXT_STORE_METADATA_DIR_NAME); +} + +export function getContextStoreMetadataPath(storeRoot: string): string { + return joinContextStorePath( + getContextStoreMetadataDir(storeRoot), + CONTEXT_STORE_METADATA_FILE_NAME + ); +} + +function validateFolderStyleName(name: string, label: string): string { + if (name.length === 0) { + throw new Error(`${label} must not be empty`); + } + + if (name === '.' || name === '..') { + throw new Error(`${label} must not be '${name}'`); + } + + if (/[\\/]/u.test(name)) { + throw new Error(`${label} must not contain path separators`); + } + + return name; +} + +export function validateContextStoreId(id: string): string { + try { + validateFolderStyleName(id, 'Context store id'); + } catch (error) { + throw new ContextStoreError( + error instanceof Error ? error.message : String(error), + 'invalid_context_store_id', + { + target: 'context_store.id', + fix: 'Use kebab-case with lowercase letters, numbers, and single hyphen separators.', + } + ); + } + + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) { + throw new ContextStoreError( + 'Context store id must be kebab-case with lowercase letters, numbers, and single hyphen separators', + 'invalid_context_store_id', + { + target: 'context_store.id', + fix: 'Use kebab-case with lowercase letters, numbers, and single hyphen separators.', + } + ); + } + + return id; +} + +export function isValidContextStoreId(id: string): boolean { + try { + validateContextStoreId(id); + return true; + } catch { + return false; + } +} + +async function pathIsFile(filePath: string): Promise<boolean> { + try { + return (await fs.stat(filePath)).isFile(); + } catch { + return false; + } +} + +async function pathIsDirectory(dirPath: string): Promise<boolean> { + try { + return (await fs.stat(dirPath)).isDirectory(); + } catch { + return false; + } +} + +function isFileNotFoundError(error: unknown): boolean { + return isNodeErrorCode(error, 'ENOENT'); +} + +function isNodeErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === code + ); +} + +function normalizeExistingPathForStorage(existingPath: string): string { + return FileSystemUtils.canonicalizeExistingPath(existingPath); +} + +function nonEmptyOptionalString() { + return z.string().min(1).optional(); +} + +const GitBackendConfigSchema = z.object({ + type: z.literal('git'), + local_path: z.string().min(1), + remote: nonEmptyOptionalString(), + branch: nonEmptyOptionalString(), +}).strict(); + +const RegistryEntrySchema = z.object({ + backend: GitBackendConfigSchema, +}).strict(); + +const RegistryStateSchema = z.object({ + version: z.literal(1), + stores: z.record(z.string(), RegistryEntrySchema), +}).strict(); + +const MetadataStateSchema = z.object({ + version: z.literal(1), + id: z.string(), +}).strict(); + +function formatZodIssues(error: z.ZodError): string { + return error.issues + .map((issue) => { + const location = issue.path.length > 0 ? issue.path.join('.') : 'root'; + return `${location}: ${issue.message}`; + }) + .join('; '); +} + +function contextStoreStateDiagnostic(label: string): { + code: string; + target: string; + fix: string; +} { + if (label.includes('metadata')) { + return { + code: 'invalid_context_store_metadata', + target: 'context_store.metadata', + fix: 'Repair .openspec-store/store.yaml.', + }; + } + + return { + code: 'invalid_context_store_registry', + target: 'context_store.registry', + fix: 'Repair or remove the context-store registry file.', + }; +} + +function invalidContextStoreStateError(label: string, message: string): ContextStoreError { + const diagnostic = contextStoreStateDiagnostic(label); + return new ContextStoreError(`Invalid ${label}: ${message}`, diagnostic.code, { + target: diagnostic.target, + fix: diagnostic.fix, + }); +} + +function parseYamlObject(content: string, label: string): unknown { + try { + return parseYaml(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw invalidContextStoreStateError(label, message); + } +} + +function assertValidContextStoreIds(ids: string[], label: string): void { + for (const id of ids) { + try { + validateContextStoreId(id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw invalidContextStoreStateError(label, `'${id}': ${message}`); + } + } +} + +export function parseContextStoreRegistryState(content: string): ContextStoreRegistryState { + const raw = parseYamlObject(content, 'context store registry state'); + const result = RegistryStateSchema.safeParse(raw); + + if (!result.success) { + throw invalidContextStoreStateError( + 'context store registry state', + formatZodIssues(result.error) + ); + } + + assertValidContextStoreIds(Object.keys(result.data.stores), 'context store id'); + + return { + version: 1, + stores: result.data.stores, + }; +} + +export function parseContextStoreMetadataState(content: string): ContextStoreMetadataState { + const raw = parseYamlObject(content, 'context store metadata state'); + const result = MetadataStateSchema.safeParse(raw); + + if (!result.success) { + throw invalidContextStoreStateError( + 'context store metadata state', + formatZodIssues(result.error) + ); + } + + validateContextStoreId(result.data.id); + + return { + version: 1, + id: result.data.id, + }; +} + +export function serializeContextStoreRegistryState(state: ContextStoreRegistryState): string { + const result = RegistryStateSchema.safeParse(state); + + if (!result.success) { + throw invalidContextStoreStateError( + 'context store registry state', + formatZodIssues(result.error) + ); + } + + assertValidContextStoreIds(Object.keys(result.data.stores), 'context store id'); + + return stringifyYaml({ + version: 1, + stores: result.data.stores, + }); +} + +export function serializeContextStoreMetadataState(state: ContextStoreMetadataState): string { + const result = MetadataStateSchema.safeParse(state); + + if (!result.success) { + throw invalidContextStoreStateError( + 'context store metadata state', + formatZodIssues(result.error) + ); + } + + validateContextStoreId(result.data.id); + + return stringifyYaml({ + version: 1, + id: result.data.id, + }); +} + +export function listContextStoreRegistryEntries( + registry: ContextStoreRegistryState +): ContextStoreRegistryEntry[] { + return Object.entries(registry.stores) + .map(([id, store]) => ({ id, backend: store.backend })) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +export async function isContextStoreRoot(candidateRoot: string): Promise<boolean> { + return pathIsFile(getContextStoreMetadataPath(candidateRoot)); +} + +export async function readContextStoreRegistryState( + options: ContextStorePathOptions = {} +): Promise<ContextStoreRegistryState | null> { + const registryPath = getContextStoreRegistryPath(options); + + if (!(await pathIsFile(registryPath))) { + return null; + } + + return parseContextStoreRegistryState(await fs.readFile(registryPath, 'utf-8')); +} + +export async function writeContextStoreRegistryState( + state: ContextStoreRegistryState, + options: ContextStorePathOptions = {} +): Promise<void> { + await writeFileAtomically( + getContextStoreRegistryPath(options), + serializeContextStoreRegistryState(state) + ); +} + +async function writeFileAtomically(filePath: string, content: string): Promise<void> { + const dirPath = path.dirname(filePath); + await FileSystemUtils.createDirectory(dirPath); + const tempPath = path.join( + dirPath, + `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp` + ); + + try { + await fs.writeFile(tempPath, content, 'utf-8'); + await fs.rename(tempPath, filePath); + } catch (error) { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } +} + +async function sleep(milliseconds: number): Promise<void> { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function acquireContextStoreRegistryLock( + options: ContextStorePathOptions +): Promise<nodeFs.promises.FileHandle> { + const registryPath = getContextStoreRegistryPath(options); + const lockPath = `${registryPath}.lock`; + await FileSystemUtils.createDirectory(path.dirname(registryPath)); + const deadline = Date.now() + 5000; + + while (true) { + try { + return await fs.open(lockPath, 'wx'); + } catch (error) { + if (!isNodeErrorCode(error, 'EEXIST') || Date.now() >= deadline) { + throw new ContextStoreError('Context store registry is busy.', 'context_store_registry_busy', { + target: 'context_store.registry', + fix: 'Retry the command after the current registry update finishes.', + }); + } + + await sleep(25); + } + } +} + +export async function updateContextStoreRegistryState( + updater: (state: ContextStoreRegistryState | null) => ContextStoreRegistryState, + options: ContextStorePathOptions = {} +): Promise<ContextStoreRegistryState> { + const registryPath = getContextStoreRegistryPath(options); + const lockPath = `${registryPath}.lock`; + const lock = await acquireContextStoreRegistryLock(options); + + try { + const next = updater(await readContextStoreRegistryState(options)); + await writeContextStoreRegistryState(next, options); + return next; + } finally { + await lock.close().catch(() => undefined); + await fs.rm(lockPath, { force: true }).catch(() => undefined); + } +} + +export async function readContextStoreMetadataState( + storeRoot: string +): Promise<ContextStoreMetadataState> { + return parseContextStoreMetadataState( + await fs.readFile(getContextStoreMetadataPath(storeRoot), 'utf-8') + ); +} + +export async function readOptionalContextStoreMetadataState( + storeRoot: string +): Promise<ContextStoreMetadataState | null> { + try { + return await readContextStoreMetadataState(storeRoot); + } catch (error) { + if (isFileNotFoundError(error)) { + return null; + } + + throw error; + } +} + +export async function writeContextStoreMetadataState( + storeRoot: string, + state: ContextStoreMetadataState +): Promise<void> { + await FileSystemUtils.writeFile( + getContextStoreMetadataPath(storeRoot), + serializeContextStoreMetadataState(state) + ); +} + +export async function resolveGitContextStoreBackendConfig( + input: ResolveGitContextStoreBackendInput, + cwd = process.cwd() +): Promise<ContextStoreGitBackendConfig> { + if (input.localPath.length === 0) { + throw new Error('Context store local path must not be empty.'); + } + + const resolvedPath = path.isAbsolute(input.localPath) + ? path.resolve(input.localPath) + : path.resolve(cwd, input.localPath); + + if (!(await pathIsDirectory(resolvedPath))) { + throw new Error(`Context store local path does not exist: ${input.localPath}`); + } + + if (input.remote !== undefined && input.remote.length === 0) { + throw new Error('Context store remote must not be empty when provided.'); + } + + if (input.branch !== undefined && input.branch.length === 0) { + throw new Error('Context store branch must not be empty when provided.'); + } + + return { + type: 'git', + local_path: normalizeExistingPathForStorage(resolvedPath), + ...(input.remote ? { remote: input.remote } : {}), + ...(input.branch ? { branch: input.branch } : {}), + }; +} diff --git a/src/core/context-store/index.ts b/src/core/context-store/index.ts new file mode 100644 index 0000000000..6ff3dfc7c3 --- /dev/null +++ b/src/core/context-store/index.ts @@ -0,0 +1,5 @@ +export * from './foundation.js'; +export * from './errors.js'; +export * from './registry.js'; +export * from './binding.js'; +export * from './operations.js'; diff --git a/src/core/context-store/operations.ts b/src/core/context-store/operations.ts new file mode 100644 index 0000000000..a0f8515e63 --- /dev/null +++ b/src/core/context-store/operations.ts @@ -0,0 +1,567 @@ +import { execFile } from 'node:child_process'; +import * as nodeFs from 'node:fs'; +import * as path from 'node:path'; +import { promisify } from 'node:util'; + +import { FileSystemUtils } from '../../utils/file-system.js'; +import { + getContextStoreMetadataPath, + getContextStoreRegistryPath, + listContextStoreRegistryEntries, + readContextStoreRegistryState, + readOptionalContextStoreMetadataState, + resolveGitContextStoreBackendConfig, + validateContextStoreId, + type ContextStoreGitBackendConfig, + type ContextStoreRegistryState, +} from './foundation.js'; +import { ContextStoreError, type ContextStoreDiagnostic, makeContextStoreDiagnostic } from './errors.js'; +import { + getStoreRootForBackend, + assertNoRegisteredStoreConflict, + commitContextStoreRegistration, + listRegisteredContextStores, +} from './registry.js'; + +const fs = nodeFs.promises; +const execFileAsync = promisify(execFile); + +type PathKind = 'missing' | 'directory' | 'file' | 'other'; + +export interface ContextStoreInfo { + id: string; + root: string; + metadataPath?: string; +} + +export interface ContextStoreMutationResult { + store: ContextStoreInfo; + registryCommit: { + path: string; + }; + git: { + isRepository: boolean; + initialized: boolean; + }; + createdArtifacts: string[]; +} + +export interface ContextStoreListResult { + stores: ContextStoreInfo[]; +} + +export interface ContextStoreDoctorResult { + stores: ContextStoreInspection[]; + diagnostics: ContextStoreDiagnostic[]; +} + +export interface ContextStoreInspection extends ContextStoreInfo { + metadata: { + present: boolean | null; + valid: boolean | null; + id?: string; + }; + git: { + isRepository: boolean | null; + }; + diagnostics: ContextStoreDiagnostic[]; +} + +export interface SetupContextStoreInput { + id?: string; + path?: string; + initGit?: boolean; +} + +export interface RegisterExistingContextStoreInput { + path?: string; + id?: string; +} + +export interface PreparedContextStoreSetup { + id: string; + root: string; + rootKind: Extract<PathKind, 'missing' | 'directory'>; + backend?: ContextStoreGitBackendConfig; + registry: ContextStoreRegistryState | null; +} + +interface ContextStoreSetupPlan { + id: string; + storeRoot: string; + kind: Extract<PathKind, 'missing' | 'directory'>; + backend?: ContextStoreGitBackendConfig; + registry: ContextStoreRegistryState | null; +} + +async function pathKind(targetPath: string): Promise<PathKind> { + try { + const stat = await fs.stat(targetPath); + if (stat.isDirectory()) return 'directory'; + if (stat.isFile()) return 'file'; + return 'other'; + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return 'missing'; + } + throw error; + } +} + +async function isDirectoryEmpty(directory: string): Promise<boolean> { + return (await fs.readdir(directory)).length === 0; +} + +async function readStoreMetadataForOperation(storeRoot: string) { + try { + return await readOptionalContextStoreMetadataState(storeRoot); + } catch (error) { + throw new ContextStoreError( + error instanceof Error ? error.message : String(error), + 'invalid_context_store_metadata', + { + target: 'context_store.metadata', + fix: `Repair ${getContextStoreMetadataPath(storeRoot)}.`, + } + ); + } +} + +async function isGitRepositoryAtRoot(storeRoot: string): Promise<boolean> { + const gitPath = path.join(storeRoot, '.git'); + const kind = await pathKind(gitPath); + return kind === 'directory' || kind === 'file'; +} + +async function initGitRepository(storeRoot: string): Promise<boolean> { + if (await isGitRepositoryAtRoot(storeRoot)) { + return false; + } + + try { + await execFileAsync('git', ['init'], { cwd: storeRoot }); + } catch (error) { + throw new ContextStoreError( + `Failed to initialize Git repository: ${error instanceof Error ? error.message : String(error)}`, + 'context_store_git_init_failed', + { + target: 'context_store.git', + fix: 'Install Git or rerun setup with --no-init-git.', + } + ); + } + + return true; +} + +function resolveSetupRoot(id: string, inputPath: string | undefined): string { + if (inputPath !== undefined && inputPath.trim().length === 0) { + throw new ContextStoreError('Pass a non-empty --path value.', 'context_store_path_required', { + target: 'context_store.root', + fix: `openspec context-store setup ${id} --path ./team-context`, + }); + } + + return path.resolve(inputPath ?? id); +} + +function resolveRegisterRoot(inputPath: string | undefined): string { + if (inputPath === undefined || inputPath.trim().length === 0) { + throw new ContextStoreError('Pass a context store path.', 'context_store_path_required', { + target: 'context_store.root', + fix: 'openspec context-store register /path/to/context-store', + }); + } + + return path.resolve(inputPath); +} + +function inferStoreIdFromPath(storeRoot: string): string { + return validateContextStoreId(path.basename(storeRoot)); +} + +function mutationPayload( + id: string, + storeRoot: string, + git: { isRepository: boolean; initialized: boolean }, + createdFiles: string[] +): ContextStoreMutationResult { + return { + store: { + id, + root: storeRoot, + metadataPath: getContextStoreMetadataPath(storeRoot), + }, + registryCommit: { + path: getContextStoreRegistryPath(), + }, + git: { + isRepository: git.isRepository, + initialized: git.initialized, + }, + createdArtifacts: createdFiles, + }; +} + +async function prepareSetupPlan( + input: Pick<SetupContextStoreInput, 'id' | 'path'> +): Promise<ContextStoreSetupPlan> { + const id = validateContextStoreId(input.id ?? ''); + const storeRoot = resolveSetupRoot(id, input.path); + const kind = await pathKind(storeRoot); + + if (kind === 'file' || kind === 'other') { + throw new ContextStoreError( + `Context store setup path is not a directory: ${storeRoot}`, + 'context_store_setup_path_not_directory', + { + target: 'context_store.root', + fix: 'Choose an empty directory or omit --path to use ./<id>.', + } + ); + } + + let metadata: Awaited<ReturnType<typeof readStoreMetadataForOperation>> = null; + let backend: ContextStoreGitBackendConfig | undefined; + + if (kind === 'directory') { + metadata = await readStoreMetadataForOperation(storeRoot); + + if (metadata) { + if (metadata.id !== id) { + throw new ContextStoreError( + `Context store metadata id '${metadata.id}' does not match requested id '${id}'.`, + 'context_store_metadata_id_mismatch', + { + target: 'context_store.metadata', + fix: `Use id '${metadata.id}' or choose a different setup path.`, + } + ); + } + } 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.', + } + ); + } + + backend = await resolveGitContextStoreBackendConfig({ localPath: storeRoot }); + } + + const registry = await readContextStoreRegistryState(); + const conflictBackend = backend ?? { + type: 'git' as const, + local_path: FileSystemUtils.canonicalizeExistingPath(storeRoot), + }; + + assertNoRegisteredStoreConflict(registry, id, conflictBackend); + + return { + id, + storeRoot, + kind, + registry, + ...(backend ? { backend } : {}), + }; +} + +export async function prepareContextStoreSetup( + input: Pick<SetupContextStoreInput, 'id' | 'path'> +): Promise<PreparedContextStoreSetup> { + const plan = await prepareSetupPlan(input); + + return { + id: plan.id, + root: plan.storeRoot, + rootKind: plan.kind, + registry: plan.registry, + ...(plan.backend ? { backend: plan.backend } : {}), + }; +} + +export async function setupPreparedContextStore( + prepared: PreparedContextStoreSetup, + input: Pick<SetupContextStoreInput, 'initGit'> = {} +): Promise<ContextStoreMutationResult> { + const plan: ContextStoreSetupPlan = { + id: prepared.id, + storeRoot: prepared.root, + kind: prepared.rootKind, + registry: prepared.registry, + ...(prepared.backend ? { backend: prepared.backend } : {}), + }; + const { id, storeRoot, kind, registry } = plan; + let { backend } = plan; + const createdFiles: string[] = []; + + const initGit = input.initGit ?? false; + + if (kind === 'missing') { + await fs.mkdir(storeRoot, { recursive: true }); + } + + try { + backend ??= await resolveGitContextStoreBackendConfig({ localPath: storeRoot }); + assertNoRegisteredStoreConflict(registry, id, backend); + + const gitInitialized = initGit ? await initGitRepository(storeRoot) : false; + const registered = await commitContextStoreRegistration({ + id, + backend, + writeMetadataIfMissing: true, + }); + if (registered.metadataCreated) { + createdFiles.push('.openspec-store/store.yaml'); + } + const isRepository = await isGitRepositoryAtRoot(registered.storeRoot); + + return mutationPayload(id, registered.storeRoot, { + isRepository, + initialized: gitInitialized, + }, createdFiles); + } catch (error) { + if (kind === 'missing') { + await fs.rm(storeRoot, { recursive: true, force: true }); + } + + throw error; + } +} + +export async function setupContextStore( + input: SetupContextStoreInput +): Promise<ContextStoreMutationResult> { + return setupPreparedContextStore(await prepareContextStoreSetup(input), { + initGit: input.initGit, + }); +} + +export async function registerExistingContextStore( + input: RegisterExistingContextStoreInput +): Promise<ContextStoreMutationResult> { + const storeRoot = resolveRegisterRoot(input.path); + const kind = await pathKind(storeRoot); + + if (kind === 'missing') { + throw new ContextStoreError( + `Context store path does not exist: ${storeRoot}`, + 'context_store_path_missing', + { + target: 'context_store.root', + fix: 'Clone or create the context store folder before registering it.', + } + ); + } + + if (kind !== 'directory') { + throw new ContextStoreError( + `Context store path is not a directory: ${storeRoot}`, + 'context_store_path_not_directory', + { + target: 'context_store.root', + fix: 'Pass an existing context store directory.', + } + ); + } + + const metadata = await readStoreMetadataForOperation(storeRoot); + const explicitId = input.id !== undefined ? validateContextStoreId(input.id) : undefined; + + if (metadata && explicitId !== undefined && metadata.id !== explicitId) { + throw new ContextStoreError( + `Context store metadata id '${metadata.id}' does not match --id '${explicitId}'.`, + 'context_store_metadata_id_mismatch', + { + target: 'context_store.id', + fix: `Use --id ${metadata.id} or register a different folder.`, + } + ); + } + + const id = metadata?.id ?? explicitId ?? inferStoreIdFromPath(storeRoot); + const backend = await resolveGitContextStoreBackendConfig({ localPath: storeRoot }); + const registry = await readContextStoreRegistryState(); + assertNoRegisteredStoreConflict(registry, id, backend); + const createdFiles: string[] = []; + + const registered = await commitContextStoreRegistration({ + id, + backend, + writeMetadataIfMissing: true, + }); + if (registered.metadataCreated) { + createdFiles.push('.openspec-store/store.yaml'); + } + + return mutationPayload(id, registered.storeRoot, { + isRepository: await isGitRepositoryAtRoot(registered.storeRoot), + initialized: false, + }, createdFiles); +} + +export async function listContextStores(): Promise<ContextStoreListResult> { + const entries = await listRegisteredContextStores(); + + return { + stores: entries.map((entry) => ({ + id: entry.id, + root: entry.storeRoot, + })), + }; +} + +function doctorStatusForError( + error: unknown, + code: string, + target: string, + fix?: string +): ContextStoreDiagnostic { + if (error instanceof ContextStoreError) { + return error.diagnostic; + } + + return makeContextStoreDiagnostic( + 'error', + code, + error instanceof Error ? error.message : String(error), + { + target, + ...(fix ? { fix } : {}), + } + ); +} + +async function inspectContextStore(entry: { + id: string; + backend: ContextStoreGitBackendConfig; +}): Promise<ContextStoreInspection> { + const root = getStoreRootForBackend(entry.backend); + const metadataPath = getContextStoreMetadataPath(root); + const diagnostics: ContextStoreDiagnostic[] = []; + const kind = await pathKind(root); + let metadata: ContextStoreInspection['metadata'] = { + present: null, + valid: null, + }; + let git: ContextStoreInspection['git'] = { + isRepository: null, + }; + + if (kind === 'missing') { + diagnostics.push(makeContextStoreDiagnostic( + 'error', + 'context_store_root_missing', + 'Context store location does not exist.', + { + target: 'context_store.root', + fix: `Run openspec context-store register /path/to/${entry.id} --id ${entry.id}.`, + } + )); + } else if (kind !== 'directory') { + diagnostics.push(makeContextStoreDiagnostic( + 'error', + 'context_store_root_not_directory', + 'Context store location is not a directory.', + { + target: 'context_store.root', + fix: 'Register a directory path for this context store.', + } + )); + } else { + try { + const parsed = await readOptionalContextStoreMetadataState(root); + if (!parsed) { + metadata = { present: false, valid: false }; + diagnostics.push(makeContextStoreDiagnostic( + 'error', + 'context_store_metadata_missing', + 'Context store metadata is missing.', + { + target: 'context_store.metadata', + fix: `Create ${metadataPath} or rerun context-store register.`, + } + )); + } else if (parsed.id !== entry.id) { + metadata = { present: true, valid: false, id: parsed.id }; + diagnostics.push(makeContextStoreDiagnostic( + 'error', + 'context_store_metadata_id_mismatch', + `Context store metadata id '${parsed.id}' does not match registry id '${entry.id}'.`, + { + target: 'context_store.metadata', + fix: 'Repair the local registry or store metadata so the ids match.', + } + )); + } else { + metadata = { present: true, valid: true, id: parsed.id }; + } + } catch (error) { + metadata = { present: true, valid: false }; + diagnostics.push(doctorStatusForError( + error, + 'context_store_metadata_invalid', + 'context_store.metadata', + `Repair ${metadataPath}.` + )); + } + + git = { + isRepository: await isGitRepositoryAtRoot(root), + }; + } + + return { + id: entry.id, + root, + metadataPath, + metadata, + git, + diagnostics, + }; +} + +export async function doctorContextStores(id?: string): Promise<ContextStoreDoctorResult> { + const selectedId = id !== undefined ? validateContextStoreId(id) : undefined; + const registry = await readContextStoreRegistryState(); + + if (!registry) { + if (selectedId !== undefined) { + throw new ContextStoreError(`Unknown context store '${selectedId}'.`, 'context_store_not_found', { + target: 'context_store.id', + fix: 'Run openspec context-store list to see registered stores.', + }); + } + + return { stores: [], diagnostics: [] }; + } + + const entries = listContextStoreRegistryEntries(registry); + const selected = selectedId + ? entries.filter((entry) => entry.id === selectedId) + : entries; + + if (selectedId && selected.length === 0) { + throw new ContextStoreError(`Unknown context store '${selectedId}'.`, 'context_store_not_found', { + target: 'context_store.id', + fix: 'Run openspec context-store list to see registered stores.', + }); + } + + return { + stores: await Promise.all(selected.map(inspectContextStore)), + diagnostics: [], + }; +} + +export function normalizeContextStorePathForComparison(targetPath: string): string { + return FileSystemUtils.canonicalizeExistingPath(targetPath); +} diff --git a/src/core/context-store/registry.ts b/src/core/context-store/registry.ts new file mode 100644 index 0000000000..b3629e9586 --- /dev/null +++ b/src/core/context-store/registry.ts @@ -0,0 +1,279 @@ +import * as fs from 'node:fs/promises'; + +import { + getContextStoreMetadataPath, + getContextStoreMetadataDir, + listContextStoreRegistryEntries, + readContextStoreRegistryState, + readOptionalContextStoreMetadataState, + resolveGitContextStoreBackendConfig, + updateContextStoreRegistryState, + validateContextStoreId, + writeContextStoreMetadataState, + type ContextStoreBackendConfig, + type ContextStoreGitBackendConfig, + type ContextStorePathOptions, + type ContextStoreRegistryEntry, + type ContextStoreRegistryState, +} from './foundation.js'; +import { ContextStoreError } from './errors.js'; +import { FileSystemUtils } from '../../utils/file-system.js'; + +export interface RegisterContextStoreInput extends ContextStorePathOptions { + id: string; + localPath: string; + remote?: string; + branch?: string; + cwd?: string; +} + +export interface ResolveRegisteredContextStoreInput extends ContextStorePathOptions { + id: string; +} + +export type ListRegisteredContextStoresOptions = ContextStorePathOptions; + +export interface RegisteredContextStoreEntry extends ContextStoreRegistryEntry { + storeRoot: string; +} + +export interface ResolvedContextStore { + id: string; + storeRoot: string; + backend: ContextStoreGitBackendConfig; +} + +export interface ContextStoreRegistrationCommit extends ResolvedContextStore { + metadataCreated: boolean; +} + +export interface CommitContextStoreRegistrationInput extends ContextStorePathOptions { + id: string; + backend: ContextStoreGitBackendConfig; + writeMetadataIfMissing: boolean; +} + +export function getStoreRootForBackend(backend: ContextStoreBackendConfig): string { + switch (backend.type) { + case 'git': + return backend.local_path; + } +} + +function normalizePathForComparison(targetPath: string): string { + try { + return FileSystemUtils.canonicalizeExistingPath(targetPath); + } catch { + return targetPath; + } +} + +export function assertNoRegisteredStoreConflict( + registry: ContextStoreRegistryState | null, + id: string, + backend: ContextStoreGitBackendConfig +): void { + const nextPath = normalizePathForComparison(getStoreRootForBackend(backend)); + + for (const entry of listContextStoreRegistryEntries(registry ?? { version: 1, stores: {} })) { + const entryPath = normalizePathForComparison(getStoreRootForBackend(entry.backend)); + + if (entry.id === id && entryPath === nextPath) { + continue; + } + + if (entry.id === id) { + throw new ContextStoreError( + `Context store '${id}' is already registered at ${getStoreRootForBackend(entry.backend)}.`, + 'context_store_id_conflict', + { + target: 'context_store.id', + fix: 'Use the existing registration or choose a different context store id.', + } + ); + } + + if (entryPath === nextPath) { + throw new ContextStoreError( + `Context store path is already registered as '${entry.id}'.`, + 'context_store_path_conflict', + { + target: 'context_store.root', + fix: `Use the existing '${entry.id}' registration or choose a different path.`, + } + ); + } + } +} + +function withRegisteredStore( + registry: ContextStoreRegistryState | null, + id: string, + backend: ContextStoreGitBackendConfig +): ContextStoreRegistryState { + assertNoRegisteredStoreConflict(registry, id, backend); + + const stores = { + ...(registry?.stores ?? {}), + [id]: { + backend, + }, + }; + + return { + version: 1, + stores: Object.fromEntries( + Object.entries(stores).sort(([leftId], [rightId]) => leftId.localeCompare(rightId)) + ), + }; +} + +async function ensureStoreMetadata( + storeRoot: string, + id: string, + options: { writeIfMissing: boolean } +): Promise<boolean> { + const metadata = await readOptionalContextStoreMetadataState(storeRoot); + + if (!metadata) { + if (!options.writeIfMissing) { + throw new ContextStoreError( + `Registered context store '${id}' is missing metadata at ${getContextStoreMetadataPath(storeRoot)}`, + 'context_store_metadata_missing', + { + target: 'context_store.metadata', + fix: `Create ${getContextStoreMetadataPath(storeRoot)} or rerun context-store register.`, + } + ); + } + + await writeContextStoreMetadataState(storeRoot, { + version: 1, + id, + }); + return true; + } + + if (metadata.id !== id) { + throw new ContextStoreError( + `Context store metadata id '${metadata.id}' does not match registered id '${id}'`, + 'context_store_metadata_id_mismatch', + { + target: 'context_store.metadata', + fix: 'Repair the local registry or store metadata so the ids match.', + } + ); + } + + return false; +} + +export async function commitContextStoreRegistration( + input: CommitContextStoreRegistrationInput +): Promise<ContextStoreRegistrationCommit> { + const id = validateContextStoreId(input.id); + const backend = input.backend; + const storeRoot = getStoreRootForBackend(backend); + + let metadataCreated = false; + + try { + metadataCreated = await ensureStoreMetadata(storeRoot, id, { + writeIfMissing: input.writeMetadataIfMissing, + }); + await updateContextStoreRegistryState( + (registry) => withRegisteredStore(registry, id, backend), + { globalDataDir: input.globalDataDir } + ); + } catch (error) { + if (metadataCreated) { + await fs.rm(getContextStoreMetadataPath(storeRoot), { force: true }); + await fs.rmdir(getContextStoreMetadataDir(storeRoot)).catch(() => undefined); + } + + throw error; + } + + return { + id, + storeRoot, + backend, + metadataCreated, + }; +} + +export async function registerContextStore( + input: RegisterContextStoreInput +): Promise<ResolvedContextStore> { + const id = validateContextStoreId(input.id); + const backend = await resolveGitContextStoreBackendConfig( + { + localPath: input.localPath, + ...(input.remote !== undefined ? { remote: input.remote } : {}), + ...(input.branch !== undefined ? { branch: input.branch } : {}), + }, + input.cwd + ); + const storeRoot = getStoreRootForBackend(backend); + + const committed = await commitContextStoreRegistration({ + id, + backend, + writeMetadataIfMissing: true, + ...(input.globalDataDir ? { globalDataDir: input.globalDataDir } : {}), + }); + return { + id: committed.id, + storeRoot: committed.storeRoot, + backend: committed.backend, + }; +} + +export async function listRegisteredContextStores( + options: ListRegisteredContextStoresOptions = {} +): Promise<RegisteredContextStoreEntry[]> { + const registry = await readContextStoreRegistryState(options); + + if (!registry) { + return []; + } + + return listContextStoreRegistryEntries(registry).map((entry) => ({ + ...entry, + storeRoot: getStoreRootForBackend(entry.backend), + })); +} + +export async function resolveRegisteredContextStore( + input: ResolveRegisteredContextStoreInput +): Promise<ResolvedContextStore> { + const id = validateContextStoreId(input.id); + const registry = await readContextStoreRegistryState({ + globalDataDir: input.globalDataDir, + }); + + if (!registry) { + throw new ContextStoreError('No context store registry found', 'no_context_store_registry', { + target: 'context_store.id', + fix: 'Register a context store before using --store, or pass --store-path <path>.', + }); + } + + const entry = registry.stores[id]; + if (!entry) { + throw new ContextStoreError(`Unknown context store '${id}'`, 'context_store_not_found', { + target: 'context_store.id', + fix: 'Run openspec context-store list to see registered stores.', + }); + } + + const backend = entry.backend; + const storeRoot = getStoreRootForBackend(backend); + await ensureStoreMetadata(storeRoot, id, { writeIfMissing: false }); + + return { + id, + storeRoot, + backend, + }; +} diff --git a/src/core/index.ts b/src/core/index.ts index a4b65abdf7..b29ae725a6 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -13,4 +13,6 @@ export { } from './global-config.js'; export * from './workspace/index.js'; +export * from './context-store/index.js'; +export * from './collections/index.js'; export * from './planning-home.js'; diff --git a/src/core/planning-home.ts b/src/core/planning-home.ts index a6a77f127c..360b82db1f 100644 --- a/src/core/planning-home.ts +++ b/src/core/planning-home.ts @@ -3,9 +3,8 @@ import * as path from 'node:path'; import { getWorkspaceChangesDir, - getWorkspaceSharedStatePath, - parseWorkspaceSharedState, - type WorkspaceSharedState, + readWorkspaceViewStateSync, + workspaceStateFileExistsSync, } from './workspace/index.js'; import { FileSystemUtils } from '../utils/file-system.js'; @@ -38,14 +37,6 @@ function pathExistsAsDirectory(candidatePath: string): boolean { } } -function pathExistsAsFile(candidatePath: string): boolean { - try { - return fs.statSync(candidatePath).isFile(); - } catch { - return false; - } -} - function getSearchStartDirectory(startPath: string): string { const resolved = path.resolve(startPath); @@ -76,9 +67,7 @@ function findNearestAncestor(startPath: string, predicate: (dirPath: string) => } export function findWorkspacePlanningRootSync(startPath = process.cwd()): string | null { - return findNearestAncestor(startPath, (dirPath) => - pathExistsAsFile(getWorkspaceSharedStatePath(dirPath)) - ); + return findNearestAncestor(startPath, workspaceStateFileExistsSync); } export function findRepoPlanningRootSync(startPath = process.cwd()): string | null { @@ -108,18 +97,8 @@ function relativePlanningPath(fromPath: string, toPath: string): string { return path.posix.relative(fromPath.replace(/\\/g, '/'), toPath.replace(/\\/g, '/')); } -function readWorkspaceSharedStateSync(workspaceRoot: string): WorkspaceSharedState | null { - try { - return parseWorkspaceSharedState( - fs.readFileSync(getWorkspaceSharedStatePath(workspaceRoot), 'utf-8') - ); - } catch { - return null; - } -} - function workspacePlanningHome(workspaceRoot: string): PlanningHome { - const sharedState = readWorkspaceSharedStateSync(workspaceRoot); + const viewState = readWorkspaceViewStateSync(workspaceRoot); return { kind: 'workspace', @@ -127,8 +106,8 @@ function workspacePlanningHome(workspaceRoot: string): PlanningHome { changesDir: getWorkspaceChangesDir(workspaceRoot), defaultSchema: WORKSPACE_DEFAULT_SCHEMA, workspace: { - name: sharedState?.name ?? path.basename(workspaceRoot), - links: Object.keys(sharedState?.links ?? {}).sort((a, b) => a.localeCompare(b)), + name: viewState?.name ?? path.basename(workspaceRoot), + links: Object.keys(viewState?.links ?? {}).sort((a, b) => a.localeCompare(b)), }, }; } diff --git a/src/core/workspace/foundation.ts b/src/core/workspace/foundation.ts index c5ac0aaf55..751bbc1d4e 100644 --- a/src/core/workspace/foundation.ts +++ b/src/core/workspace/foundation.ts @@ -1,20 +1,16 @@ -import * as nodeFs from 'node:fs'; -import * as path from 'node:path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { z } from 'zod'; -import { getGlobalDataDir } from '../global-config.js'; +import { + normalizeContextStoreBinding, + type ContextStoreBinding, + type ContextStoreSelector, +} from '../context-store/index.js'; import { FileSystemUtils } from '../../utils/file-system.js'; -const fs = nodeFs.promises; - export const WORKSPACE_METADATA_DIR_NAME = '.openspec-workspace'; -export const WORKSPACE_SHARED_STATE_FILE_NAME = 'workspace.yaml'; -export const WORKSPACE_LOCAL_STATE_FILE_NAME = 'local.yaml'; +export const WORKSPACE_VIEW_STATE_FILE_NAME = 'workspace.yaml'; export const WORKSPACE_CHANGES_DIR_NAME = 'changes'; -export const MANAGED_WORKSPACES_DIR_NAME = 'workspaces'; -export const WORKSPACE_REGISTRY_FILE_NAME = 'registry.yaml'; -export const WORKSPACE_LOCAL_STATE_IGNORE_PATTERN = `${WORKSPACE_METADATA_DIR_NAME}/${WORKSPACE_LOCAL_STATE_FILE_NAME}`; export const WORKSPACE_CODE_WORKSPACE_EXTENSION = '.code-workspace'; export const WORKSPACE_SUPPORTED_OPENER_VALUES = [ @@ -46,18 +42,21 @@ export type WorkspacePreferredOpener = id: WorkspaceEditorOpenerId; }; -export interface WorkspaceSharedState { - version: 1; - name: string; - links: Record<string, WorkspaceLinkState>; +export interface WorkspaceContextState { + kind: 'initiative'; + store: ContextStoreBinding; + initiative: { + id: string; + }; } -export type WorkspaceLinkState = Record<string, unknown>; - -export interface WorkspaceLocalState { +export interface WorkspaceViewState { version: 1; - paths: Record<string, string>; + name: string; + context: WorkspaceContextState | null; + links: Record<string, string | null>; preferred_opener?: WorkspacePreferredOpener; + tools?: string[]; workspace_skills?: WorkspaceSkillState; } @@ -69,20 +68,6 @@ export interface WorkspaceSkillState { last_applied_at?: string; } -export interface WorkspaceRegistryState { - version: 1; - workspaces: Record<string, string>; -} - -export interface WorkspaceRegistryEntry { - name: string; - workspaceRoot: string; -} - -export interface WorkspacePathOptions { - globalDataDir?: string; -} - function joinWorkspacePath(basePath: string, ...segments: string[]): string { return FileSystemUtils.joinPath(basePath, ...segments); } @@ -91,40 +76,14 @@ export function getWorkspaceMetadataDir(workspaceRoot: string): string { return joinWorkspacePath(workspaceRoot, WORKSPACE_METADATA_DIR_NAME); } -export function getWorkspaceSharedStatePath(workspaceRoot: string): string { - return joinWorkspacePath( - getWorkspaceMetadataDir(workspaceRoot), - WORKSPACE_SHARED_STATE_FILE_NAME - ); -} - -export function getWorkspaceLocalStatePath(workspaceRoot: string): string { - return joinWorkspacePath( - getWorkspaceMetadataDir(workspaceRoot), - WORKSPACE_LOCAL_STATE_FILE_NAME - ); +export function getWorkspaceViewStatePath(workspaceRoot: string): string { + return joinWorkspacePath(workspaceRoot, WORKSPACE_VIEW_STATE_FILE_NAME); } export function getWorkspaceChangesDir(workspaceRoot: string): string { return joinWorkspacePath(workspaceRoot, WORKSPACE_CHANGES_DIR_NAME); } -export function getManagedWorkspacesDir(options: WorkspacePathOptions = {}): string { - return joinWorkspacePath(options.globalDataDir ?? getGlobalDataDir(), MANAGED_WORKSPACES_DIR_NAME); -} - -export function getManagedWorkspaceRoot( - workspaceName: string, - options: WorkspacePathOptions = {} -): string { - validateWorkspaceName(workspaceName); - return joinWorkspacePath(getManagedWorkspacesDir(options), workspaceName); -} - -export function getWorkspaceRegistryPath(options: WorkspacePathOptions = {}): string { - return joinWorkspacePath(getManagedWorkspacesDir(options), WORKSPACE_REGISTRY_FILE_NAME); -} - export function getWorkspaceCodeWorkspaceFileName(workspaceName: string): string { validateWorkspaceName(workspaceName); return `${workspaceName}${WORKSPACE_CODE_WORKSPACE_EXTENSION}`; @@ -135,9 +94,7 @@ export function getWorkspaceCodeWorkspacePath(workspaceRoot: string, workspaceNa } export function getWorkspacePortableIgnorePatterns(workspaceName?: string): string[] { - return workspaceName - ? [WORKSPACE_LOCAL_STATE_IGNORE_PATTERN, getWorkspaceCodeWorkspaceFileName(workspaceName)] - : [WORKSPACE_LOCAL_STATE_IGNORE_PATTERN]; + return workspaceName ? [getWorkspaceCodeWorkspaceFileName(workspaceName)] : []; } function validateFolderStyleName(name: string, label: string): string { @@ -190,96 +147,71 @@ export function isValidWorkspaceLinkName(name: string): boolean { } } -async function pathIsFile(filePath: string): Promise<boolean> { - try { - return (await fs.stat(filePath)).isFile(); - } catch { - return false; - } -} - -async function pathIsDirectory(dirPath: string): Promise<boolean> { - try { - return (await fs.stat(dirPath)).isDirectory(); - } catch { - return false; - } -} - -export async function isWorkspaceRoot(candidateRoot: string): Promise<boolean> { - return pathIsFile(getWorkspaceSharedStatePath(candidateRoot)); -} - -async function getSearchStartDirectory(startPath: string): Promise<string> { - const resolvedStart = path.resolve(startPath); - - try { - const stats = await fs.stat(resolvedStart); - return stats.isDirectory() ? resolvedStart : path.dirname(resolvedStart); - } catch { - return resolvedStart; - } -} - -export async function findWorkspaceRoot(startPath = process.cwd()): Promise<string | null> { - let currentDir = await getSearchStartDirectory(startPath); - - while (true) { - if (await isWorkspaceRoot(currentDir)) { - return process.platform === 'win32' - ? FileSystemUtils.canonicalizeExistingPath(currentDir) - : currentDir; - } - - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - return null; - } - - currentDir = parentDir; - } -} - -function isPlainObject(value: unknown): value is Record<string, unknown> { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -const PlainObjectSchema = z.custom<Record<string, unknown>>(isPlainObject, { - message: 'must be an object', -}); - -const SharedStateSchema = z.object({ - version: z.literal(1), - name: z.string(), - links: z.record(z.string(), PlainObjectSchema), -}).strict(); - -const LocalStateSchema = z.object({ - version: z.literal(1), - paths: z.record(z.string(), z.string()), - preferred_opener: z +const ContextStoreSelectorSchema = z.union([ + z .object({ - kind: z.enum(['agent', 'editor']), + kind: z.literal('registry'), id: z.string(), }) - .strict() - .optional(), - workspace_skills: z + .strict(), + z .object({ - selected_agents: z.array(z.string()), - last_applied_profile: z.enum(['core', 'custom']).optional(), - last_applied_delivery: z.enum(['both', 'skills', 'commands']).optional(), - last_applied_workflow_ids: z.array(z.string()).optional(), - last_applied_at: z.string().optional(), + kind: z.literal('path'), + path: z.string(), + observed_id: z.string().optional(), }) - .strict() - .optional(), -}).strict(); - -const RegistryStateSchema = z.object({ - version: z.literal(1), - workspaces: z.record(z.string(), z.string()), -}).strict(); + .strict(), +]); + +const ContextStoreBindingSchema = z + .object({ + id: z.string(), + selector: ContextStoreSelectorSchema, + }) + .strict(); + +const WorkspaceInitiativeContextSchema = z + .object({ + kind: z.literal('initiative'), + store: ContextStoreBindingSchema, + initiative: z + .object({ + id: z.string(), + }) + .strict(), + }) + .strict(); + +const WorkspaceContextSchema = WorkspaceInitiativeContextSchema; + +const WorkspaceSkillStateSchema = z + .object({ + selected_agents: z.array(z.string()), + last_applied_profile: z.enum(['core', 'custom']).optional(), + last_applied_delivery: z.enum(['both', 'skills', 'commands']).optional(), + last_applied_workflow_ids: z.array(z.string()).optional(), + last_applied_at: z.string().optional(), + }) + .strict(); + +const PreferredOpenerSchema = z + .object({ + kind: z.enum(['agent', 'editor']), + id: z.string(), + }) + .strict(); + +const ViewStateSchema = z + .object({ + version: z.literal(1), + name: z.string(), + context: WorkspaceContextSchema.nullable(), + links: z.record(z.string(), z.string().nullable()), + preferred_opener: PreferredOpenerSchema.optional(), + tools: z.array(z.string()).optional(), + workspace_skills: WorkspaceSkillStateSchema.optional(), + }) + .strict(); function formatZodIssues(error: z.ZodError): string { return error.issues @@ -364,40 +296,65 @@ export function validateWorkspacePreferredOpener( ); } -export function parseWorkspaceSharedState(content: string): WorkspaceSharedState { - const raw = parseYamlObject(content, 'workspace shared state'); - const result = SharedStateSchema.safeParse(raw); +function normalizeWorkspaceContextState( + context: z.infer<typeof WorkspaceContextSchema> +): WorkspaceContextState { + return createWorkspaceInitiativeContext( + normalizeContextStoreBinding(context.store as ContextStoreBinding), + context.initiative.id + ); +} - if (!result.success) { - throw new Error(`Invalid workspace shared state: ${formatZodIssues(result.error)}`); - } +function normalizeOptionalWorkspaceContextState( + context: z.infer<typeof WorkspaceContextSchema> | null | undefined +): WorkspaceContextState | null { + return context ? normalizeWorkspaceContextState(context) : null; +} - validateWorkspaceName(result.data.name); - assertValidMapKeys( - Object.keys(result.data.links), - validateWorkspaceLinkName, - 'workspace link name' - ); +export function createWorkspaceInitiativeContext( + store: ContextStoreBinding, + initiativeId: string +): WorkspaceContextState { + if (initiativeId.length === 0) { + throw new Error('Workspace initiative id must not be empty.'); + } return { - version: 1, - name: result.data.name, - links: result.data.links, + kind: 'initiative', + store: normalizeContextStoreBinding(store), + initiative: { + id: initiativeId, + }, }; } -export function parseWorkspaceLocalState(content: string): WorkspaceLocalState { - const raw = parseYamlObject(content, 'workspace local state'); - const result = LocalStateSchema.safeParse(raw); +export function getWorkspaceContextStoreId(context: WorkspaceContextState): string { + return context.store.id; +} + +export function getWorkspaceContextStoreSelector( + context: WorkspaceContextState +): ContextStoreSelector { + return context.store.selector; +} + +export function getWorkspaceContextInitiativeId(context: WorkspaceContextState): string { + return context.initiative.id; +} + +export function parseWorkspaceViewState(content: string): WorkspaceViewState { + const raw = parseYamlObject(content, 'workspace state'); + const result = ViewStateSchema.safeParse(raw); if (!result.success) { - throw new Error(`Invalid workspace local state: ${formatZodIssues(result.error)}`); + throw new Error(`Invalid workspace state: ${formatZodIssues(result.error)}`); } + validateWorkspaceName(result.data.name); assertValidMapKeys( - Object.keys(result.data.paths), + Object.keys(result.data.links), validateWorkspaceLinkName, - 'workspace local path name' + 'workspace link name' ); const preferredOpener = result.data.preferred_opener @@ -406,59 +363,24 @@ export function parseWorkspaceLocalState(content: string): WorkspaceLocalState { return { version: 1, - paths: result.data.paths, + name: result.data.name, + context: normalizeOptionalWorkspaceContextState(result.data.context), + links: result.data.links, ...(preferredOpener ? { preferred_opener: preferredOpener } : {}), - ...(result.data.workspace_skills ? { workspace_skills: result.data.workspace_skills } : {}), - }; -} - -export function parseWorkspaceRegistryState(content: string): WorkspaceRegistryState { - const raw = parseYamlObject(content, 'workspace registry state'); - const result = RegistryStateSchema.safeParse(raw); - - if (!result.success) { - throw new Error(`Invalid workspace registry state: ${formatZodIssues(result.error)}`); - } - - assertValidMapKeys( - Object.keys(result.data.workspaces), - validateWorkspaceName, - 'workspace registry name' - ); - - return { - version: 1, - workspaces: result.data.workspaces, + ...(result.data.tools ? { tools: result.data.tools } : {}), + ...(result.data.workspace_skills + ? { workspace_skills: result.data.workspace_skills } + : {}), }; } -export function serializeWorkspaceSharedState(state: WorkspaceSharedState): string { +export function serializeWorkspaceViewState(state: WorkspaceViewState): string { validateWorkspaceName(state.name); assertValidMapKeys(Object.keys(state.links), validateWorkspaceLinkName, 'workspace link name'); - for (const [linkName, linkState] of Object.entries(state.links)) { - if (!isPlainObject(linkState)) { - throw new Error(`Invalid workspace link '${linkName}': link state must be an object`); - } - } - - return stringifyYaml({ - version: 1, - name: state.name, - links: state.links, - }); -} - -export function serializeWorkspaceLocalState(state: WorkspaceLocalState): string { - assertValidMapKeys( - Object.keys(state.paths), - validateWorkspaceLinkName, - 'workspace local path name' - ); - - for (const [linkName, localPath] of Object.entries(state.paths)) { - if (typeof localPath !== 'string') { - throw new Error(`Invalid workspace local path '${linkName}': path must be a string`); + for (const [linkName, localPath] of Object.entries(state.links)) { + if (localPath !== null && typeof localPath !== 'string') { + throw new Error(`Invalid workspace link '${linkName}': path must be a string or null`); } } @@ -468,116 +390,11 @@ export function serializeWorkspaceLocalState(state: WorkspaceLocalState): string return stringifyYaml({ version: 1, - paths: state.paths, + name: state.name, + context: state.context ? normalizeWorkspaceContextState(state.context) : null, + links: state.links, ...(preferredOpener ? { preferred_opener: preferredOpener } : {}), + ...(state.tools ? { tools: state.tools } : {}), ...(state.workspace_skills ? { workspace_skills: state.workspace_skills } : {}), }); } - -export function serializeWorkspaceRegistryState(state: WorkspaceRegistryState): string { - assertValidMapKeys( - Object.keys(state.workspaces), - validateWorkspaceName, - 'workspace registry name' - ); - - for (const [workspaceName, workspaceRoot] of Object.entries(state.workspaces)) { - if (typeof workspaceRoot !== 'string') { - throw new Error(`Invalid workspace registry entry '${workspaceName}': path must be a string`); - } - } - - return stringifyYaml({ - version: 1, - workspaces: state.workspaces, - }); -} - -export function listWorkspaceRegistryEntries( - registry: WorkspaceRegistryState -): WorkspaceRegistryEntry[] { - return Object.entries(registry.workspaces) - .map(([name, workspaceRoot]) => ({ name, workspaceRoot })) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -export async function readWorkspaceSharedState(workspaceRoot: string): Promise<WorkspaceSharedState> { - return parseWorkspaceSharedState( - await fs.readFile(getWorkspaceSharedStatePath(workspaceRoot), 'utf-8') - ); -} - -export async function readWorkspaceLocalState(workspaceRoot: string): Promise<WorkspaceLocalState> { - return parseWorkspaceLocalState( - await fs.readFile(getWorkspaceLocalStatePath(workspaceRoot), 'utf-8') - ); -} - -function isFileNotFoundError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as NodeJS.ErrnoException).code === 'ENOENT' - ); -} - -export async function readOptionalWorkspaceLocalState( - workspaceRoot: string -): Promise<WorkspaceLocalState | null> { - try { - return await readWorkspaceLocalState(workspaceRoot); - } catch (error) { - if (isFileNotFoundError(error)) { - return null; - } - - throw error; - } -} - -export async function writeWorkspaceSharedState( - workspaceRoot: string, - state: WorkspaceSharedState -): Promise<void> { - await FileSystemUtils.writeFile( - getWorkspaceSharedStatePath(workspaceRoot), - serializeWorkspaceSharedState(state) - ); -} - -export async function writeWorkspaceLocalState( - workspaceRoot: string, - state: WorkspaceLocalState -): Promise<void> { - await FileSystemUtils.writeFile( - getWorkspaceLocalStatePath(workspaceRoot), - serializeWorkspaceLocalState(state) - ); -} - -export async function readWorkspaceRegistryState( - options: WorkspacePathOptions = {} -): Promise<WorkspaceRegistryState | null> { - const registryPath = getWorkspaceRegistryPath(options); - - if (!(await pathIsFile(registryPath))) { - return null; - } - - return parseWorkspaceRegistryState(await fs.readFile(registryPath, 'utf-8')); -} - -export async function writeWorkspaceRegistryState( - state: WorkspaceRegistryState, - options: WorkspacePathOptions = {} -): Promise<void> { - await FileSystemUtils.writeFile( - getWorkspaceRegistryPath(options), - serializeWorkspaceRegistryState(state) - ); -} - -export async function workspaceChangesDirExists(workspaceRoot: string): Promise<boolean> { - return pathIsDirectory(getWorkspaceChangesDir(workspaceRoot)); -} diff --git a/src/core/workspace/index.ts b/src/core/workspace/index.ts index a5630edcf4..638bace5fa 100644 --- a/src/core/workspace/index.ts +++ b/src/core/workspace/index.ts @@ -2,4 +2,6 @@ export * from './foundation.js'; export * from './link-input.js'; export * from './openers.js'; export * from './open-surface.js'; +export * from './registry.js'; export * from './skills.js'; +export * from './state-io.js'; diff --git a/src/core/workspace/legacy-state.ts b/src/core/workspace/legacy-state.ts new file mode 100644 index 0000000000..e0c91ef8ef --- /dev/null +++ b/src/core/workspace/legacy-state.ts @@ -0,0 +1,298 @@ +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { z } from 'zod'; + +import { + WORKSPACE_METADATA_DIR_NAME, + WORKSPACE_VIEW_STATE_FILE_NAME, + getWorkspaceMetadataDir, + parseWorkspaceViewState, + validateWorkspaceLinkName, + validateWorkspaceName, + validateWorkspacePreferredOpener, + type WorkspaceContextState, + type WorkspacePreferredOpener, + type WorkspaceSkillState, + type WorkspaceViewState, +} from './foundation.js'; +import { FileSystemUtils } from '../../utils/file-system.js'; + +export const WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME = WORKSPACE_VIEW_STATE_FILE_NAME; +export const WORKSPACE_LEGACY_LOCAL_STATE_FILE_NAME = 'local.yaml'; +export const WORKSPACE_LEGACY_LOCAL_STATE_IGNORE_PATTERN = + `${WORKSPACE_METADATA_DIR_NAME}/${WORKSPACE_LEGACY_LOCAL_STATE_FILE_NAME}`; + +export type WorkspaceLinkState = Record<string, unknown>; + +export interface WorkspaceSharedState { + version: 1; + name: string; + context: WorkspaceContextState | null; + links: Record<string, WorkspaceLinkState>; +} + +export interface WorkspaceLocalState { + version: 1; + paths: Record<string, string>; + preferred_opener?: WorkspacePreferredOpener; + tools?: string[]; + workspace_skills?: WorkspaceSkillState; +} + +function joinWorkspacePath(basePath: string, ...segments: string[]): string { + return FileSystemUtils.joinPath(basePath, ...segments); +} + +export function getWorkspaceLegacySharedStatePath(workspaceRoot: string): string { + return joinWorkspacePath( + getWorkspaceMetadataDir(workspaceRoot), + WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME + ); +} + +export function getWorkspaceLegacyLocalStatePath(workspaceRoot: string): string { + return joinWorkspacePath( + getWorkspaceMetadataDir(workspaceRoot), + WORKSPACE_LEGACY_LOCAL_STATE_FILE_NAME + ); +} + +function isPlainObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const PlainObjectSchema = z.custom<Record<string, unknown>>(isPlainObject, { + message: 'must be an object', +}); + +const PreferredOpenerSchema = z + .object({ + kind: z.enum(['agent', 'editor']), + id: z.string(), + }) + .strict(); + +const WorkspaceSkillStateSchema = z + .object({ + selected_agents: z.array(z.string()), + last_applied_profile: z.enum(['core', 'custom']).optional(), + last_applied_delivery: z.enum(['both', 'skills', 'commands']).optional(), + last_applied_workflow_ids: z.array(z.string()).optional(), + last_applied_at: z.string().optional(), + }) + .strict(); + +const SharedStateSchema = z.object({ + version: z.literal(1), + name: z.string(), + context: z.unknown().optional(), + links: z.record(z.string(), PlainObjectSchema), +}).strict(); + +const LocalStateSchema = z.object({ + version: z.literal(1), + paths: z.record(z.string(), z.string()), + preferred_opener: PreferredOpenerSchema.optional(), + tools: z.array(z.string()).optional(), + workspace_skills: WorkspaceSkillStateSchema.optional(), +}).strict(); + +function formatZodIssues(error: z.ZodError): string { + return error.issues + .map((issue) => { + const location = issue.path.length > 0 ? issue.path.join('.') : 'root'; + return `${location}: ${issue.message}`; + }) + .join('; '); +} + +function parseYamlObject(content: string, label: string): unknown { + try { + return parseYaml(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ${label}: ${message}`); + } +} + +function assertValidMapKeys( + keys: string[], + validator: (name: string) => string, + label: string +): void { + for (const key of keys) { + try { + validator(key); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ${label} '${key}': ${message}`); + } + } +} + +function normalizeLegacyWorkspaceContext( + name: string, + context: unknown +): WorkspaceContextState | null { + return parseWorkspaceViewState(stringifyYaml({ + version: 1, + name, + context: context ?? null, + links: {}, + })).context; +} + +export function workspaceViewToSharedState(state: WorkspaceViewState): WorkspaceSharedState { + return { + version: 1, + name: state.name, + context: state.context, + links: Object.fromEntries(Object.keys(state.links).map((linkName) => [linkName, {}])), + }; +} + +export function workspaceViewToLocalState(state: WorkspaceViewState): WorkspaceLocalState { + return { + version: 1, + paths: Object.fromEntries( + Object.entries(state.links).filter((entry): entry is [string, string] => + typeof entry[1] === 'string' + ) + ), + ...(state.preferred_opener ? { preferred_opener: state.preferred_opener } : {}), + ...(state.tools ? { tools: state.tools } : {}), + ...(state.workspace_skills ? { workspace_skills: state.workspace_skills } : {}), + }; +} + +export function workspaceStatePartsToViewState( + sharedState: WorkspaceSharedState, + localState: WorkspaceLocalState | null +): WorkspaceViewState { + const linkNames = new Set([ + ...Object.keys(sharedState.links), + ...Object.keys(localState?.paths ?? {}), + ]); + const links = Object.fromEntries( + [...linkNames] + .sort((a, b) => a.localeCompare(b)) + .map((linkName) => [linkName, localState?.paths[linkName] ?? null] as const) + ); + + return { + version: 1, + name: sharedState.name, + context: sharedState.context, + links, + ...(localState?.preferred_opener ? { preferred_opener: localState.preferred_opener } : {}), + ...(localState?.tools ? { tools: localState.tools } : {}), + ...(localState?.workspace_skills ? { workspace_skills: localState.workspace_skills } : {}), + }; +} + +export function parseWorkspaceSharedState(content: string): WorkspaceSharedState { + const raw = parseYamlObject(content, 'workspace shared state'); + + try { + return workspaceViewToSharedState(parseWorkspaceViewState(content)); + } catch { + // Fall through to the legacy shared schema. + } + + const result = SharedStateSchema.safeParse(raw); + + if (!result.success) { + throw new Error(`Invalid workspace shared state: ${formatZodIssues(result.error)}`); + } + + validateWorkspaceName(result.data.name); + assertValidMapKeys( + Object.keys(result.data.links), + validateWorkspaceLinkName, + 'workspace link name' + ); + + return { + version: 1, + name: result.data.name, + context: normalizeLegacyWorkspaceContext(result.data.name, result.data.context), + links: result.data.links, + }; +} + +export function parseWorkspaceLocalState(content: string): WorkspaceLocalState { + const raw = parseYamlObject(content, 'workspace local state'); + + try { + return workspaceViewToLocalState(parseWorkspaceViewState(content)); + } catch { + // Fall through to the legacy local schema. + } + + const result = LocalStateSchema.safeParse(raw); + + if (!result.success) { + throw new Error(`Invalid workspace local state: ${formatZodIssues(result.error)}`); + } + + assertValidMapKeys( + Object.keys(result.data.paths), + validateWorkspaceLinkName, + 'workspace local path name' + ); + + const preferredOpener = result.data.preferred_opener + ? validateWorkspacePreferredOpener(result.data.preferred_opener as WorkspacePreferredOpener) + : undefined; + + return { + version: 1, + paths: result.data.paths, + ...(preferredOpener ? { preferred_opener: preferredOpener } : {}), + ...(result.data.tools ? { tools: result.data.tools } : {}), + ...(result.data.workspace_skills ? { workspace_skills: result.data.workspace_skills } : {}), + }; +} + +export function serializeWorkspaceSharedState(state: WorkspaceSharedState): string { + validateWorkspaceName(state.name); + assertValidMapKeys(Object.keys(state.links), validateWorkspaceLinkName, 'workspace link name'); + + for (const [linkName, linkState] of Object.entries(state.links)) { + if (!isPlainObject(linkState)) { + throw new Error(`Invalid workspace link '${linkName}': link state must be an object`); + } + } + + return stringifyYaml({ + version: 1, + name: state.name, + context: state.context, + links: state.links, + }); +} + +export function serializeWorkspaceLocalState(state: WorkspaceLocalState): string { + assertValidMapKeys( + Object.keys(state.paths), + validateWorkspaceLinkName, + 'workspace local path name' + ); + + for (const [linkName, localPath] of Object.entries(state.paths)) { + if (typeof localPath !== 'string') { + throw new Error(`Invalid workspace local path '${linkName}': path must be a string`); + } + } + + const preferredOpener = state.preferred_opener + ? validateWorkspacePreferredOpener(state.preferred_opener) + : undefined; + + return stringifyYaml({ + version: 1, + paths: state.paths, + ...(preferredOpener ? { preferred_opener: preferredOpener } : {}), + ...(state.tools ? { tools: state.tools } : {}), + ...(state.workspace_skills ? { workspace_skills: state.workspace_skills } : {}), + }); +} diff --git a/src/core/workspace/open-surface.ts b/src/core/workspace/open-surface.ts index 10dbf8148b..0ba6bec8e5 100644 --- a/src/core/workspace/open-surface.ts +++ b/src/core/workspace/open-surface.ts @@ -3,8 +3,8 @@ import * as path from 'node:path'; import { FileSystemUtils } from '../../utils/file-system.js'; import { - WorkspaceLocalState, - WorkspaceSharedState, + WorkspaceViewState, + getWorkspaceContextInitiativeId, getWorkspaceCodeWorkspacePath, getWorkspacePortableIgnorePatterns, } from './foundation.js'; @@ -16,14 +16,29 @@ export const WORKSPACE_GUIDANCE_END_MARKER = '<!-- OPENSPEC:WORKSPACE-GUIDANCE:E export const WORKSPACE_GUIDANCE_BODY = `# OpenSpec Workspace Guidance -This directory is an OpenSpec workspace for planning across linked repos or folders. - -- Use \`changes/\` for workspace-level planning. -- Linked repos and folders are available for exploration and planning. -- Repo or folder visibility supports exploration and planning. -- Make implementation edits after the user explicitly asks for implementation work. -- Treat linked repos and folders as the implementation homes for their owned code. -- Use OpenSpec workspace commands instead of hand-editing \`.openspec-workspace/*.yaml\`.`; +This directory is an OpenSpec workspace: a local working view over context stores, initiatives, repos, and folders. + +- Use this workspace to open the local view of coordinated work. +- Use initiatives for durable cross-team or cross-repo intent, decisions, requirements, and coordination context. +- Use repo-local OpenSpec changes for implementation plans owned by a repo or team. +- Use linked repos and folders to inspect context, understand ownership, and make edits in the place that owns the work. +- Keep workspace-local files focused on local paths, opener state, agent setup, and other machine-specific view state. +- Use OpenSpec workspace commands instead of hand-editing \`workspace.yaml\`. +- If this workspace contains legacy or beta workspace-level planning files, treat them as compatibility context unless the user explicitly asks to use that beta flow.`; + +export interface WorkspaceOpenResolvedContext { + contextStore: { + id: string; + root: string; + }; + initiative: { + id: string; + title: string; + root: string; + metadataPath: string; + storePath: string; + }; +} export interface WorkspaceOpenLink { name: string; @@ -41,6 +56,11 @@ export interface WorkspaceOpenSurfaceLinks { skipped: WorkspaceSkippedOpenLink[]; } +export interface WorkspaceOpenSurfaceGeneration { + agentsPath: string; + codeWorkspacePath: string; +} + async function fileExists(filePath: string): Promise<boolean> { try { return (await fs.stat(filePath)).isFile(); @@ -57,14 +77,91 @@ async function directoryExists(dirPath: string): Promise<boolean> { } } -export function buildWorkspaceGuidanceBlock(): string { +function formatGuidancePathList(items: Array<{ label: string; path: string }>): string { + if (items.length === 0) { + return '- None selected yet.'; + } + + return items.map((item) => `- ${item.label}: ${item.path}`).join('\n'); +} + +function buildWorkspaceContextGuidance( + viewState: WorkspaceViewState, + resolvedContext?: WorkspaceOpenResolvedContext | null +): string { + const linkedRoots = Object.entries(viewState.links) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string') + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, linkPath]) => ({ label: name, path: linkPath })); + + if (!viewState.context) { + return `## Local View + +This workspace is not bound to an initiative. It is still a first-class local view over selected repos or folders. + +## Linked Implementation Context + +${formatGuidancePathList(linkedRoots)}`; + } + + const storedContextSelector = viewState.context.store.selector; + const storedContextStore = viewState.context + ? storedContextSelector?.kind === 'path' + ? `${viewState.context.store.id} via ${storedContextSelector.path}` + : viewState.context.store.id + : null; + const storedInitiativeId = viewState.context + ? getWorkspaceContextInitiativeId(viewState.context) + : null; + const contextLines = resolvedContext + ? [ + `- Context store: ${resolvedContext.contextStore.id} (${resolvedContext.contextStore.root})`, + `- Initiative: ${resolvedContext.initiative.id} (${resolvedContext.initiative.root})`, + `- Initiative title: ${resolvedContext.initiative.title}`, + `- Initiative metadata: ${resolvedContext.initiative.metadataPath}`, + '- Broader context may exist in the context store, but this workspace opens the selected initiative by default.', + ].join('\n') + : [ + `- Context store: ${storedContextStore}`, + `- Initiative: ${storedInitiativeId}`, + '- Run `openspec workspace open --json` to refresh resolved local paths for this view.', + ].join('\n'); + + return `## Selected Initiative Context + +${contextLines} + +## Advisory Edit Boundaries + +- Treat initiative and context-store files as shared coordination context. +- Treat linked repos and folders as local implementation context when the user has selected them. +- These boundaries are advisory in this OpenSpec version; use judgment and repo ownership when editing. + +## Linked Implementation Context + +${formatGuidancePathList(linkedRoots)}`; +} + +export function buildWorkspaceGuidanceBlock( + viewState?: WorkspaceViewState, + resolvedContext?: WorkspaceOpenResolvedContext | null +): string { + const contextGuidance = + viewState + ? `\n\n${buildWorkspaceContextGuidance(viewState, resolvedContext)}` + : ''; + return `${WORKSPACE_GUIDANCE_START_MARKER} -${WORKSPACE_GUIDANCE_BODY} +${WORKSPACE_GUIDANCE_BODY}${contextGuidance} ${WORKSPACE_GUIDANCE_END_MARKER}`; } -export function applyWorkspaceGuidanceBlock(existingContent: string): string { - const block = buildWorkspaceGuidanceBlock(); +export function applyWorkspaceGuidanceBlock( + existingContent: string, + viewState?: WorkspaceViewState, + resolvedContext?: WorkspaceOpenResolvedContext | null +): string { + const block = buildWorkspaceGuidanceBlock(viewState, resolvedContext); const startIndex = existingContent.indexOf(WORKSPACE_GUIDANCE_START_MARKER); const endIndex = existingContent.indexOf(WORKSPACE_GUIDANCE_END_MARKER); @@ -90,12 +187,21 @@ export function applyWorkspaceGuidanceBlock(existingContent: string): string { } export function buildWorkspaceCodeWorkspaceContent( - links: WorkspaceOpenLink[] + links: WorkspaceOpenLink[], + resolvedContext?: WorkspaceOpenResolvedContext | null ): string { const folders = [ { path: '.', }, + ...(resolvedContext + ? [ + { + name: `initiative:${resolvedContext.initiative.id}`, + path: resolvedContext.initiative.root, + }, + ] + : []), ...links.map((link) => ({ name: link.name, path: link.path, @@ -107,20 +213,23 @@ export function buildWorkspaceCodeWorkspaceContent( export async function writeWorkspaceCodeWorkspaceFile( codeWorkspacePath: string, - links: WorkspaceOpenLink[] + links: WorkspaceOpenLink[], + resolvedContext?: WorkspaceOpenResolvedContext | null ): Promise<void> { - await FileSystemUtils.writeFile(codeWorkspacePath, buildWorkspaceCodeWorkspaceContent(links)); + await FileSystemUtils.writeFile( + codeWorkspacePath, + buildWorkspaceCodeWorkspaceContent(links, resolvedContext) + ); } export async function resolveWorkspaceOpenLinks( - sharedState: WorkspaceSharedState, - localState: WorkspaceLocalState + viewState: WorkspaceViewState ): Promise<WorkspaceOpenSurfaceLinks> { const links: WorkspaceOpenLink[] = []; const skipped: WorkspaceSkippedOpenLink[] = []; - for (const linkName of Object.keys(sharedState.links).sort((a, b) => a.localeCompare(b))) { - const localPath = localState.paths[linkName] ?? null; + for (const linkName of Object.keys(viewState.links).sort((a, b) => a.localeCompare(b))) { + const localPath = viewState.links[linkName] ?? null; if (!localPath) { skipped.push({ @@ -149,24 +258,34 @@ export async function resolveWorkspaceOpenLinks( return { links, skipped }; } -async function syncWorkspaceGuidance(workspaceRoot: string): Promise<void> { +async function syncWorkspaceGuidance( + workspaceRoot: string, + viewState: WorkspaceViewState, + resolvedContext?: WorkspaceOpenResolvedContext | null +): Promise<string> { const agentsPath = path.join(workspaceRoot, 'AGENTS.md'); const existingContent = (await fileExists(agentsPath)) ? await fs.readFile(agentsPath, 'utf-8') : ''; - await FileSystemUtils.writeFile(agentsPath, applyWorkspaceGuidanceBlock(existingContent)); + await FileSystemUtils.writeFile( + agentsPath, + applyWorkspaceGuidanceBlock(existingContent, viewState, resolvedContext) + ); + + return agentsPath; } async function syncWorkspaceCodeWorkspace( workspaceRoot: string, - sharedState: WorkspaceSharedState, - links: WorkspaceOpenLink[] -): Promise<void> { - await writeWorkspaceCodeWorkspaceFile( - getWorkspaceCodeWorkspacePath(workspaceRoot, sharedState.name), - links - ); + viewState: WorkspaceViewState, + links: WorkspaceOpenLink[], + resolvedContext?: WorkspaceOpenResolvedContext | null +): Promise<string> { + const codeWorkspacePath = getWorkspaceCodeWorkspacePath(workspaceRoot, viewState.name); + await writeWorkspaceCodeWorkspaceFile(codeWorkspacePath, links, resolvedContext); + + return codeWorkspacePath; } async function syncWorkspaceIgnoreRules( @@ -199,14 +318,29 @@ async function syncWorkspaceIgnoreRules( export async function syncWorkspaceOpenSurface( workspaceRoot: string, - sharedState: WorkspaceSharedState, - localState: WorkspaceLocalState -): Promise<WorkspaceOpenSurfaceLinks> { - const openLinks = await resolveWorkspaceOpenLinks(sharedState, localState); + viewState: WorkspaceViewState, + resolvedContext?: WorkspaceOpenResolvedContext | null +): Promise<WorkspaceOpenSurfaceLinks & { generated: WorkspaceOpenSurfaceGeneration }> { + const openLinks = await resolveWorkspaceOpenLinks(viewState); + const agentsPath = await syncWorkspaceGuidance( + workspaceRoot, + viewState, + resolvedContext + ); + const codeWorkspacePath = await syncWorkspaceCodeWorkspace( + workspaceRoot, + viewState, + openLinks.links, + resolvedContext + ); - await syncWorkspaceGuidance(workspaceRoot); - await syncWorkspaceCodeWorkspace(workspaceRoot, sharedState, openLinks.links); - await syncWorkspaceIgnoreRules(workspaceRoot, sharedState.name); + await syncWorkspaceIgnoreRules(workspaceRoot, viewState.name); - return openLinks; + return { + ...openLinks, + generated: { + agentsPath, + codeWorkspacePath, + }, + }; } diff --git a/src/core/workspace/registry.ts b/src/core/workspace/registry.ts new file mode 100644 index 0000000000..4a89add398 --- /dev/null +++ b/src/core/workspace/registry.ts @@ -0,0 +1,221 @@ +import * as nodeFs from 'node:fs'; + +import { z } from 'zod'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; + +import { getGlobalDataDir } from '../global-config.js'; +import { FileSystemUtils } from '../../utils/file-system.js'; +import { validateWorkspaceName } from './foundation.js'; +import { isWorkspaceRoot, readWorkspaceViewState } from './state-io.js'; + +const fs = nodeFs.promises; + +export const MANAGED_WORKSPACES_DIR_NAME = 'workspaces'; +export const WORKSPACE_REGISTRY_FILE_NAME = 'registry.yaml'; + +export interface WorkspaceRegistryState { + version: 1; + workspaces: Record<string, string>; +} + +export interface WorkspaceRegistryEntry { + name: string; + workspaceRoot: string; +} + +export interface WorkspacePathOptions { + globalDataDir?: string; +} + +function joinWorkspacePath(basePath: string, ...segments: string[]): string { + return FileSystemUtils.joinPath(basePath, ...segments); +} + +async function pathIsFile(filePath: string): Promise<boolean> { + try { + return (await fs.stat(filePath)).isFile(); + } catch { + return false; + } +} + +async function pathIsDirectory(dirPath: string): Promise<boolean> { + try { + return (await fs.stat(dirPath)).isDirectory(); + } catch { + return false; + } +} + +function formatZodIssues(error: z.ZodError): string { + return error.issues + .map((issue) => { + const location = issue.path.length > 0 ? issue.path.join('.') : 'root'; + return `${location}: ${issue.message}`; + }) + .join('; '); +} + +function parseYamlObject(content: string, label: string): unknown { + try { + return parseYaml(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ${label}: ${message}`); + } +} + +function assertValidMapKeys( + keys: string[], + validator: (name: string) => string, + label: string +): void { + for (const key of keys) { + try { + validator(key); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ${label} '${key}': ${message}`); + } + } +} + +const RegistryStateSchema = z.object({ + version: z.literal(1), + workspaces: z.record(z.string(), z.string()), +}).strict(); + +export function getManagedWorkspacesDir(options: WorkspacePathOptions = {}): string { + return joinWorkspacePath(options.globalDataDir ?? getGlobalDataDir(), MANAGED_WORKSPACES_DIR_NAME); +} + +export function getManagedWorkspaceRoot( + workspaceName: string, + options: WorkspacePathOptions = {} +): string { + validateWorkspaceName(workspaceName); + return joinWorkspacePath(getManagedWorkspacesDir(options), workspaceName); +} + +export function getWorkspaceRegistryPath(options: WorkspacePathOptions = {}): string { + return joinWorkspacePath(getManagedWorkspacesDir(options), WORKSPACE_REGISTRY_FILE_NAME); +} + +export function parseWorkspaceRegistryState(content: string): WorkspaceRegistryState { + const raw = parseYamlObject(content, 'workspace registry state'); + const result = RegistryStateSchema.safeParse(raw); + + if (!result.success) { + throw new Error(`Invalid workspace registry state: ${formatZodIssues(result.error)}`); + } + + assertValidMapKeys( + Object.keys(result.data.workspaces), + validateWorkspaceName, + 'workspace registry name' + ); + + return { + version: 1, + workspaces: result.data.workspaces, + }; +} + +export function serializeWorkspaceRegistryState(state: WorkspaceRegistryState): string { + assertValidMapKeys( + Object.keys(state.workspaces), + validateWorkspaceName, + 'workspace registry name' + ); + + for (const [workspaceName, workspaceRoot] of Object.entries(state.workspaces)) { + if (typeof workspaceRoot !== 'string') { + throw new Error(`Invalid workspace registry entry '${workspaceName}': path must be a string`); + } + } + + return stringifyYaml({ + version: 1, + workspaces: state.workspaces, + }); +} + +export function listWorkspaceRegistryEntries( + registry: WorkspaceRegistryState +): WorkspaceRegistryEntry[] { + return Object.entries(registry.workspaces) + .map(([name, workspaceRoot]) => ({ name, workspaceRoot })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function listKnownWorkspaceEntries( + options: WorkspacePathOptions = {} +): Promise<WorkspaceRegistryEntry[]> { + const legacyRegistry = await readWorkspaceRegistryState(options); + const workspaces = new Map<string, string>(Object.entries(legacyRegistry?.workspaces ?? {})); + + for (const entry of await listManagedWorkspaceEntries(options)) { + workspaces.set(entry.name, entry.workspaceRoot); + } + + return [...workspaces.entries()] + .map(([name, workspaceRoot]) => ({ name, workspaceRoot })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function listManagedWorkspaceEntries( + options: WorkspacePathOptions = {} +): Promise<WorkspaceRegistryEntry[]> { + const workspacesDir = getManagedWorkspacesDir(options); + + if (!(await pathIsDirectory(workspacesDir))) { + return []; + } + + const entries = await fs.readdir(workspacesDir, { withFileTypes: true }); + const workspaces: WorkspaceRegistryEntry[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const workspaceRoot = FileSystemUtils.canonicalizeExistingPath( + joinWorkspacePath(workspacesDir, entry.name) + ); + if (!(await isWorkspaceRoot(workspaceRoot))) { + continue; + } + + try { + const state = await readWorkspaceViewState(workspaceRoot); + workspaces.push({ name: state.name, workspaceRoot }); + } catch { + workspaces.push({ name: entry.name, workspaceRoot }); + } + } + + return workspaces.sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function readWorkspaceRegistryState( + options: WorkspacePathOptions = {} +): Promise<WorkspaceRegistryState | null> { + const registryPath = getWorkspaceRegistryPath(options); + + if (!(await pathIsFile(registryPath))) { + return null; + } + + return parseWorkspaceRegistryState(await fs.readFile(registryPath, 'utf-8')); +} + +export async function writeWorkspaceRegistryState( + state: WorkspaceRegistryState, + options: WorkspacePathOptions = {} +): Promise<void> { + await FileSystemUtils.writeFile( + getWorkspaceRegistryPath(options), + serializeWorkspaceRegistryState(state) + ); +} diff --git a/src/core/workspace/skills.ts b/src/core/workspace/skills.ts index dca3167acd..9caea04a9d 100644 --- a/src/core/workspace/skills.ts +++ b/src/core/workspace/skills.ts @@ -13,7 +13,7 @@ import { getToolsWithSkillsDir, extractGeneratedByVersion, } from '../shared/index.js'; -import type { WorkspaceLocalState, WorkspaceSkillState } from './foundation.js'; +import type { WorkspaceSkillState } from './foundation.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../../package.json'); @@ -115,9 +115,9 @@ function arraysEqual(left: readonly string[] | undefined, right: readonly string } export function hasWorkspaceSkillProfileDrift( - localState: Pick<WorkspaceLocalState, 'workspace_skills'> | null | undefined + state: { workspace_skills?: WorkspaceSkillState } | null | undefined ): boolean { - const workspaceSkills = localState?.workspace_skills; + const workspaceSkills = state?.workspace_skills; if (!workspaceSkills) { return false; diff --git a/src/core/workspace/state-io.ts b/src/core/workspace/state-io.ts new file mode 100644 index 0000000000..bb3b72be5e --- /dev/null +++ b/src/core/workspace/state-io.ts @@ -0,0 +1,173 @@ +import * as nodeFs from 'node:fs'; +import * as path from 'node:path'; + +import { FileSystemUtils } from '../../utils/file-system.js'; +import { + getWorkspaceChangesDir, + getWorkspaceViewStatePath, + parseWorkspaceViewState, + serializeWorkspaceViewState, + type WorkspaceViewState, +} from './foundation.js'; +import { + getWorkspaceLegacyLocalStatePath, + getWorkspaceLegacySharedStatePath, + parseWorkspaceLocalState, + parseWorkspaceSharedState, + workspaceStatePartsToViewState, + type WorkspaceLocalState, +} from './legacy-state.js'; + +const fs = nodeFs.promises; + +async function pathIsFile(filePath: string): Promise<boolean> { + try { + return (await fs.stat(filePath)).isFile(); + } catch { + return false; + } +} + +async function pathIsDirectory(dirPath: string): Promise<boolean> { + try { + return (await fs.stat(dirPath)).isDirectory(); + } catch { + return false; + } +} + +function pathExistsAsFile(filePath: string): boolean { + try { + return nodeFs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +function isFileNotFoundError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +async function getSearchStartDirectory(startPath: string): Promise<string> { + const resolvedStart = path.resolve(startPath); + + try { + const stats = await fs.stat(resolvedStart); + const searchStart = stats.isDirectory() ? resolvedStart : path.dirname(resolvedStart); + return FileSystemUtils.canonicalizeExistingPath(searchStart); + } catch { + return resolvedStart; + } +} + +export async function isWorkspaceRoot(candidateRoot: string): Promise<boolean> { + return ( + (await pathIsFile(getWorkspaceViewStatePath(candidateRoot))) || + (await pathIsFile(getWorkspaceLegacySharedStatePath(candidateRoot))) + ); +} + +export async function findWorkspaceRoot(startPath = process.cwd()): Promise<string | null> { + let currentDir = await getSearchStartDirectory(startPath); + + while (true) { + if (await isWorkspaceRoot(currentDir)) { + return FileSystemUtils.canonicalizeExistingPath(currentDir); + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + + currentDir = parentDir; + } +} + +export function workspaceStateFileExistsSync(workspaceRoot: string): boolean { + return ( + pathExistsAsFile(getWorkspaceViewStatePath(workspaceRoot)) || + pathExistsAsFile(getWorkspaceLegacySharedStatePath(workspaceRoot)) + ); +} + +export async function readWorkspaceViewState(workspaceRoot: string): Promise<WorkspaceViewState> { + const viewStatePath = getWorkspaceViewStatePath(workspaceRoot); + + if (await pathIsFile(viewStatePath)) { + return parseWorkspaceViewState(await fs.readFile(viewStatePath, 'utf-8')); + } + + const legacySharedState = parseWorkspaceSharedState( + await fs.readFile(getWorkspaceLegacySharedStatePath(workspaceRoot), 'utf-8') + ); + let legacyLocalState: WorkspaceLocalState | null = null; + + try { + legacyLocalState = parseWorkspaceLocalState( + await fs.readFile(getWorkspaceLegacyLocalStatePath(workspaceRoot), 'utf-8') + ); + } catch (error) { + if (!isFileNotFoundError(error)) { + throw error; + } + } + + return workspaceStatePartsToViewState(legacySharedState, legacyLocalState); +} + +export function readWorkspaceViewStateSync(workspaceRoot: string): WorkspaceViewState | null { + const viewStatePath = getWorkspaceViewStatePath(workspaceRoot); + + if (pathExistsAsFile(viewStatePath)) { + return parseWorkspaceViewState(nodeFs.readFileSync(viewStatePath, 'utf-8')); + } + + const legacySharedPath = getWorkspaceLegacySharedStatePath(workspaceRoot); + if (!pathExistsAsFile(legacySharedPath)) { + return null; + } + + const legacySharedState = parseWorkspaceSharedState( + nodeFs.readFileSync(legacySharedPath, 'utf-8') + ); + const legacyLocalPath = getWorkspaceLegacyLocalStatePath(workspaceRoot); + const legacyLocalState = pathExistsAsFile(legacyLocalPath) + ? parseWorkspaceLocalState(nodeFs.readFileSync(legacyLocalPath, 'utf-8')) + : null; + + return workspaceStatePartsToViewState(legacySharedState, legacyLocalState); +} + +export async function readOptionalWorkspaceViewState( + workspaceRoot: string +): Promise<WorkspaceViewState | null> { + try { + return await readWorkspaceViewState(workspaceRoot); + } catch (error) { + if (isFileNotFoundError(error)) { + return null; + } + + throw error; + } +} + +export async function writeWorkspaceViewState( + workspaceRoot: string, + state: WorkspaceViewState +): Promise<void> { + await FileSystemUtils.writeFile( + getWorkspaceViewStatePath(workspaceRoot), + serializeWorkspaceViewState(state) + ); +} + +export async function workspaceChangesDirExists(workspaceRoot: string): Promise<boolean> { + return pathIsDirectory(getWorkspaceChangesDir(workspaceRoot)); +} diff --git a/src/utils/change-metadata.ts b/src/utils/change-metadata.ts index 46c66b3a4b..92717cf7ab 100644 --- a/src/utils/change-metadata.ts +++ b/src/utils/change-metadata.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as yaml from 'yaml'; -import { ChangeMetadataSchema, type ChangeMetadata } from '../core/artifact-graph/types.js'; +import { ChangeMetadataSchema, type ChangeMetadata } from '../core/change-metadata/index.js'; import { listSchemas } from '../core/artifact-graph/resolver.js'; import { readProjectConfig } from '../core/project-config.js'; @@ -146,6 +146,10 @@ export function readChangeMetadata( return parseResult.data; } +export interface ResolveSchemaForChangeOptions { + metadata?: ChangeMetadata | null; +} + /** * Resolves the schema for a change, with explicit override taking precedence. * @@ -162,7 +166,8 @@ export function readChangeMetadata( export function resolveSchemaForChange( changeDir: string, explicitSchema?: string, - projectRootOverride?: string + projectRootOverride?: string, + options: ResolveSchemaForChangeOptions = {} ): string { // Derive project root from changeDir (changeDir is typically projectRoot/openspec/changes/change-name) const projectRoot = projectRootOverride ?? path.resolve(changeDir, '../../..'); @@ -172,17 +177,13 @@ export function resolveSchemaForChange( return explicitSchema; } - // 2. Try reading from metadata - try { - const metadata = readChangeMetadata(changeDir, projectRoot); - if (metadata?.schema) { - return metadata.schema; - } - } catch { - // If metadata read fails, continue to next option + const metadata = + options.metadata !== undefined ? options.metadata : readChangeMetadata(changeDir, projectRoot); + if (metadata?.schema) { + return metadata.schema; } - // 3. Try reading from project config + // 3. Try reading from project config when metadata is absent. try { const config = readProjectConfig(projectRoot); if (config?.schema) { diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index ce25afa52e..c3ff95ccb6 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -2,7 +2,7 @@ import path from 'path'; import { FileSystemUtils } from './file-system.js'; import { writeChangeMetadata, validateSchemaName } from './change-metadata.js'; import { readProjectConfig } from '../core/project-config.js'; -import type { ChangeMetadata } from '../core/artifact-graph/types.js'; +import type { ChangeMetadata } from '../core/change-metadata/index.js'; const DEFAULT_SCHEMA = 'spec-driven'; @@ -17,7 +17,7 @@ export interface CreateChangeOptions { /** Directory that should contain the change directories */ changesDir?: string; /** Additional metadata to persist in the change's .openspec.yaml */ - metadata?: Partial<Pick<ChangeMetadata, 'goal' | 'affected_areas'>>; + metadata?: Partial<Pick<ChangeMetadata, 'goal' | 'affected_areas' | 'initiative'>>; } /** diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 7fe58da8cb..14ed078666 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -450,9 +450,18 @@ describe('artifact-workflow CLI commands', () => { 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)]); const instructions = await runCLI( diff --git a/test/commands/change-initiative-link.test.ts b/test/commands/change-initiative-link.test.ts new file mode 100644 index 0000000000..7ce62c2376 --- /dev/null +++ b/test/commands/change-initiative-link.test.ts @@ -0,0 +1,532 @@ +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, + 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', () => { + 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'); + env = { + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + fs.mkdirSync(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function parseJson(result: RunCLIResult): any { + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error( + `Could not parse JSON.\nCommand: ${result.command}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\n${String(error)}` + ); + } + } + + function 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<string> { + const storeRoot = mkdir(`stores/${store}`); + await registerContextStore({ + id: store, + localPath: storeRoot, + globalDataDir, + }); + return storeRoot; + } + + async function setupUnregisteredStore(store = 'scratch-context'): Promise<string> { + 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<void> { + 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 } + ); + + 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 } + ); + + 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('creates a linked change with an explicit context store selector', async () => { + const storeRoot = await setupRegisteredStore('platform'); + await createInitiative('billing-launch'); + + const result = await runCLI( + ['new', 'change', 'store-selected-link', '--initiative', 'billing-launch', '--store', 'platform', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + expect(parseJson(result).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 <id>'); + 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'], { + 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']); + + 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 <store>' + ); + expect(fs.readFileSync(metadataPath('set-lookup-failure'), 'utf-8')).toBe(before); + }); + + 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; + + 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', + ], + { 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('<initiative store="scratch-context" id="scratch-launch" />'); + + 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', + }); + }); + + 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']); + + 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}`], + { cwd: tempDir, env } + ); + expect(setup.exitCode).toBe(0); + const workspaceRoot = parseJson(setup).workspace.root; + + const create = await runCLI(['new', 'change', 'workspace-plan'], { + cwd: workspaceRoot, + env, + }); + 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/context-store.test.ts b/test/commands/context-store.test.ts new file mode 100644 index 0000000000..b703940bcd --- /dev/null +++ b/test/commands/context-store.test.ts @@ -0,0 +1,389 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + getGlobalDataDir, + getContextStoreMetadataPath, + readContextStoreMetadataState, + readContextStoreRegistryState, + writeContextStoreMetadataState, + writeContextStoreRegistryState, +} from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; + +vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), +})); + +async function runContextStoreCommand(args: string[]): Promise<void> { + const { registerContextStoreCommand } = await import('../../src/commands/context-store.js'); + const program = new Command(); + registerContextStoreCommand(program); + await program.parseAsync(['node', 'openspec', 'context-store', ...args]); +} + +async function getPromptMocks(): Promise<{ + confirm: ReturnType<typeof vi.fn>; +}> { + const prompts = await import('@inquirer/prompts'); + return { + confirm: prompts.confirm as unknown as ReturnType<typeof vi.fn>, + }; +} + +describe('context-store command', () => { + let tempDir: string; + let dataHome: string; + let configHome: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + let originalEnv: NodeJS.ProcessEnv; + let originalCwd: string; + let originalStdinTTY: boolean | undefined; + let originalExitCode: string | number | undefined; + let consoleLogSpy: ReturnType<typeof vi.spyOn> | undefined; + let consoleErrorSpy: ReturnType<typeof vi.spyOn> | undefined; + + beforeEach(() => { + vi.resetModules(); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-context-store-command-')); + dataHome = path.join(tempDir, 'data'); + configHome = path.join(tempDir, 'config'); + env = { + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + originalStdinTTY = (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY; + originalExitCode = process.exitCode; + process.exitCode = undefined; + }); + + afterEach(() => { + process.env = originalEnv; + process.chdir(originalCwd); + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = originalStdinTTY; + process.exitCode = originalExitCode; + consoleLogSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + vi.clearAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function mkdir(relativePath: string): string { + const dir = path.join(tempDir, relativePath); + fs.mkdirSync(dir, { recursive: true }); + return dir; + } + + function expectedExistingPath(existingPath: string): string { + return fs.realpathSync.native(existingPath); + } + + function parseJson(result: RunCLIResult): any { + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error( + `Could not parse JSON.\nCommand: ${result.command}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\n${String(error)}` + ); + } + } + + it('sets up a context store at ./<id> without Git in non-interactive JSON mode', async () => { + const result = await runCLI( + ['context-store', 'setup', 'team-context', '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + const storeRoot = expectedExistingPath(path.join(tempDir, 'team-context')); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const payload = parseJson(result); + expect(payload.context_store).toEqual({ + id: 'team-context', + root: storeRoot, + metadata_path: getContextStoreMetadataPath(storeRoot), + }); + expect(payload.git).toEqual({ + is_repository: false, + initialized: false, + }); + expect(payload.created_files).toEqual(['.openspec-store/store.yaml']); + expect(payload.status).toEqual([]); + await expect(readContextStoreMetadataState(storeRoot)).resolves.toEqual({ + version: 1, + id: 'team-context', + }); + await expect(readContextStoreRegistryState({ globalDataDir })).resolves.toEqual({ + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: storeRoot, + }, + }, + }, + }); + expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(false); + }); + + it('supports explicit current-directory setup', async () => { + const storeRoot = mkdir('team-context'); + + const result = await runCLI( + ['context-store', 'setup', 'team-context', '--path', '.', '--no-init-git', '--json'], + { cwd: storeRoot, env } + ); + + expect(result.exitCode).toBe(0); + expect(parseJson(result).context_store.root).toBe(expectedExistingPath(storeRoot)); + }); + + it('rejects non-empty setup folders without context-store metadata', async () => { + const storeRoot = mkdir('existing'); + fs.writeFileSync(path.join(storeRoot, 'notes.md'), 'hello\n'); + + const result = await runCLI( + ['context-store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_setup_non_empty_directory', + }) + ); + expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(false); + }); + + it('does not prompt before setup validation fails', async () => { + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPENSPEC_TELEMETRY: '0', + }; + delete process.env.OPEN_SPEC_INTERACTIVE; + delete process.env.CI; + process.chdir(tempDir); + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { confirm } = await getPromptMocks(); + confirm.mockResolvedValue(true); + const storeRoot = mkdir('existing'); + fs.writeFileSync(path.join(storeRoot, 'notes.md'), 'hello\n'); + + await runContextStoreCommand(['setup', 'team-context', '--path', storeRoot]); + + expect(confirm).not.toHaveBeenCalled(); + expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(false); + expect(process.exitCode).toBe(1); + }); + + it('registers an existing folder by inferring the folder name', async () => { + const storeRoot = mkdir('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']); + await expect(readContextStoreMetadataState(storeRoot)).resolves.toEqual({ + version: 1, + id: 'team-context', + }); + }); + + 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'); + await writeContextStoreMetadataState(firstRoot, { version: 1, id: 'team-context' }); + await writeContextStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: firstRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + const sameId = await runCLI( + ['context-store', 'register', secondRoot, '--id', 'team-context', '--json'], + { cwd: tempDir, env } + ); + expect(sameId.exitCode).toBe(1); + expect(parseJson(sameId).status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_id_conflict', + }) + ); + + fs.rmSync(path.join(firstRoot, '.openspec-store'), { recursive: true, force: true }); + fs.symlinkSync(firstRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir'); + const samePath = await runCLI( + ['context-store', 'register', aliasRoot, '--id', 'other-context', '--json'], + { cwd: tempDir, env } + ); + expect(samePath.exitCode).toBe(1); + expect(parseJson(samePath).status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_path_conflict', + }) + ); + }); + + it('lists the local registry without health checks', async () => { + await writeContextStoreRegistryState( + { + version: 1, + stores: { + 'zeta-context': { + backend: { + type: 'git', + local_path: path.join(tempDir, 'missing-zeta'), + }, + }, + 'alpha-context': { + backend: { + type: 'git', + local_path: path.join(tempDir, 'missing-alpha'), + }, + }, + }, + }, + { globalDataDir } + ); + + const result = await runCLI(['context-store', 'list', '--json'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(0); + expect(parseJson(result)).toEqual({ + context_stores: [ + { + id: 'alpha-context', + root: path.join(tempDir, 'missing-alpha'), + }, + { + id: 'zeta-context', + root: path.join(tempDir, 'missing-zeta'), + }, + ], + status: [], + }); + }); + + it('rejects an explicit blank doctor id', async () => { + const result = await runCLI(['context-store', 'doctor', '', '--json'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'invalid_context_store_id', + }) + ); + }); + + it('doctors registered store path, metadata, and Git presence', async () => { + const healthyRoot = mkdir('healthy-context'); + const mismatchRoot = mkdir('mismatch-context'); + fs.mkdirSync(path.join(healthyRoot, '.git')); + await writeContextStoreMetadataState(healthyRoot, { version: 1, id: 'healthy-context' }); + await writeContextStoreMetadataState(mismatchRoot, { version: 1, id: 'other-context' }); + await writeContextStoreRegistryState( + { + version: 1, + stores: { + 'healthy-context': { + backend: { + type: 'git', + local_path: healthyRoot, + }, + }, + 'missing-context': { + backend: { + type: 'git', + local_path: path.join(tempDir, 'missing-context'), + }, + }, + 'mismatch-context': { + backend: { + type: 'git', + local_path: mismatchRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + const result = await runCLI(['context-store', 'doctor', '--json'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(0); + 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'].git.is_repository).toBe(true); + expect(byId['missing-context'].status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_root_missing', + }) + ); + expect(byId['mismatch-context'].status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_metadata_id_mismatch', + }) + ); + }); + + it('prompts for Git initialization in interactive setup', async () => { + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPENSPEC_TELEMETRY: '0', + }; + delete process.env.OPEN_SPEC_INTERACTIVE; + delete process.env.CI; + process.chdir(tempDir); + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { confirm } = await getPromptMocks(); + confirm.mockResolvedValue(true); + + await runContextStoreCommand(['setup', 'interactive-context']); + + const storeRoot = path.join(tempDir, 'interactive-context'); + expect(confirm).toHaveBeenCalledWith({ + message: 'Initialize Git repository?', + default: true, + }); + expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(true); + expect(process.exitCode).toBeUndefined(); + }); +}); diff --git a/test/commands/initiative.test.ts b/test/commands/initiative.test.ts new file mode 100644 index 0000000000..01b358c545 --- /dev/null +++ b/test/commands/initiative.test.ts @@ -0,0 +1,907 @@ +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 { COMMAND_REGISTRY } from '../../src/core/completions/command-registry.js'; +import { + getGlobalDataDir, + INITIATIVE_FILE_NAMES, + parseInitiativeState, + registerContextStore, + writeContextStoreRegistryState, + writeContextStoreMetadataState, +} from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; + +describe('initiative command', () => { + let tempDir: string; + let dataHome: string; + let configHome: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-initiative-command-')); + dataHome = path.join(tempDir, 'data'); + configHome = path.join(tempDir, 'config'); + env = { + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + }); + + 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 expectedExistingPath(existingPath: string): string { + return fs.realpathSync.native(existingPath); + } + + function expectSameExistingPath(actualPath: string, expectedPath: string): void { + expect(fs.realpathSync.native(actualPath)).toBe(expectedExistingPath(expectedPath)); + } + + 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)}` + ); + } + } + + async function setupRegisteredStore(id = 'team-context'): Promise<string> { + const storeRoot = mkdir(`stores/${id}`); + await registerContextStore({ + id, + localPath: storeRoot, + globalDataDir, + }); + return storeRoot; + } + + async function setupUnregisteredStore(id = 'scratch-context'): Promise<string> { + const storeRoot = mkdir(`stores/${id}`); + await writeContextStoreMetadataState(storeRoot, { + version: 1, + id, + }); + return storeRoot; + } + + function initiativeRoot(storeRoot: string, id: string): string { + return path.join(storeRoot, 'initiatives', id); + } + + function readInitiativeState(storeRoot: string, id: string) { + return parseInitiativeState( + fs.readFileSync(path.join(initiativeRoot(storeRoot, id), 'initiative.yaml'), 'utf-8') + ); + } + + function writeInvalidInitiative(storeRoot: string, id: string): void { + fs.mkdirSync(initiativeRoot(storeRoot, id), { recursive: true }); + fs.writeFileSync( + path.join(initiativeRoot(storeRoot, id), 'initiative.yaml'), + 'version: 1\nid: Invalid\n', + 'utf-8' + ); + } + + it('creates an initiative in a registered context store with JSON output', async () => { + const storeRoot = await setupRegisteredStore('team-context'); + + const result = await runCLI( + [ + 'initiative', + 'create', + 'launch-billing-flow', + '--store', + 'team-context', + '--title', + 'Launch Billing Flow', + '--summary', + 'Coordinate billing launch work.', + '--json', + ], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const payload = parseJson(result); + expect(payload.status).toEqual([]); + expect(payload.context_store).toEqual({ + id: 'team-context', + root: expect.any(String), + source: 'registry', + }); + expectSameExistingPath(payload.context_store.root, storeRoot); + expect(payload.initiative).toEqual( + expect.objectContaining({ + id: 'launch-billing-flow', + title: 'Launch Billing Flow', + summary: 'Coordinate billing launch work.', + status: 'exploring', + owners: [], + metadata: {}, + root: expect.any(String), + store_path: 'initiatives/launch-billing-flow', + }) + ); + expectSameExistingPath(payload.initiative.root, initiativeRoot(storeRoot, 'launch-billing-flow')); + expect(payload.initiative.created).toMatch(/^\d{4}-\d{2}-\d{2}$/u); + expect(payload.created_files).toEqual([...INITIATIVE_FILE_NAMES]); + + for (const fileName of INITIATIVE_FILE_NAMES) { + expect(fs.existsSync(path.join(initiativeRoot(storeRoot, 'launch-billing-flow'), fileName))).toBe(true); + } + expect(fs.existsSync(path.join(initiativeRoot(storeRoot, 'launch-billing-flow'), 'links.yaml'))).toBe(false); + expect(readInitiativeState(storeRoot, 'launch-billing-flow')).toEqual( + expect.objectContaining({ + id: 'launch-billing-flow', + title: 'Launch Billing Flow', + summary: 'Coordinate billing launch work.', + }) + ); + }); + + it('lists initiatives from an explicit context store path in sorted order', async () => { + const storeRoot = await setupUnregisteredStore('scratch-context'); + + for (const id of ['zeta-launch', 'alpha-launch']) { + const create = await runCLI( + [ + 'initiative', + 'create', + id, + '--store-path', + storeRoot, + '--title', + id, + '--summary', + `Summary for ${id}.`, + '--json', + ], + { cwd: tempDir, env } + ); + expect(create.exitCode).toBe(0); + } + + const list = await runCLI(['initiative', 'list', '--store-path', storeRoot, '--json'], { + cwd: tempDir, + env, + }); + + expect(list.exitCode).toBe(0); + expect(list.stderr).toBe(''); + const payload = parseJson(list); + expect(payload.status).toEqual([]); + expect(payload.context_store).toEqual({ + id: 'scratch-context', + root: expect.any(String), + source: 'path', + }); + expectSameExistingPath(payload.context_store.root, storeRoot); + expect(payload.initiatives.map((initiative: any) => initiative.id)).toEqual([ + 'alpha-launch', + 'zeta-launch', + ]); + }); + + it('prints readable human output for create and list', async () => { + const storeRoot = await setupRegisteredStore('team-context'); + + const create = await runCLI( + [ + 'initiative', + 'create', + 'launch-billing-flow', + '--store', + 'team-context', + '--title', + 'Launch Billing Flow', + '--summary', + 'Coordinate billing launch work.', + ], + { cwd: tempDir, env } + ); + + expect(create.exitCode).toBe(0); + expect(create.stdout).toContain('Created initiative'); + expect(create.stdout).toContain('ID: launch-billing-flow'); + expect(create.stdout).toContain('Context store: team-context'); + expect(create.stdout).toContain( + `Location: ${expectedExistingPath(initiativeRoot(storeRoot, 'launch-billing-flow'))}` + ); + expect(create.stdout).toContain('Created files (6):'); + expect(create.stdout).toContain('openspec initiative list --store team-context'); + + const list = await runCLI(['initiative', 'ls', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + + expect(list.exitCode).toBe(0); + expect(list.stdout).toContain('OpenSpec initiatives in team-context (1)'); + expect(list.stdout).toContain('launch-billing-flow'); + expect(list.stdout).not.toContain('Status: exploring'); + expect(list.stdout).toContain(`Location: ${expectedExistingPath(storeRoot)}`); + }); + + it('lists initiatives across registered context stores by default', async () => { + const platformRoot = await setupRegisteredStore('platform'); + const teamRoot = await setupRegisteredStore('team-context'); + + for (const [store, id] of [ + ['team-context', 'zeta-launch'], + ['platform', 'billing-launch'], + ['team-context', 'alpha-launch'], + ]) { + const create = await runCLI( + [ + 'initiative', + 'create', + id, + '--store', + store, + '--title', + id, + '--summary', + `Summary for ${id}.`, + '--json', + ], + { cwd: tempDir, env } + ); + expect(create.exitCode).toBe(0); + } + + const list = await runCLI(['initiative', 'list', '--json'], { cwd: tempDir, env }); + + expect(list.exitCode).toBe(0); + expect(list.stderr).toBe(''); + const payload = parseJson(list); + expect(payload.context_store).toBeNull(); + expect(payload.context_stores.map((store: any) => store.context_store.id)).toEqual([ + 'platform', + 'team-context', + ]); + expect(payload.initiatives.map((initiative: any) => `${initiative.store}/${initiative.id}`)).toEqual([ + 'platform/billing-launch', + 'team-context/alpha-launch', + 'team-context/zeta-launch', + ]); + expect(payload.initiatives[0]).toEqual( + expect.objectContaining({ + root: expect.any(String), + store_path: 'initiatives/billing-launch', + }) + ); + expect(payload.initiatives[1]).toEqual( + expect.objectContaining({ + root: expect.any(String), + store_path: 'initiatives/alpha-launch', + }) + ); + expectSameExistingPath( + payload.initiatives[0].root, + initiativeRoot(platformRoot, 'billing-launch') + ); + expectSameExistingPath(payload.initiatives[1].root, initiativeRoot(teamRoot, 'alpha-launch')); + }); + + it('prints compact all-store human output without initiative statuses', async () => { + await setupRegisteredStore('platform'); + await setupRegisteredStore('team-context'); + await runCLI( + [ + 'initiative', + 'create', + 'billing-launch', + '--store', + 'platform', + '--title', + 'Billing Launch', + '--summary', + 'Coordinate billing launch work.', + ], + { cwd: tempDir, env } + ); + + const list = await runCLI(['initiative', 'ls'], { cwd: tempDir, env }); + + expect(list.exitCode).toBe(0); + expect(list.stdout).toContain('OpenSpec initiatives (1 across 2 stores)'); + expect(list.stdout).toContain('ID'); + expect(list.stdout).toContain('Store'); + expect(list.stdout).toContain('Title'); + expect(list.stdout).toContain('billing-launch'); + expect(list.stdout).toContain('platform'); + expect(list.stdout).toContain('Billing Launch'); + expect(list.stdout).not.toContain('Status:'); + }); + + it('shows one initiative by searching registered context stores', async () => { + const storeRoot = await setupRegisteredStore('platform'); + const create = await runCLI( + [ + 'initiative', + 'create', + 'billing-launch', + '--store', + 'platform', + '--title', + 'Billing Launch', + '--summary', + 'Coordinate billing launch work.', + '--json', + ], + { cwd: tempDir, env } + ); + expect(create.exitCode).toBe(0); + + const show = await runCLI(['initiative', 'show', 'billing-launch', '--json'], { + cwd: tempDir, + env, + }); + + expect(show.exitCode).toBe(0); + expect(show.stderr).toBe(''); + const payload = parseJson(show); + expect(payload).toEqual({ + context_store: { + id: 'platform', + root: expect.any(String), + }, + initiative: { + version: 1, + id: 'billing-launch', + title: 'Billing Launch', + summary: 'Coordinate billing launch work.', + created: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/u), + root: expect.any(String), + store_path: 'initiatives/billing-launch', + metadata_path: expect.any(String), + }, + status: [], + }); + expectSameExistingPath(payload.context_store.root, storeRoot); + expectSameExistingPath(payload.initiative.root, initiativeRoot(storeRoot, 'billing-launch')); + expectSameExistingPath( + payload.initiative.metadata_path, + path.join(initiativeRoot(storeRoot, 'billing-launch'), 'initiative.yaml') + ); + expect(payload.initiative).not.toHaveProperty('status'); + expect(payload.initiative).not.toHaveProperty('owners'); + expect(payload.initiative).not.toHaveProperty('metadata'); + expect(payload.context_store).not.toHaveProperty('source'); + expect(payload).not.toHaveProperty('files'); + expect(payload).not.toHaveProperty('matches'); + }); + + it('shows an initiative from an explicit context store path', async () => { + const storeRoot = await setupUnregisteredStore('scratch-context'); + const create = await runCLI( + [ + 'initiative', + 'create', + 'scratch-launch', + '--store-path', + storeRoot, + '--title', + 'Scratch Launch', + '--summary', + 'Coordinate scratch launch work.', + '--json', + ], + { cwd: tempDir, env } + ); + expect(create.exitCode).toBe(0); + + const show = await runCLI( + ['initiative', 'show', 'scratch-launch', '--store-path', storeRoot, '--json'], + { cwd: tempDir, env } + ); + + expect(show.exitCode).toBe(0); + expect(parseJson(show).context_store).toEqual({ + id: 'scratch-context', + root: expect.any(String), + }); + expectSameExistingPath(parseJson(show).context_store.root, storeRoot); + }); + + it('prints compact human output for initiative show', async () => { + const storeRoot = await setupRegisteredStore('platform'); + await runCLI( + [ + 'initiative', + 'create', + 'billing-launch', + '--store', + 'platform', + '--title', + 'Billing Launch', + '--summary', + 'Coordinate billing launch work.', + ], + { cwd: tempDir, env } + ); + + const show = await runCLI(['initiative', 'show', 'billing-launch'], { cwd: tempDir, env }); + + expect(show.exitCode).toBe(0); + expect(show.stdout).toContain('OpenSpec initiative: Billing Launch'); + expect(show.stdout).toContain('ID: billing-launch'); + expect(show.stdout).toContain('Summary: Coordinate billing launch work.'); + expect(show.stdout).toContain('Context store: platform'); + const expectedInitiativeRoot = expectedExistingPath(initiativeRoot(storeRoot, 'billing-launch')); + expect(show.stdout).toContain(`Location: ${expectedInitiativeRoot}`); + expect(show.stdout).toContain( + `Metadata: ${path.join(expectedInitiativeRoot, 'initiative.yaml')}` + ); + expect(show.stdout).not.toContain('Status:'); + expect(show.stdout).not.toContain('Owners:'); + }); + + it('does not let unrelated invalid initiatives block exact show lookup', async () => { + const storeRoot = await setupRegisteredStore('platform'); + const create = await runCLI( + [ + 'initiative', + 'create', + 'billing-launch', + '--store', + 'platform', + '--title', + 'Billing Launch', + '--summary', + 'Coordinate billing launch work.', + '--json', + ], + { cwd: tempDir, env } + ); + expect(create.exitCode).toBe(0); + writeInvalidInitiative(storeRoot, 'broken-launch'); + + const show = await runCLI(['initiative', 'show', 'billing-launch', '--json'], { + cwd: tempDir, + env, + }); + + expect(show.exitCode).toBe(0); + expect(parseJson(show).initiative.id).toBe('billing-launch'); + }); + + it('reports show ambiguity and incomplete lookups with diagnostic matches', async () => { + const platformRoot = await setupRegisteredStore('platform'); + const financeRoot = await setupRegisteredStore('finance'); + + for (const store of ['platform', 'finance']) { + const create = await runCLI( + [ + 'initiative', + 'create', + 'billing-launch', + '--store', + store, + '--title', + 'Billing Launch', + '--summary', + `Coordinate ${store} billing launch work.`, + '--json', + ], + { cwd: tempDir, env } + ); + expect(create.exitCode).toBe(0); + } + + const ambiguous = await runCLI(['initiative', 'show', 'billing-launch', '--json'], { + cwd: tempDir, + env, + }); + expect(ambiguous.exitCode).toBe(1); + const ambiguousPayload = parseJson(ambiguous); + expect(ambiguousPayload).not.toHaveProperty('matches'); + expect(ambiguousPayload.status[0]).toEqual( + expect.objectContaining({ + code: 'initiative_ambiguous', + details: { + matches: [ + expect.objectContaining({ + context_store: { id: 'finance', root: expect.any(String) }, + }), + expect.objectContaining({ + context_store: { id: 'platform', root: expect.any(String) }, + }), + ], + }, + }) + ); + expectSameExistingPath( + ambiguousPayload.status[0].details.matches[0].context_store.root, + financeRoot + ); + expectSameExistingPath( + ambiguousPayload.status[0].details.matches[1].context_store.root, + platformRoot + ); + + 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(['initiative', 'show', 'billing-launch', '--json'], { + cwd: tempDir, + env, + }); + expect(incomplete.exitCode).toBe(1); + const incompletePayload = parseJson(incomplete); + expect(incompletePayload.status[0]).toEqual( + expect.objectContaining({ + code: 'initiative_lookup_incomplete', + details: { + matches: [ + expect.objectContaining({ + context_store: { id: 'platform', root: expect.any(String) }, + }), + ], + }, + }) + ); + expectSameExistingPath( + incompletePayload.status[0].details.matches[0].context_store.root, + platformRoot + ); + }); + + it('reports not found and invalid exact initiative show failures', async () => { + const storeRoot = await setupRegisteredStore('platform'); + + const missing = await runCLI(['initiative', 'show', 'missing-launch', '--json'], { + cwd: tempDir, + env, + }); + expect(missing.exitCode).toBe(1); + expect(parseJson(missing).status[0]).toEqual( + expect.objectContaining({ + code: 'initiative_not_found', + }) + ); + + writeInvalidInitiative(storeRoot, 'broken-launch'); + const invalid = await runCLI(['initiative', 'show', 'broken-launch', '--json'], { + cwd: tempDir, + env, + }); + expect(invalid.exitCode).toBe(1); + expect(parseJson(invalid).status[0]).toEqual( + expect.objectContaining({ + code: 'invalid_initiative', + }) + ); + + await writeContextStoreRegistryState( + { + version: 1, + stores: { + platform: { + backend: { + type: 'git', + local_path: storeRoot, + }, + }, + 'missing-context': { + backend: { + type: 'git', + local_path: path.join(tempDir, 'missing-context'), + }, + }, + }, + }, + { globalDataDir } + ); + const invalidWithUnreadableStore = await runCLI(['initiative', 'show', 'broken-launch', '--json'], { + cwd: tempDir, + env, + }); + expect(invalidWithUnreadableStore.exitCode).toBe(1); + expect(parseJson(invalidWithUnreadableStore).status[0]).toEqual( + expect.objectContaining({ + code: 'invalid_initiative', + target: 'initiative', + }) + ); + }); + + it('reports all-store empty and partial-read initiative list states', async () => { + const empty = await runCLI(['initiative', 'list'], { cwd: tempDir, env }); + expect(empty.exitCode).toBe(0); + expect(empty.stdout).toContain('No initiatives found because no context stores are registered.'); + + const readableRoot = await setupRegisteredStore('team-context'); + await runCLI( + [ + 'initiative', + 'create', + 'billing-launch', + '--store', + 'team-context', + '--title', + 'Billing Launch', + '--summary', + 'Coordinate billing launch work.', + ], + { cwd: tempDir, env } + ); + await writeContextStoreRegistryState( + { + version: 1, + stores: { + 'broken-context': { + backend: { + type: 'git', + local_path: path.join(tempDir, 'missing-context'), + }, + }, + 'team-context': { + backend: { + type: 'git', + local_path: readableRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + const partial = await runCLI(['initiative', 'list', '--json'], { cwd: tempDir, env }); + expect(partial.exitCode).toBe(0); + const partialPayload = parseJson(partial); + expect(partialPayload.initiatives.map((initiative: any) => initiative.id)).toEqual([ + 'billing-launch', + ]); + expect(partialPayload.status[0]).toEqual( + expect.objectContaining({ + severity: 'warning', + code: 'context_stores_partially_unreadable', + fix: 'openspec context-store doctor', + }) + ); + + const invalidRoot = await setupRegisteredStore('invalid-context'); + writeInvalidInitiative(invalidRoot, 'broken-launch'); + const invalidPartial = await runCLI(['initiative', 'list', '--json'], { cwd: tempDir, env }); + expect(invalidPartial.exitCode).toBe(0); + const invalidPartialPayload = parseJson(invalidPartial); + expect(invalidPartialPayload.initiatives.map((initiative: any) => initiative.id)).toEqual([ + 'billing-launch', + ]); + expect(invalidPartialPayload.status).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'context_stores_partially_unreadable', + }), + expect.objectContaining({ + code: 'initiative_collections_partially_invalid', + fix: 'Fix the invalid initiative folder state and retry.', + }), + ]) + ); + const invalidStore = invalidPartialPayload.context_stores.find( + (store: any) => store.context_store.id === 'invalid-context' + ); + expect(invalidStore?.status[0]).toEqual( + expect.objectContaining({ + code: 'invalid_initiative', + target: 'initiative', + }) + ); + + fs.rmSync(readableRoot, { recursive: true, force: true }); + const allInvalid = await runCLI(['initiative', 'list', '--json'], { cwd: tempDir, env }); + expect(allInvalid.exitCode).toBe(1); + expect(parseJson(allInvalid).status[0]).toEqual( + expect.objectContaining({ + code: 'initiative_collections_invalid', + target: 'initiative', + fix: 'Fix the invalid initiative folder state and retry.', + }) + ); + + fs.rmSync(invalidRoot, { recursive: true, force: true }); + const allUnreadable = await runCLI(['initiative', 'list', '--json'], { cwd: tempDir, env }); + expect(allUnreadable.exitCode).toBe(1); + expect(parseJson(allUnreadable).status[0]).toEqual( + expect.objectContaining({ + code: 'context_stores_unreadable', + fix: 'openspec context-store doctor', + }) + ); + }); + + it('reports structured JSON errors for selector and create failures', async () => { + const storeRoot = await setupRegisteredStore('team-context'); + + const missingSelector = await runCLI( + [ + 'initiative', + 'create', + 'launch-billing-flow', + '--title', + 'Launch Billing Flow', + '--summary', + 'Coordinate billing launch work.', + '--json', + ], + { cwd: tempDir, env } + ); + expect(missingSelector.exitCode).toBe(1); + expect(parseJson(missingSelector).status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_required', + target: 'context_store', + }) + ); + + const conflict = await runCLI( + ['initiative', 'list', '--store', 'team-context', '--store-path', storeRoot, '--json'], + { cwd: tempDir, env } + ); + expect(conflict.exitCode).toBe(1); + expect(parseJson(conflict).status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_selector_conflict', + }) + ); + + const blankSelector = await runCLI( + ['initiative', 'list', '--store', '', '--json'], + { cwd: tempDir, env } + ); + expect(blankSelector.exitCode).toBe(1); + expect(parseJson(blankSelector).status[0]).toEqual( + expect.objectContaining({ + code: 'invalid_context_store_id', + }) + ); + + const unknownStore = await runCLI( + ['initiative', 'list', '--store', 'unknown-context', '--json'], + { cwd: tempDir, env } + ); + expect(unknownStore.exitCode).toBe(1); + expect(parseJson(unknownStore).status[0]).toEqual( + expect.objectContaining({ + code: 'context_store_not_found', + }) + ); + + const missingTitle = await runCLI( + [ + 'initiative', + 'create', + 'missing-title', + '--store', + 'team-context', + '--summary', + 'Coordinate billing launch work.', + '--json', + ], + { cwd: tempDir, env } + ); + expect(missingTitle.exitCode).toBe(1); + expect(parseJson(missingTitle).status[0]).toEqual( + expect.objectContaining({ + code: 'initiative_title_required', + target: 'initiative.title', + }) + ); + + const create = await runCLI( + [ + 'initiative', + 'create', + 'duplicate-launch', + '--store', + 'team-context', + '--title', + 'Duplicate Launch', + '--summary', + 'Coordinate duplicate launch work.', + ], + { cwd: tempDir, env } + ); + expect(create.exitCode).toBe(0); + + const duplicate = await runCLI( + [ + 'initiative', + 'create', + 'duplicate-launch', + '--store', + 'team-context', + '--title', + 'Duplicate Launch', + '--summary', + 'Coordinate duplicate launch work.', + '--json', + ], + { cwd: tempDir, env } + ); + expect(duplicate.exitCode).toBe(1); + expect(parseJson(duplicate).status[0]).toEqual( + expect.objectContaining({ + code: 'initiative_already_exists', + target: 'initiative.id', + }) + ); + }); + + it('registers initiative subcommands for shell completions', () => { + const initiative = COMMAND_REGISTRY.find((command) => command.name === 'initiative'); + const create = initiative?.subcommands?.find((command) => command.name === 'create'); + const show = initiative?.subcommands?.find((command) => command.name === 'show'); + const list = initiative?.subcommands?.find((command) => command.name === 'list'); + const ls = initiative?.subcommands?.find((command) => command.name === 'ls'); + + expect(initiative?.subcommands?.map((command) => command.name)).toEqual([ + 'create', + 'show', + 'list', + 'ls', + ]); + expect(create?.positionals).toEqual([ + { + name: 'id', + optional: true, + }, + ]); + expect(create?.flags?.map((flag) => flag.name)).toEqual([ + 'store', + 'store-path', + 'title', + 'summary', + 'json', + ]); + expect(create?.flags?.find((flag) => flag.name === 'store')?.takesValue).toBe(true); + expect(create?.flags?.find((flag) => flag.name === 'store-path')?.takesValue).toBe(true); + expect(show?.positionals).toEqual([ + { + name: 'id', + }, + ]); + expect(show?.flags?.map((flag) => flag.name)).toEqual(['store', 'store-path', 'json']); + expect(list?.flags?.map((flag) => flag.name)).toEqual(['store', 'store-path', 'json']); + expect(ls?.flags?.map((flag) => flag.name)).toEqual(['store', 'store-path', 'json']); + }); +}); diff --git a/test/commands/workspace-initiative-open.test.ts b/test/commands/workspace-initiative-open.test.ts new file mode 100644 index 0000000000..596931d8bc --- /dev/null +++ b/test/commands/workspace-initiative-open.test.ts @@ -0,0 +1,635 @@ +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 { + createInitiative, + getGlobalDataDir, + getManagedWorkspaceRoot, + getWorkspaceCodeWorkspacePath, + getWorkspaceViewStatePath, + mountInitiativesCollection, + parseWorkspaceViewState, + registerContextStore, + writeContextStoreMetadataState, +} from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; + +describe('workspace open initiative views', () => { + let tempDir: string; + let dataHome: string; + let configHome: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-initiative-')); + dataHome = path.join(tempDir, 'data'); + configHome = path.join(tempDir, 'config'); + env = { + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + }); + + 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 expectedExistingPath(existingPath: string): string { + return fs.realpathSync.native(existingPath); + } + + function expectSameExistingPath(actualPath: string, expectedPath: string): void { + expect(fs.realpathSync.native(actualPath)).toBe(expectedExistingPath(expectedPath)); + } + + 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)}` + ); + } + } + + async function setupInitiative(storeId = 'platform', initiativeId = 'billing-launch') { + const storeRoot = mkdir(`stores/${storeId}`); + await registerContextStore({ + id: storeId, + localPath: storeRoot, + globalDataDir, + }); + const state = await createInitiative({ + collection: mountInitiativesCollection(storeRoot), + id: initiativeId, + title: 'Billing Launch', + summary: 'Coordinate the billing launch.', + }); + + return { + storeId, + storeRoot, + initiativeId, + initiativeRoot: path.join(storeRoot, 'initiatives', initiativeId), + state, + }; + } + + function createFakeExecutable(name: string): { binDir: string; logPath: string } { + const binDir = path.join(tempDir, `fake-${name}-bin`); + const logPath = path.join(tempDir, `${name}-launch.json`); + const recorderPath = path.join(binDir, 'record-launch.cjs'); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync( + recorderPath, + "const fs = require('node:fs');\nfs.writeFileSync(process.env.OPENSPEC_FAKE_OPEN_LOG, JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) }));\n" + ); + + const posixExecutable = path.join(binDir, name); + fs.writeFileSync(posixExecutable, '#!/bin/sh\nnode "$OPENSPEC_FAKE_OPEN_RECORDER" "$@"\n'); + fs.chmodSync(posixExecutable, 0o755); + fs.writeFileSync( + path.join(binDir, `${name}.cmd`), + '@echo off\r\nnode "%OPENSPEC_FAKE_OPEN_RECORDER%" %*\r\n' + ); + + return { binDir, logPath }; + } + + function envWithFakeExecutable(fake: { binDir: string; logPath: string }): NodeJS.ProcessEnv { + return { + ...env, + PATH: `${fake.binDir}${path.delimiter}${process.env.PATH ?? ''}`, + OPENSPEC_FAKE_OPEN_RECORDER: path.join(fake.binDir, 'record-launch.cjs'), + OPENSPEC_FAKE_OPEN_LOG: fake.logPath, + }; + } + + function readLaunchLog(logPath: string): { cwd: string; args: string[] } { + return JSON.parse(fs.readFileSync(logPath, 'utf-8')); + } + + it('creates a default local view for an initiative and returns a JSON receipt', async () => { + const initiative = await setupInitiative(); + const code = createFakeExecutable('code'); + + const result = await runCLI( + [ + 'workspace', + 'open', + '--initiative', + 'billing-launch', + '--store', + 'platform', + '--editor', + '--json', + '--no-interactive', + ], + { cwd: tempDir, env: envWithFakeExecutable(code) } + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const payload = parseJson(result); + const workspaceRoot = getManagedWorkspaceRoot('billing-launch', { globalDataDir }); + + expect(payload.workspace).toEqual({ + name: 'billing-launch', + root: expect.any(String), + }); + expectSameExistingPath(payload.workspace.root, workspaceRoot); + expect(payload.context).toEqual({ + context_store: { + id: 'platform', + root: expect.any(String), + selector: { + kind: 'registry', + id: 'platform', + }, + }, + initiative: expect.objectContaining({ + id: 'billing-launch', + title: 'Billing Launch', + root: expect.any(String), + }), + }); + expectSameExistingPath(payload.context.context_store.root, initiative.storeRoot); + expectSameExistingPath(payload.context.initiative.root, initiative.initiativeRoot); + expect(payload.generated_files).toEqual({ + agents: expect.any(String), + code_workspace: expect.any(String), + }); + expectSameExistingPath(payload.generated_files.agents, path.join(workspaceRoot, 'AGENTS.md')); + expectSameExistingPath( + payload.generated_files.code_workspace, + getWorkspaceCodeWorkspacePath(workspaceRoot, 'billing-launch') + ); + expect(payload.opened_roots).toEqual([ + { + kind: 'workspace', + path: expect.any(String), + }, + { + kind: 'initiative', + name: 'billing-launch', + path: expect.any(String), + }, + ]); + expectSameExistingPath(payload.opened_roots[0].path, workspaceRoot); + expectSameExistingPath(payload.opened_roots[1].path, initiative.initiativeRoot); + expect(payload.skipped_roots).toEqual([]); + expect(payload.advisory_edit_boundaries).toEqual({ + allowed_edit_roots: [], + coordination_roots: [expect.any(String)], + enforcement: 'advisory', + }); + expectSameExistingPath( + payload.advisory_edit_boundaries.coordination_roots[0], + initiative.initiativeRoot + ); + expect(payload.launch).toEqual({ + attempted: true, + status: 'succeeded', + }); + + const viewState = parseWorkspaceViewState( + fs.readFileSync(getWorkspaceViewStatePath(workspaceRoot), 'utf-8') + ); + expect(viewState).toEqual( + expect.objectContaining({ + version: 1, + name: 'billing-launch', + context: { + kind: 'initiative', + store: { + id: 'platform', + selector: { + kind: 'registry', + id: 'platform', + }, + }, + initiative: { + id: 'billing-launch', + }, + }, + links: {}, + preferred_opener: { + kind: 'editor', + id: 'vscode', + }, + }) + ); + expect(fs.existsSync(path.join(workspaceRoot, '.openspec-workspace'))).toBe(false); + expect(fs.existsSync(path.join(globalDataDir, 'workspaces', 'registry.yaml'))).toBe(false); + expect(fs.readFileSync(path.join(workspaceRoot, 'AGENTS.md'), 'utf-8')).toContain( + 'Initiative title: Billing Launch' + ); + expect(JSON.parse(fs.readFileSync(getWorkspaceCodeWorkspacePath(workspaceRoot, 'billing-launch'), 'utf-8')).folders).toEqual([ + { path: '.' }, + { + name: 'initiative:billing-launch', + path: expect.any(String), + }, + ]); + const codeWorkspaceFolders = JSON.parse( + fs.readFileSync(getWorkspaceCodeWorkspacePath(workspaceRoot, 'billing-launch'), 'utf-8') + ).folders; + expectSameExistingPath(codeWorkspaceFolders[1].path, initiative.initiativeRoot); + + const launch = readLaunchLog(code.logPath); + expect(fs.realpathSync.native(launch.cwd)).toBe(fs.realpathSync.native(workspaceRoot)); + expect(launch.args).toHaveLength(1); + expectSameExistingPath( + launch.args[0], + getWorkspaceCodeWorkspacePath(workspaceRoot, 'billing-launch') + ); + }); + + it('persists a path-bound context store and reopens without registry registration', async () => { + const storeRoot = mkdir('stores/scratch-context'); + const initiativeId = 'scratch-launch'; + await writeContextStoreMetadataState(storeRoot, { + version: 1, + id: 'scratch-context', + }); + await createInitiative({ + collection: mountInitiativesCollection(storeRoot), + id: initiativeId, + title: 'Scratch Launch', + summary: 'Coordinate local scratch work.', + }); + const code = createFakeExecutable('code'); + + const open = await runCLI( + [ + 'workspace', + 'open', + '--initiative', + initiativeId, + '--store-path', + storeRoot, + '--editor', + '--json', + '--no-interactive', + ], + { cwd: tempDir, env: envWithFakeExecutable(code) } + ); + + expect(open.exitCode).toBe(0); + const payload = parseJson(open); + expect(payload.context.context_store).toEqual({ + id: 'scratch-context', + root: expect.any(String), + selector: { + kind: 'path', + path: expect.any(String), + observed_id: 'scratch-context', + }, + }); + expectSameExistingPath(payload.context.context_store.root, storeRoot); + expectSameExistingPath(payload.context.context_store.selector.path, storeRoot); + + const workspaceRoot = getManagedWorkspaceRoot(initiativeId, { globalDataDir }); + const viewState = parseWorkspaceViewState( + fs.readFileSync(getWorkspaceViewStatePath(workspaceRoot), 'utf-8') + ); + expect(viewState.context).toEqual({ + kind: 'initiative', + store: { + id: 'scratch-context', + selector: { + kind: 'path', + path: expect.any(String), + observed_id: 'scratch-context', + }, + }, + initiative: { + id: initiativeId, + }, + }); + const storedSelector = viewState.context?.store.selector; + expect(storedSelector?.kind).toBe('path'); + expectSameExistingPath(storedSelector?.kind === 'path' ? storedSelector.path : '', storeRoot); + + const reopen = await runCLI( + ['workspace', 'open', initiativeId, '--editor', '--json', '--no-interactive'], + { cwd: tempDir, env: envWithFakeExecutable(code) } + ); + + expect(reopen.exitCode).toBe(0); + const reopenedPayload = parseJson(reopen); + expect(reopenedPayload.status).toEqual([]); + expectSameExistingPath(reopenedPayload.context.context_store.root, storeRoot); + expectSameExistingPath(reopenedPayload.context.context_store.selector.path, storeRoot); + + const doctor = await runCLI( + ['workspace', 'doctor', '--workspace', initiativeId, '--json'], + { cwd: tempDir, env } + ); + + expect(doctor.exitCode).toBe(0); + expect(parseJson(doctor).workspace.status).toEqual([]); + }); + + it('reports path-bound context store id drift in workspace doctor', async () => { + const storeRoot = mkdir('stores/drift-context'); + const initiativeId = 'drift-launch'; + await writeContextStoreMetadataState(storeRoot, { + version: 1, + id: 'drift-context', + }); + await createInitiative({ + collection: mountInitiativesCollection(storeRoot), + id: initiativeId, + title: 'Drift Launch', + summary: 'Coordinate local drift work.', + }); + const code = createFakeExecutable('code'); + + const open = await runCLI( + [ + 'workspace', + 'open', + '--initiative', + initiativeId, + '--store-path', + storeRoot, + '--editor', + '--json', + '--no-interactive', + ], + { cwd: tempDir, env: envWithFakeExecutable(code) } + ); + expect(open.exitCode).toBe(0); + + await writeContextStoreMetadataState(storeRoot, { + version: 1, + id: 'renamed-context', + }); + + const doctor = await runCLI( + ['workspace', 'doctor', '--workspace', initiativeId, '--json'], + { cwd: tempDir, env } + ); + + expect(doctor.exitCode).toBe(0); + expect(parseJson(doctor).workspace.status).toContainEqual( + expect.objectContaining({ + severity: 'warning', + code: 'context_store_binding_id_changed', + target: 'workspace.context.store.metadata.id', + }) + ); + }); + + it('does not conflate registry and path bindings that share a store id', async () => { + const registered = await setupInitiative('platform', 'billing-launch'); + const pathStoreRoot = mkdir('stores/platform-copy'); + await writeContextStoreMetadataState(pathStoreRoot, { + version: 1, + id: 'platform', + }); + await createInitiative({ + collection: mountInitiativesCollection(pathStoreRoot), + id: registered.initiativeId, + title: 'Billing Launch Copy', + summary: 'Coordinate a local copy.', + }); + const code = createFakeExecutable('code'); + + const registryOpen = await runCLI( + [ + 'workspace', + 'open', + '--initiative', + `${registered.storeId}/${registered.initiativeId}`, + '--editor', + '--json', + '--no-interactive', + ], + { cwd: tempDir, env: envWithFakeExecutable(code) } + ); + expect(registryOpen.exitCode).toBe(0); + + const pathOpen = await runCLI( + [ + 'workspace', + 'open', + '--initiative', + registered.initiativeId, + '--store-path', + pathStoreRoot, + '--editor', + '--json', + '--no-interactive', + ], + { cwd: tempDir, env: envWithFakeExecutable(code) } + ); + + expect(pathOpen.exitCode).toBe(1); + expect(parseJson(pathOpen).status[0]).toEqual( + expect.objectContaining({ + code: 'workspace_name_collision', + }) + ); + }); + + it('refuses to silently bind an existing non-initiative workspace', async () => { + const initiative = await setupInitiative(); + const repo = mkdir('repos/api'); + const setup = await runCLI( + [ + 'workspace', + 'setup', + '--no-interactive', + '--json', + '--name', + 'team-local', + '--link', + `api=${repo}`, + '--opener', + 'editor', + ], + { cwd: tempDir, env } + ); + expect(setup.exitCode).toBe(0); + + const result = await runCLI( + [ + 'workspace', + 'open', + 'team-local', + '--initiative', + `${initiative.storeId}/${initiative.initiativeId}`, + '--editor', + '--json', + '--no-interactive', + ], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'workspace_context_bind_required', + }) + ); + }); + + it('reports initiative read failures separately from context store failures', async () => { + const initiative = await setupInitiative(); + const code = createFakeExecutable('code'); + const open = await runCLI( + [ + 'workspace', + 'open', + '--initiative', + `${initiative.storeId}/${initiative.initiativeId}`, + '--editor', + '--json', + '--no-interactive', + ], + { cwd: tempDir, env: envWithFakeExecutable(code) } + ); + expect(open.exitCode).toBe(0); + + fs.writeFileSync( + path.join(initiative.initiativeRoot, 'initiative.yaml'), + 'version: 1\nid: Invalid\n', + 'utf-8' + ); + + const doctor = await runCLI( + ['workspace', 'doctor', '--workspace', initiative.initiativeId, '--json'], + { cwd: tempDir, env } + ); + + expect(doctor.exitCode).toBe(0); + expect(parseJson(doctor).workspace.status[0]).toEqual( + expect.objectContaining({ + code: 'workspace_initiative_unavailable', + target: 'workspace.context.initiative', + }) + ); + }); + + it('warns and skips missing linked roots while opening stored initiative context', async () => { + const initiative = await setupInitiative(); + const code = createFakeExecutable('code'); + const repo = mkdir('repos/api'); + const open = await runCLI( + [ + 'workspace', + 'open', + 'team-billing', + '--initiative', + `${initiative.storeId}/${initiative.initiativeId}`, + '--editor', + '--json', + '--no-interactive', + ], + { cwd: tempDir, env: envWithFakeExecutable(code) } + ); + expect(open.exitCode).toBe(0); + + const expectedRepo = expectedExistingPath(repo); + const link = await runCLI( + ['workspace', 'link', 'api', repo, '--workspace', 'team-billing', '--json'], + { cwd: tempDir, env } + ); + expect(link.exitCode).toBe(0); + fs.rmSync(repo, { recursive: true, force: true }); + + const reopen = await runCLI( + ['workspace', 'open', 'team-billing', '--editor', '--json', '--no-interactive'], + { cwd: tempDir, env: envWithFakeExecutable(code) } + ); + + expect(reopen.exitCode).toBe(0); + const payload = parseJson(reopen); + expect(payload.opened_roots).toEqual([ + { + kind: 'workspace', + path: expect.any(String), + }, + { + kind: 'initiative', + name: initiative.initiativeId, + path: expect.any(String), + }, + ]); + expectSameExistingPath( + payload.opened_roots[0].path, + getManagedWorkspaceRoot('team-billing', { globalDataDir }) + ); + expectSameExistingPath(payload.opened_roots[1].path, initiative.initiativeRoot); + expect(payload.skipped_roots).toEqual([ + { + kind: 'link', + name: 'api', + path: expectedRepo, + reason: 'path-missing', + }, + ]); + expect(payload.warnings).toContainEqual( + expect.objectContaining({ + code: 'workspace_open_link_skipped', + target: 'links.api.path', + }) + ); + }); + + it('requires an explicit workspace name when multiple local views point at one initiative', async () => { + const initiative = await setupInitiative(); + const code = createFakeExecutable('code'); + + for (const name of ['team-a-billing', 'team-b-billing']) { + const open = await runCLI( + [ + 'workspace', + 'open', + name, + '--initiative', + `${initiative.storeId}/${initiative.initiativeId}`, + '--editor', + '--json', + '--no-interactive', + ], + { cwd: tempDir, env: envWithFakeExecutable(code) } + ); + expect(open.exitCode).toBe(0); + } + + const ambiguous = await runCLI( + [ + 'workspace', + 'open', + '--initiative', + `${initiative.storeId}/${initiative.initiativeId}`, + '--editor', + '--json', + '--no-interactive', + ], + { cwd: tempDir, env: envWithFakeExecutable(code) } + ); + + expect(ambiguous.exitCode).toBe(1); + expect(parseJson(ambiguous).status[0]).toEqual( + expect.objectContaining({ + code: 'workspace_initiative_selection_ambiguous', + }) + ); + }); +}); diff --git a/test/commands/workspace.interactive.test.ts b/test/commands/workspace.interactive.test.ts index 9846346e2c..9f5556a9a9 100644 --- a/test/commands/workspace.interactive.test.ts +++ b/test/commands/workspace.interactive.test.ts @@ -6,8 +6,8 @@ import * as path from 'node:path'; import { getManagedWorkspaceRoot, - getWorkspaceLocalStatePath, - parseWorkspaceLocalState, + getWorkspaceViewStatePath, + parseWorkspaceViewState, } from '../../src/core/workspace/index.js'; const searchableMultiSelectMock = vi.hoisted(() => vi.fn(async () => [])); @@ -101,14 +101,12 @@ describe('workspace command interactive flows', () => { } function expectedExistingPath(existingPath: string): string { - return process.platform === 'win32' ? fs.realpathSync.native(existingPath) : existingPath; + return fs.realpathSync.native(existingPath); } - function readLocalState(workspaceName: string) { + function readWorkspaceState(workspaceName: string) { const workspaceRoot = getManagedWorkspaceRoot(workspaceName); - return parseWorkspaceLocalState( - fs.readFileSync(getWorkspaceLocalStatePath(workspaceRoot), 'utf-8') - ); + return parseWorkspaceViewState(fs.readFileSync(getWorkspaceViewStatePath(workspaceRoot), 'utf-8')); } it('asks for the workspace name first and validates kebab-case before asking for links', async () => { @@ -156,7 +154,7 @@ describe('workspace command interactive flows', () => { ]), }) ); - expect(readLocalState('platform').paths).toEqual({ api: expectedApi }); + expect(readWorkspaceState('platform').links).toEqual({ api: expectedApi }); }); it('handles prompt cancellation without printing the raw SIGINT error', async () => { @@ -217,7 +215,7 @@ describe('workspace command interactive flows', () => { expect(process.exitCode).toBeUndefined(); expect(confirm).not.toHaveBeenCalled(); - expect(readLocalState('platform').preferred_opener).toEqual({ + expect(readWorkspaceState('platform').preferred_opener).toEqual({ kind: 'agent', id: 'github-copilot', }); @@ -268,7 +266,7 @@ describe('workspace command interactive flows', () => { expect(process.exitCode).toBeUndefined(); expect(searchableMultiSelectMock).toHaveBeenCalledTimes(1); - expect(readLocalState('platform').workspace_skills).toEqual( + expect(readWorkspaceState('platform').workspace_skills).toEqual( expect.objectContaining({ selected_agents: ['codex', 'claude'], last_applied_workflow_ids: ['propose', 'explore', 'apply', 'sync', 'archive'], @@ -321,7 +319,7 @@ describe('workspace command interactive flows', () => { expect(consoleLogSpy).toHaveBeenCalledWith( `Link name 'api' is already linked to ${expectedFirstApi}.` ); - expect(readLocalState('platform').paths).toEqual({ + expect(readWorkspaceState('platform').links).toEqual({ api: expectedFirstApi, 'api-archive': expectedSecondApi, }); @@ -360,7 +358,7 @@ describe('workspace command interactive flows', () => { 'Link name:', ]); expect(confirm).not.toHaveBeenCalled(); - expect(readLocalState('platform').paths).toEqual({ + expect(readWorkspaceState('platform').links).toEqual({ root: expectedLinkedRoot, }); }); @@ -429,7 +427,7 @@ describe('workspace command interactive flows', () => { expect.arrayContaining(['editor', 'github-copilot']) ); expect(consoleLogSpy).toHaveBeenCalledWith('Opening workspace: platform'); - expect(readLocalState('platform').preferred_opener).toBeUndefined(); + expect(readWorkspaceState('platform').preferred_opener).toBeUndefined(); }); it('fails workspace open without prompting when no opener is available', async () => { diff --git a/test/commands/workspace.test.ts b/test/commands/workspace.test.ts index 4554729d6f..aa15f05b9d 100644 --- a/test/commands/workspace.test.ts +++ b/test/commands/workspace.test.ts @@ -10,19 +10,20 @@ import { } from '../../src/commands/workspace/operations.js'; import { WORKSPACE_CHANGES_DIR_NAME, - WORKSPACE_LOCAL_STATE_FILE_NAME, - WORKSPACE_LOCAL_STATE_IGNORE_PATTERN, + WORKSPACE_GUIDANCE_END_MARKER, + WORKSPACE_GUIDANCE_START_MARKER, WORKSPACE_METADATA_DIR_NAME, - WORKSPACE_SHARED_STATE_FILE_NAME, getWorkspaceCodeWorkspacePath, getManagedWorkspaceRoot, - getWorkspaceLocalStatePath, getWorkspaceRegistryPath, - getWorkspaceSharedStatePath, - parseWorkspaceLocalState, - parseWorkspaceRegistryState, - parseWorkspaceSharedState, + getWorkspaceViewStatePath, + parseWorkspaceViewState, } from '../../src/core/workspace/index.js'; +import { + WORKSPACE_LEGACY_LOCAL_STATE_FILE_NAME, + WORKSPACE_LEGACY_LOCAL_STATE_IGNORE_PATTERN, + WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME, +} from '../../src/core/workspace/legacy-state.js'; import { FileSystemUtils } from '../../src/utils/file-system.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; @@ -55,7 +56,12 @@ describe('workspace command', () => { } function expectedExistingPath(existingPath: string): string { - return process.platform === 'win32' ? fs.realpathSync.native(existingPath) : existingPath; + return fs.realpathSync.native(existingPath); + } + + function expectSameExistingPath(actualPath: string | null, expectedPath: string): void { + expect(actualPath).not.toBeNull(); + expect(fs.realpathSync.native(actualPath as string)).toBe(fs.realpathSync.native(expectedPath)); } function parseJson(result: RunCLIResult): any { @@ -124,10 +130,8 @@ describe('workspace command', () => { return parseJson(result); } - function readLocalState(workspaceRoot: string) { - return parseWorkspaceLocalState( - fs.readFileSync(getWorkspaceLocalStatePath(workspaceRoot), 'utf-8') - ); + function readWorkspaceState(workspaceRoot: string) { + return parseWorkspaceViewState(fs.readFileSync(getWorkspaceViewStatePath(workspaceRoot), 'utf-8')); } function writeGlobalConfig(config: Record<string, unknown>): void { @@ -136,12 +140,6 @@ describe('workspace command', () => { fs.writeFileSync(path.join(configDir, 'config.json'), `${JSON.stringify(config, null, 2)}\n`); } - function readSharedState(workspaceRoot: string) { - return parseWorkspaceSharedState( - fs.readFileSync(getWorkspaceSharedStatePath(workspaceRoot), 'utf-8') - ); - } - it('sets up a workspace with required links, records local state, and lists it through ls', async () => { const api = mkdir('repos/api'); mkdir('repos/api/openspec/specs'); @@ -170,35 +168,21 @@ describe('workspace command', () => { }), ]); - const sharedState = parseWorkspaceSharedState( - fs.readFileSync(getWorkspaceSharedStatePath(workspaceRoot), 'utf-8') - ); - const localState = parseWorkspaceLocalState( - fs.readFileSync(getWorkspaceLocalStatePath(workspaceRoot), 'utf-8') - ); - const registry = parseWorkspaceRegistryState( - fs.readFileSync( - getWorkspaceRegistryPath({ globalDataDir: path.join(dataHome, 'openspec') }), - 'utf-8' - ) - ); + const workspaceState = readWorkspaceState(workspaceRoot); - expect(sharedState).toEqual({ + expect(workspaceState).toEqual({ version: 1, name: 'platform', + context: null, links: { - api: {}, - checkout: {}, + api: expectedApi, + checkout: expectedCheckout, }, }); - expect(localState.paths).toEqual({ - api: expectedApi, - checkout: expectedCheckout, - }); - expect(localState.preferred_opener).toBeUndefined(); - expect(registry.workspaces.platform).toBe(expectedWorkspaceRoot); - expect(fs.readFileSync(path.join(workspaceRoot, '.gitignore'), 'utf-8')).toContain( - WORKSPACE_LOCAL_STATE_IGNORE_PATTERN + expect(workspaceState.preferred_opener).toBeUndefined(); + expect(fs.existsSync(getWorkspaceRegistryPath({ globalDataDir: path.join(dataHome, 'openspec') }))).toBe(false); + expect(fs.readFileSync(path.join(workspaceRoot, '.gitignore'), 'utf-8')).not.toContain( + WORKSPACE_LEGACY_LOCAL_STATE_IGNORE_PATTERN ); expect(fs.readFileSync(path.join(workspaceRoot, '.gitignore'), 'utf-8')).toContain( 'platform.code-workspace' @@ -264,7 +248,7 @@ describe('workspace command', () => { ], }) ); - expect(readLocalState(setup.workspace.root).workspace_skills).toBeUndefined(); + expect(readWorkspaceState(setup.workspace.root).workspace_skills).toBeUndefined(); expect(fs.existsSync(path.join(setup.workspace.root, '.codex'))).toBe(false); }); @@ -331,7 +315,7 @@ describe('workspace command', () => { expect(fs.readdirSync(api).sort()).toEqual(linkedEntriesBefore); expect(fs.existsSync(path.join(api, '.codex'))).toBe(false); - expect(readLocalState(workspaceRoot).workspace_skills).toEqual( + expect(readWorkspaceState(workspaceRoot).workspace_skills).toEqual( expect.objectContaining({ selected_agents: ['codex'], last_applied_profile: 'custom', @@ -359,7 +343,7 @@ describe('workspace command', () => { ], }) ); - expect(readLocalState(setup.workspace.root).workspace_skills).toEqual( + expect(readWorkspaceState(setup.workspace.root).workspace_skills).toEqual( expect.objectContaining({ selected_agents: [], last_applied_workflow_ids: ['propose', 'explore', 'apply', 'sync', 'archive'], @@ -442,7 +426,7 @@ describe('workspace command', () => { expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'prompts'))).toBe(false); expect(fs.readdirSync(api).sort()).toEqual(linkedEntriesBefore); expect(fs.existsSync(path.join(api, '.codex'))).toBe(false); - expect(readLocalState(workspaceRoot).workspace_skills).toEqual( + expect(readWorkspaceState(workspaceRoot).workspace_skills).toEqual( expect.objectContaining({ selected_agents: ['codex'], last_applied_profile: 'core', @@ -488,7 +472,7 @@ describe('workspace command', () => { expect(update.exitCode).toBe(0); expect(update.stdout).toContain('Workspace update complete'); expect(update.stdout).toContain('update-redirect'); - expect(update.stdout).not.toContain('not recorded in the local workspace registry'); + expect(update.stdout).not.toContain('not in the managed local workspace views list'); expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-sync-specs', 'SKILL.md'))).toBe(true); expect(fs.readdirSync(api).sort()).toEqual(linkedEntriesBefore); @@ -550,7 +534,7 @@ describe('workspace command', () => { expect.objectContaining({ tool_id: 'claude', workflow_ids: ['apply'] }), ]); expect(fs.existsSync(path.join(workspaceRoot, '.claude', 'skills', 'openspec-apply-change', 'SKILL.md'))).toBe(true); - expect(readLocalState(workspaceRoot).workspace_skills?.selected_agents).toEqual(['codex', 'claude']); + expect(readWorkspaceState(workspaceRoot).workspace_skills?.selected_agents).toEqual(['codex', 'claude']); const removeAgent = await runCLI( ['workspace', 'update', '--workspace', 'agent-change', '--tools', 'claude', '--json'], @@ -570,7 +554,7 @@ describe('workspace command', () => { ]); expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-apply-change'))).toBe(false); expect(fs.existsSync(path.join(userSkillDir, 'SKILL.md'))).toBe(true); - expect(readLocalState(workspaceRoot).workspace_skills?.selected_agents).toEqual(['claude']); + expect(readWorkspaceState(workspaceRoot).workspace_skills?.selected_agents).toEqual(['claude']); }); it('does not remove unmanaged skill directories that collide with OpenSpec workflow names', async () => { @@ -593,7 +577,7 @@ describe('workspace command', () => { expect(update.exitCode).toBe(0); expect(parseJson(update).workspace_skills.removed).toEqual([]); expect(fs.existsSync(path.join(collidingSkillDir, 'SKILL.md'))).toBe(true); - expect(readLocalState(workspaceRoot).workspace_skills?.selected_agents).toEqual([]); + expect(readWorkspaceState(workspaceRoot).workspace_skills?.selected_agents).toEqual([]); }); it('does not record workspace skills as applied when an update fails', async () => { @@ -624,7 +608,7 @@ describe('workspace command', () => { tool_id: 'codex', }), ]); - expect(readLocalState(workspaceRoot).workspace_skills).toEqual( + expect(readWorkspaceState(workspaceRoot).workspace_skills).toEqual( expect.objectContaining({ selected_agents: ['codex'], last_applied_profile: 'custom', @@ -635,7 +619,20 @@ describe('workspace command', () => { it('reports a no-op workspace update when no stored skill selection exists', async () => { const api = mkdir('repos/api'); + const linkedEntriesBefore = fs.readdirSync(api).sort(); const setup = await setupWorkspace('no-stored-skills', [`api=${api}`]); + const agentsPath = path.join(setup.workspace.root, 'AGENTS.md'); + fs.writeFileSync( + agentsPath, + `# User Notes + +${WORKSPACE_GUIDANCE_START_MARKER} +# OpenSpec Workspace Guidance + +Use \`changes/\` for workspace-level planning. +${WORKSPACE_GUIDANCE_END_MARKER} +` + ); const update = await runCLI( ['workspace', 'update', '--workspace', 'no-stored-skills', '--json'], @@ -658,7 +655,14 @@ describe('workspace command', () => { ], }) ); - expect(readLocalState(setup.workspace.root).workspace_skills).toBeUndefined(); + const agentsContent = fs.readFileSync(agentsPath, 'utf-8'); + expect(agentsContent).toContain('# User Notes'); + expect(agentsContent).toContain( + 'Use initiatives for durable cross-team or cross-repo intent' + ); + expect(agentsContent).not.toContain('Use `changes/` for workspace-level planning'); + expect(fs.readdirSync(api).sort()).toEqual(linkedEntriesBefore); + expect(readWorkspaceState(setup.workspace.root).workspace_skills).toBeUndefined(); expect(fs.existsSync(path.join(setup.workspace.root, '.codex'))).toBe(false); }); @@ -710,7 +714,7 @@ describe('workspace command', () => { message: expect.stringContaining('not-real'), }) ); - expect(readLocalState(setup.workspace.root).workspace_skills).toBeUndefined(); + expect(readWorkspaceState(setup.workspace.root).workspace_skills).toBeUndefined(); }); it('preserves equals signs in inferred and explicit setup link paths', async () => { @@ -734,10 +738,8 @@ describe('workspace command', () => { }), ]); - const localState = parseWorkspaceLocalState( - fs.readFileSync(getWorkspaceLocalStatePath(setup.workspace.root), 'utf-8') - ); - expect(localState.paths).toEqual({ + const workspaceState = readWorkspaceState(setup.workspace.root); + expect(workspaceState.links).toEqual({ api: expectedExplicit, 'foo=bar': expectedInferred, }); @@ -749,15 +751,15 @@ describe('workspace command', () => { const editor = await setupWorkspace('editor-workspace', [`api=${api}`], ['--opener', 'editor']); const unset = await setupWorkspace('unset-workspace', [`api=${api}`]); - expect(readLocalState(codex.workspace.root).preferred_opener).toEqual({ + expect(readWorkspaceState(codex.workspace.root).preferred_opener).toEqual({ kind: 'agent', id: 'codex', }); - expect(readLocalState(editor.workspace.root).preferred_opener).toEqual({ + expect(readWorkspaceState(editor.workspace.root).preferred_opener).toEqual({ kind: 'editor', id: 'vscode', }); - expect(readLocalState(unset.workspace.root).preferred_opener).toBeUndefined(); + expect(readWorkspaceState(unset.workspace.root).preferred_opener).toBeUndefined(); const invalid = await runCLI( [ @@ -788,7 +790,6 @@ describe('workspace command', () => { fs.mkdirSync(path.join(project, 'repos', 'api'), { recursive: true }); fs.mkdirSync(path.join(project, 'services', 'billing'), { recursive: true }); fs.mkdirSync(path.join(project, 'archive', 'billing'), { recursive: true }); - const resolvedProject = fs.realpathSync.native(project); const setup = await runCLI( [ @@ -806,8 +807,9 @@ describe('workspace command', () => { expect(setup.exitCode).toBe(0); const setupPayload = parseJson(setup); - expect(readLocalState(setupPayload.workspace.root).paths.api).toBe( - path.join(resolvedProject, 'repos', 'api') + expectSameExistingPath( + readWorkspaceState(setupPayload.workspace.root).links.api ?? null, + path.join(project, 'repos', 'api') ); const link = await runCLI(['workspace', 'link', 'services/billing', '--json'], { @@ -815,29 +817,33 @@ describe('workspace command', () => { env, }); expect(link.exitCode).toBe(0); - expect(parseJson(link).link).toEqual( + const linkPayload = parseJson(link).link; + expect(linkPayload).toEqual( expect.objectContaining({ name: 'billing', - path: path.join(resolvedProject, 'services', 'billing'), + path: expect.any(String), }) ); + expectSameExistingPath(linkPayload.path, path.join(project, 'services', 'billing')); const relink = await runCLI( ['workspace', 'relink', 'billing', 'archive/billing', '--json'], { cwd: project, env } ); expect(relink.exitCode).toBe(0); - expect(parseJson(relink).link).toEqual( + const relinkPayload = parseJson(relink).link; + expect(relinkPayload).toEqual( expect.objectContaining({ name: 'billing', - path: path.join(resolvedProject, 'archive', 'billing'), + path: expect.any(String), }) ); + expectSameExistingPath(relinkPayload.path, path.join(project, 'archive', 'billing')); - expect(readLocalState(setupPayload.workspace.root).paths).toEqual({ - api: path.join(resolvedProject, 'repos', 'api'), - billing: path.join(resolvedProject, 'archive', 'billing'), - }); + const workspaceLinks = readWorkspaceState(setupPayload.workspace.root).links; + expect(Object.keys(workspaceLinks).sort()).toEqual(['api', 'billing']); + expectSameExistingPath(workspaceLinks.api ?? null, path.join(project, 'repos', 'api')); + expectSameExistingPath(workspaceLinks.billing ?? null, path.join(project, 'archive', 'billing')); }); it('canonicalizes existing link directories on Windows before storing local paths', async () => { @@ -924,8 +930,7 @@ describe('workspace command', () => { const web = mkdir('repos/web'); const setup = await setupWorkspace('platform', [`api=${api}`]); const workspaceRoot = setup.workspace.root; - const sharedBefore = fs.readFileSync(getWorkspaceSharedStatePath(workspaceRoot), 'utf-8'); - const localBefore = fs.readFileSync(getWorkspaceLocalStatePath(workspaceRoot), 'utf-8'); + const viewBefore = fs.readFileSync(getWorkspaceViewStatePath(workspaceRoot), 'utf-8'); const markerPath = path.join(workspaceRoot, WORKSPACE_CHANGES_DIR_NAME, 'sentinel.txt'); fs.writeFileSync(markerPath, 'keep me'); @@ -950,8 +955,7 @@ describe('workspace command', () => { target: 'workspace.name', }) ); - expect(fs.readFileSync(getWorkspaceSharedStatePath(workspaceRoot), 'utf-8')).toBe(sharedBefore); - expect(fs.readFileSync(getWorkspaceLocalStatePath(workspaceRoot), 'utf-8')).toBe(localBefore); + expect(fs.readFileSync(getWorkspaceViewStatePath(workspaceRoot), 'utf-8')).toBe(viewBefore); expect(fs.readFileSync(markerPath, 'utf-8')).toBe('keep me'); }); @@ -1148,15 +1152,13 @@ describe('workspace command', () => { expect(fs.existsSync(path.join(packageDir, WORKSPACE_METADATA_DIR_NAME))).toBe(false); }); - it('fails link and relink without rewriting malformed local state', async () => { + it('fails link and relink without rewriting malformed workspace state', async () => { const api = mkdir('repos/api'); const billing = mkdir('repos/billing'); const setup = await setupWorkspace('broken-local', [`api=${api}`]); - const sharedPath = getWorkspaceSharedStatePath(setup.workspace.root); - const localPath = getWorkspaceLocalStatePath(setup.workspace.root); - const sharedBefore = fs.readFileSync(sharedPath, 'utf-8'); - const malformedLocalState = 'version: 1\npaths: []\n'; - fs.writeFileSync(localPath, malformedLocalState); + const statePath = getWorkspaceViewStatePath(setup.workspace.root); + const malformedState = 'version: 1\npaths: []\n'; + fs.writeFileSync(statePath, malformedState); const link = await runCLI( ['workspace', 'link', 'billing', billing, '--workspace', 'broken-local', '--json'], @@ -1165,12 +1167,11 @@ describe('workspace command', () => { expect(link.exitCode).toBe(1); expect(parseJson(link).status[0]).toEqual( expect.objectContaining({ - code: 'workspace_local_state_invalid', - target: 'workspace.local_state', + code: 'workspace_state_invalid', + target: 'workspace.state', }) ); - expect(fs.readFileSync(sharedPath, 'utf-8')).toBe(sharedBefore); - expect(fs.readFileSync(localPath, 'utf-8')).toBe(malformedLocalState); + expect(fs.readFileSync(statePath, 'utf-8')).toBe(malformedState); const relink = await runCLI( ['workspace', 'relink', 'api', billing, '--workspace', 'broken-local', '--json'], @@ -1179,64 +1180,58 @@ describe('workspace command', () => { expect(relink.exitCode).toBe(1); expect(parseJson(relink).status[0]).toEqual( expect.objectContaining({ - code: 'workspace_local_state_invalid', - target: 'workspace.local_state', + code: 'workspace_state_invalid', + target: 'workspace.state', }) ); - expect(fs.readFileSync(sharedPath, 'utf-8')).toBe(sharedBefore); - expect(fs.readFileSync(localPath, 'utf-8')).toBe(malformedLocalState); + expect(fs.readFileSync(statePath, 'utf-8')).toBe(malformedState); }); - it('reports stale registry entries without rewriting the registry', async () => { + it('drops deleted managed workspace roots from scanned workspace selection', async () => { const api = mkdir('repos/api'); const setup = await setupWorkspace('platform', [`api=${api}`]); const registryPath = getWorkspaceRegistryPath({ globalDataDir: path.join(dataHome, 'openspec') }); - const registryBefore = fs.readFileSync(registryPath, 'utf-8'); + expect(fs.existsSync(registryPath)).toBe(false); fs.rmSync(setup.workspace.root, { recursive: true, force: true }); const list = await runCLI(['workspace', 'list', '--json'], { cwd: tempDir, env }); expect(list.exitCode).toBe(0); - expect(parseJson(list).workspaces[0].status[0]).toEqual( - expect.objectContaining({ - code: 'workspace_root_missing', - }) - ); + expect(parseJson(list).workspaces).toEqual([]); const doctor = await runCLI(['workspace', 'doctor', '--workspace', 'platform', '--json'], { cwd: tempDir, env, }); - expect(doctor.exitCode).toBe(0); - expect(parseJson(doctor).workspace.status[0]).toEqual( + expect(doctor.exitCode).toBe(1); + expect(parseJson(doctor).status[0]).toEqual( expect.objectContaining({ - code: 'selected_workspace_root_missing', + code: 'workspace_not_found', }) ); - expect(fs.readFileSync(registryPath, 'utf-8')).toBe(registryBefore); + expect(fs.existsSync(registryPath)).toBe(false); }); - it('reports malformed local state in list and doctor without rewriting files', async () => { + it('reports malformed workspace state in list and doctor without rewriting files', async () => { const api = mkdir('repos/api'); const setup = await setupWorkspace('doctor-local-invalid', [`api=${api}`]); - const localPath = getWorkspaceLocalStatePath(setup.workspace.root); + const statePath = getWorkspaceViewStatePath(setup.workspace.root); const registryPath = getWorkspaceRegistryPath({ globalDataDir: path.join(dataHome, 'openspec') }); - const malformedLocalState = 'version: 1\npaths: []\n'; - const registryBefore = fs.readFileSync(registryPath, 'utf-8'); - fs.writeFileSync(localPath, malformedLocalState); + const malformedState = 'version: 1\npaths: []\n'; + expect(fs.existsSync(registryPath)).toBe(false); + fs.writeFileSync(statePath, malformedState); const list = await runCLI(['workspace', 'list', '--json'], { cwd: tempDir, env }); expect(list.exitCode).toBe(0); expect(parseJson(list).workspaces[0].status[0]).toEqual( expect.objectContaining({ - code: 'workspace_local_state_invalid', + code: 'workspace_state_invalid', }) ); const humanList = await runCLI(['workspace', 'list'], { cwd: tempDir, env }); expect(humanList.exitCode).toBe(0); - expect(humanList.stdout).toContain('Linked repos or folders (1):'); - expect(humanList.stdout).toContain('api -> (no local path recorded)'); + expect(humanList.stdout).toContain('Workspace state could not be read'); const doctor = await runCLI( ['workspace', 'doctor', '--workspace', 'doctor-local-invalid', '--json'], @@ -1246,43 +1241,32 @@ describe('workspace command', () => { const doctorPayload = parseJson(doctor); expect(doctorPayload.workspace.status[0]).toEqual( expect.objectContaining({ - code: 'workspace_local_state_invalid', - target: 'workspace.local_state', + code: 'workspace_state_invalid', + target: 'workspace.root', }) ); - expect(doctorPayload.workspace.links[0]).toEqual( - expect.objectContaining({ - name: 'api', - path: null, - status: [], - }) - ); - expect(fs.readFileSync(localPath, 'utf-8')).toBe(malformedLocalState); - expect(fs.readFileSync(registryPath, 'utf-8')).toBe(registryBefore); + expect(doctorPayload.workspace.links).toEqual([]); + expect(fs.readFileSync(statePath, 'utf-8')).toBe(malformedState); + expect(fs.existsSync(registryPath)).toBe(false); }); - it('reports shared/local drift and missing paths without repairing workspace state', async () => { + it('reports missing linked paths without repairing workspace state', async () => { const api = mkdir('repos/api'); const localOnly = mkdir('repos/local-only'); const setup = await setupWorkspace('platform', [`api=${api}`]); const workspaceRoot = setup.workspace.root; const registryPath = getWorkspaceRegistryPath({ globalDataDir: path.join(dataHome, 'openspec') }); const missingApiPath = path.join(tempDir, 'repos', 'missing-api'); - const sharedDrift = `version: 1 + const viewState = `version: 1 name: platform +context: null links: - api: {} - web: {} -`; - const localDrift = `version: 1 -paths: api: ${missingApiPath} local-only: ${localOnly} `; - fs.writeFileSync(getWorkspaceSharedStatePath(workspaceRoot), sharedDrift); - fs.writeFileSync(getWorkspaceLocalStatePath(workspaceRoot), localDrift); + fs.writeFileSync(getWorkspaceViewStatePath(workspaceRoot), viewState); fs.rmSync(path.join(workspaceRoot, WORKSPACE_CHANGES_DIR_NAME), { recursive: true, force: true }); - const registryBefore = fs.readFileSync(registryPath, 'utf-8'); + expect(fs.existsSync(registryPath)).toBe(false); const doctor = await runCLI(['workspace', 'doctor', '--workspace', 'platform', '--json'], { cwd: tempDir, @@ -1291,12 +1275,7 @@ paths: expect(doctor.exitCode).toBe(0); const payload = parseJson(doctor); - expect(payload.workspace.status).toEqual([ - expect.objectContaining({ - code: 'workspace_planning_path_missing', - target: 'workspace.planning_path', - }), - ]); + expect(payload.workspace.status).toEqual([]); expect(payload.workspace.links).toEqual([ expect.objectContaining({ name: 'api', @@ -1310,31 +1289,19 @@ paths: }), expect.objectContaining({ name: 'local-only', - path: localOnly, - status: [ - expect.objectContaining({ - code: 'local_path_without_shared_link', - severity: 'warning', - }), - ], - }), - expect.objectContaining({ - name: 'web', - path: null, - status: [ - expect.objectContaining({ - code: 'linked_path_missing_from_local_state', - fix: expect.stringContaining('workspace relink web'), - }), - ], + path: expect.any(String), + status: [], }), ]); - expect(fs.readFileSync(getWorkspaceSharedStatePath(workspaceRoot), 'utf-8')).toBe(sharedDrift); - expect(fs.readFileSync(getWorkspaceLocalStatePath(workspaceRoot), 'utf-8')).toBe(localDrift); - expect(fs.readFileSync(registryPath, 'utf-8')).toBe(registryBefore); + expectSameExistingPath( + payload.workspace.links.find((link: any) => link.name === 'local-only')?.path ?? null, + localOnly + ); + expect(fs.readFileSync(getWorkspaceViewStatePath(workspaceRoot), 'utf-8')).toBe(viewState); + expect(fs.existsSync(registryPath)).toBe(false); }); - it('uses current unregistered workspaces for doctor and records them after link', async () => { + it('uses current unlisted legacy workspaces for doctor and link without writing a registry', async () => { const manualRoot = path.join(tempDir, 'manual-workspace'); const nested = path.join(manualRoot, WORKSPACE_CHANGES_DIR_NAME, 'add-billing'); const api = mkdir('repos/api'); @@ -1342,11 +1309,11 @@ paths: fs.mkdirSync(path.join(manualRoot, WORKSPACE_METADATA_DIR_NAME), { recursive: true }); fs.mkdirSync(nested, { recursive: true }); fs.writeFileSync( - path.join(manualRoot, WORKSPACE_METADATA_DIR_NAME, WORKSPACE_SHARED_STATE_FILE_NAME), + path.join(manualRoot, WORKSPACE_METADATA_DIR_NAME, WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME), 'version: 1\nname: manual-workspace\nlinks: {}\n' ); fs.writeFileSync( - path.join(manualRoot, WORKSPACE_METADATA_DIR_NAME, WORKSPACE_LOCAL_STATE_FILE_NAME), + path.join(manualRoot, WORKSPACE_METADATA_DIR_NAME, WORKSPACE_LEGACY_LOCAL_STATE_FILE_NAME), 'version: 1\npaths: {}\n' ); @@ -1355,7 +1322,7 @@ paths: expect(doctor.exitCode).toBe(0); expect(parseJson(doctor).status[0]).toEqual( expect.objectContaining({ - code: 'workspace_not_in_local_registry', + code: 'workspace_not_in_known_views', severity: 'warning', }) ); @@ -1368,12 +1335,11 @@ paths: expect(link.exitCode).toBe(0); expect(parseJson(link).status[0]).toEqual( expect.objectContaining({ - code: 'workspace_not_in_local_registry', + code: 'workspace_not_in_known_views', }) ); - const registry = parseWorkspaceRegistryState(fs.readFileSync(registryPath, 'utf-8')); - expect(registry.workspaces['manual-workspace']).toBe(fs.realpathSync.native(manualRoot)); + expect(fs.existsSync(registryPath)).toBe(false); }); it('fails JSON workspace selection when multiple known workspaces are available', async () => { @@ -1505,7 +1471,7 @@ paths: expectedApi, 'Open this OpenSpec workspace.', ]); - expect(readLocalState(setup.workspace.root).preferred_opener).toEqual({ + expect(readWorkspaceState(setup.workspace.root).preferred_opener).toEqual({ kind: 'editor', id: 'vscode', }); @@ -1547,14 +1513,14 @@ paths: expect(unsupported.exitCode).toBe(1); expect(unsupported.stderr).toContain('future context/query surface'); - const jsonUnsupported = await runCLI(['workspace', 'open', '--json'], { + const jsonAmbiguous = await runCLI(['workspace', 'open', '--json'], { cwd: tempDir, env, }); - expect(jsonUnsupported.exitCode).toBe(1); - expect(parseJson(jsonUnsupported).status[0]).toEqual( + expect(jsonAmbiguous.exitCode).toBe(1); + expect(parseJson(jsonAmbiguous).status[0]).toEqual( expect.objectContaining({ - code: 'workspace_open_json_unsupported', + code: 'workspace_selection_ambiguous', }) ); @@ -1583,9 +1549,11 @@ paths: expect(openerConflict.stderr).toContain('either --agent <tool> or --editor'); fs.writeFileSync( - getWorkspaceLocalStatePath(platform.workspace.root), + getWorkspaceViewStatePath(platform.workspace.root), `version: 1 -paths: +name: platform +context: null +links: api: ${api} preferred_opener: kind: editor @@ -1659,7 +1627,7 @@ preferred_opener: const updateHelp = await runCLI(['workspace', 'update', '--help'], { cwd: tempDir, env }); expect(updateHelp.exitCode).toBe(0); - expect(updateHelp.stdout).toContain('active global profile'); + expect(updateHelp.stdout).toContain('guidance and agent skills'); expect(updateHelp.stdout).toContain('--workspace'); expect(updateHelp.stdout).toContain('--tools'); expect(updateHelp.stdout).toMatch(/Global profile\s+selects workflows/u); @@ -1695,7 +1663,7 @@ preferred_opener: ]); expect(link?.positionals).toEqual([ { name: 'name-or-path', type: 'path', optional: true }, - { name: 'path', type: 'path' }, + { name: 'path', type: 'path', optional: true }, ]); expect(relink?.positionals).toEqual([ { name: 'name' }, @@ -1710,7 +1678,7 @@ preferred_opener: 'json', 'no-interactive', ]); - expect(update?.description).toContain('active global profile'); + expect(update?.description).toContain('guidance and agent skills'); expect(update?.flags?.find((flag) => flag.name === 'tools')?.description).toContain( 'global profile selects workflows' ); @@ -1727,8 +1695,14 @@ preferred_opener: ]); expect(open?.flags?.map((flag) => flag.name)).toEqual([ 'workspace', + 'initiative', + 'store', + 'store-path', 'agent', 'editor', + 'prepare-only', + 'json', + 'change', 'no-interactive', ]); }); diff --git a/test/core/collections/initiatives/operations.test.ts b/test/core/collections/initiatives/operations.test.ts new file mode 100644 index 0000000000..b403646a24 --- /dev/null +++ b/test/core/collections/initiatives/operations.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import * as nodeFs from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + INITIATIVE_FILE_NAME, + INITIATIVE_FILE_NAMES, + createCollectionRegistry, + createInitiative, + listInitiatives, + mountCollections, + parseInitiativeState, + readInitiative, + serializeInitiativeState, + type InitiativeOperationsFileSystem, + type InitiativeState, +} from '../../../../src/core/collections/index.js'; + +describe('initiative operations', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = nodeFs.mkdtempSync(path.join(os.tmpdir(), 'openspec-initiatives-operations-')); + }); + + afterEach(() => { + nodeFs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function mountInitiatives(storeRoot = path.join(tempDir, 'context-store')) { + const collections = createCollectionRegistry([{ id: 'initiatives', mount: 'initiatives' }]); + return mountCollections({ storeRoot, collections }).require('initiatives'); + } + + function initiativeState(overrides: Partial<InitiativeState> = {}): InitiativeState { + return { + version: 1, + id: 'launch-billing-flow', + title: 'Launch Billing Flow', + summary: 'Coordinate billing launch across product, API, and client surfaces.', + status: 'exploring', + created: '2026-05-21', + owners: [], + metadata: {}, + ...overrides, + }; + } + + async function writeInitiativeState( + collection: ReturnType<typeof mountInitiatives>, + folderName: string, + state: InitiativeState + ): Promise<void> { + await fs.mkdir(collection.resolvePath(folderName), { recursive: true }); + await fs.writeFile( + collection.resolvePath(`${folderName}/${INITIATIVE_FILE_NAME}`), + serializeInitiativeState(state), + 'utf-8' + ); + } + + const realFileSystem: InitiativeOperationsFileSystem = { + async mkdir(dirPath, options) { + await fs.mkdir(dirPath, options); + }, + + async writeFile(filePath, content, options) { + await fs.writeFile(filePath, content, { + encoding: 'utf-8', + flag: options.flag ?? 'w', + }); + }, + + async readFile(filePath) { + return fs.readFile(filePath, 'utf-8'); + }, + + async readdir(dirPath, options) { + return fs.readdir(dirPath, options); + }, + + async rm(dirPath, options) { + await fs.rm(dirPath, options); + }, + }; + + it('creates the MVP initiative folder shape without links.yaml', async () => { + const collection = mountInitiatives(); + + const created = await createInitiative({ + collection, + id: 'launch-billing-flow', + title: 'Launch Billing Flow', + summary: 'Coordinate billing launch across product, API, and client surfaces.', + owners: ['platform-team'], + metadata: { priority: 'high' }, + getCurrentDate: () => '2026-05-21', + }); + + expect(created).toEqual(initiativeState({ + owners: ['platform-team'], + metadata: { priority: 'high' }, + })); + + for (const fileName of INITIATIVE_FILE_NAMES) { + expect(nodeFs.existsSync(collection.resolvePath(`launch-billing-flow/${fileName}`))).toBe( + true + ); + } + expect(nodeFs.existsSync(collection.resolvePath('launch-billing-flow/links.yaml'))).toBe( + false + ); + + expect( + parseInitiativeState( + await fs.readFile( + collection.resolvePath(`launch-billing-flow/${INITIATIVE_FILE_NAME}`), + 'utf-8' + ) + ) + ).toEqual(created); + + await expect(listInitiatives({ collection })).resolves.toEqual([created]); + }); + + it('fails when creating an initiative that already exists', async () => { + const collection = mountInitiatives(); + + await createInitiative({ + collection, + id: 'launch-billing-flow', + title: 'Launch Billing Flow', + summary: 'Coordinate billing launch.', + getCurrentDate: () => '2026-05-21', + }); + + await expect( + createInitiative({ + collection, + id: 'launch-billing-flow', + title: 'Replacement', + summary: 'Do not overwrite existing initiative.', + getCurrentDate: () => '2026-05-22', + }) + ).rejects.toThrow(/already exists/u); + + expect( + parseInitiativeState( + await fs.readFile( + collection.resolvePath(`launch-billing-flow/${INITIATIVE_FILE_NAME}`), + 'utf-8' + ) + ).title + ).toBe('Launch Billing Flow'); + }); + + it('cleans up the initiative folder when a create write fails', async () => { + const collection = mountInitiatives(); + const failingFileSystem: InitiativeOperationsFileSystem = { + ...realFileSystem, + async writeFile(filePath, content, options) { + if (filePath.endsWith('design.md')) { + throw new Error('simulated write failure'); + } + + await realFileSystem.writeFile(filePath, content, options); + }, + }; + + await expect( + createInitiative({ + collection, + id: 'launch-billing-flow', + title: 'Launch Billing Flow', + summary: 'Coordinate billing launch.', + getCurrentDate: () => '2026-05-21', + fileSystem: failingFileSystem, + }) + ).rejects.toThrow(/simulated write failure/u); + + expect(nodeFs.existsSync(collection.resolvePath('launch-billing-flow'))).toBe(false); + expect(nodeFs.existsSync(collection.resolvePath())).toBe(true); + }); + + it('lists initiatives by valid initiative.yaml and ignores unrelated folders', async () => { + const collection = mountInitiatives(); + + await createInitiative({ + collection, + id: 'zeta-rollout', + title: 'Zeta Rollout', + summary: 'Coordinate zeta rollout.', + getCurrentDate: () => '2026-05-22', + }); + await createInitiative({ + collection, + id: 'alpha-rollout', + title: 'Alpha Rollout', + summary: 'Coordinate alpha rollout.', + getCurrentDate: () => '2026-05-21', + }); + + await fs.mkdir(collection.resolvePath('scratch-notes'), { recursive: true }); + await fs.writeFile(collection.resolvePath('scratch-notes/notes.md'), 'not an initiative'); + await fs.writeFile(collection.resolvePath('loose-file.txt'), 'not a folder'); + + await expect(listInitiatives({ collection })).resolves.toEqual([ + initiativeState({ + id: 'alpha-rollout', + title: 'Alpha Rollout', + summary: 'Coordinate alpha rollout.', + created: '2026-05-21', + }), + initiativeState({ + id: 'zeta-rollout', + title: 'Zeta Rollout', + summary: 'Coordinate zeta rollout.', + created: '2026-05-22', + }), + ]); + }); + + it('returns an empty list when the mounted initiatives folder does not exist', async () => { + await expect(listInitiatives({ collection: mountInitiatives() })).resolves.toEqual([]); + }); + + it('reads one initiative by id without scanning unrelated folders', async () => { + const collection = mountInitiatives(); + + await writeInitiativeState(collection, 'launch-billing-flow', initiativeState()); + await fs.mkdir(collection.resolvePath('broken-initiative'), { recursive: true }); + await fs.writeFile( + collection.resolvePath(`broken-initiative/${INITIATIVE_FILE_NAME}`), + 'version: 1\nid: Broken\n', + 'utf-8' + ); + + await expect( + readInitiative({ collection, id: 'launch-billing-flow' }) + ).resolves.toEqual(initiativeState()); + }); + + it('returns null when an exact initiative is absent', async () => { + await expect( + readInitiative({ collection: mountInitiatives(), id: 'missing-initiative' }) + ).resolves.toBeNull(); + }); + + it('fails when the exact initiative.yaml is invalid', async () => { + const collection = mountInitiatives(); + + await fs.mkdir(collection.resolvePath('broken-initiative'), { recursive: true }); + await fs.writeFile( + collection.resolvePath(`broken-initiative/${INITIATIVE_FILE_NAME}`), + 'version: 1\nid: Broken\n', + 'utf-8' + ); + + await expect( + readInitiative({ collection, id: 'broken-initiative' }) + ).rejects.toThrow(/Invalid initiative 'broken-initiative'/u); + }); + + it('requires exact initiative.yaml id to match the folder name', async () => { + const collection = mountInitiatives(); + + await writeInitiativeState( + collection, + 'folder-name', + initiativeState({ + id: 'state-name', + title: 'State Name', + }) + ); + + await expect( + readInitiative({ collection, id: 'folder-name' }) + ).rejects.toThrow(/id 'state-name' must match folder name/u); + }); + + it('fails loudly when initiative.yaml is invalid', async () => { + const collection = mountInitiatives(); + + await fs.mkdir(collection.resolvePath('broken-initiative'), { recursive: true }); + await fs.writeFile( + collection.resolvePath(`broken-initiative/${INITIATIVE_FILE_NAME}`), + 'version: 1\nid: Broken\n', + 'utf-8' + ); + + await expect(listInitiatives({ collection })).rejects.toThrow( + /Invalid initiative 'broken-initiative'/u + ); + }); + + it('requires initiative.yaml id to match the folder name', async () => { + const collection = mountInitiatives(); + + await writeInitiativeState( + collection, + 'folder-name', + initiativeState({ + id: 'state-name', + title: 'State Name', + }) + ); + + await expect(listInitiatives({ collection })).rejects.toThrow( + /id 'state-name' must match folder name/u + ); + }); + + it('requires the mounted initiatives collection', async () => { + const collections = createCollectionRegistry([{ id: 'decisions', mount: 'decisions' }]); + const decisions = mountCollections({ + storeRoot: path.join(tempDir, 'context-store'), + collections, + }).require('decisions'); + + await expect( + createInitiative({ + collection: decisions, + id: 'launch-billing-flow', + title: 'Launch Billing Flow', + summary: 'Coordinate billing launch.', + }) + ).rejects.toThrow(/Expected mounted 'initiatives' collection/u); + + await expect(listInitiatives({ collection: decisions })).rejects.toThrow( + /Expected mounted 'initiatives' collection/u + ); + + await expect( + readInitiative({ + collection: decisions, + id: 'launch-billing-flow', + }) + ).rejects.toThrow(/Expected mounted 'initiatives' collection/u); + }); +}); diff --git a/test/core/collections/initiatives/resolution.test.ts b/test/core/collections/initiatives/resolution.test.ts new file mode 100644 index 0000000000..9f0ead739c --- /dev/null +++ b/test/core/collections/initiatives/resolution.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { initiativeDiagnosticFromError } from '../../../../src/core/collections/initiatives/index.js'; + +describe('initiative resolution diagnostics', () => { + it('classifies already-exists errors without regex backtracking', () => { + expect( + initiativeDiagnosticFromError( + new Error("Initiative 'billing-launch' already exists at /tmp/store/initiatives/billing-launch") + ) + ).toEqual( + expect.objectContaining({ + code: 'initiative_already_exists', + target: 'initiative.id', + }) + ); + + const diagnostic = initiativeDiagnosticFromError(new Error("Initiative '".repeat(32000))); + expect(diagnostic.code).toBe('initiative_error'); + }); +}); diff --git a/test/core/collections/initiatives/schema.test.ts b/test/core/collections/initiatives/schema.test.ts new file mode 100644 index 0000000000..d241f85582 --- /dev/null +++ b/test/core/collections/initiatives/schema.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; + +import { + INITIATIVE_COLLECTION_ID, + INITIATIVE_FILE_NAME, + INITIATIVE_FILE_NAMES, + INITIATIVE_MARKDOWN_FILE_NAMES, + INITIATIVE_STATUSES, + isValidInitiativeId, + parseInitiativeState, + serializeInitiativeState, + validateInitiativeId, + type InitiativeState, +} from '../../../../src/core/collections/initiatives/index.js'; + +describe('initiative schema', () => { + const state: InitiativeState = { + version: 1, + id: 'launch-billing-flow', + title: 'Launch Billing Flow', + summary: 'Coordinate billing launch across product, API, and client surfaces.', + status: 'exploring', + created: '2026-05-21', + owners: ['platform-team'], + metadata: { + priority: 'high', + nested: { + score: 3, + blocked: false, + notes: null, + }, + }, + }; + + it('defines the initiative MVP file contract without links.yaml', () => { + expect(INITIATIVE_COLLECTION_ID).toBe('initiatives'); + expect(INITIATIVE_FILE_NAME).toBe('initiative.yaml'); + expect(INITIATIVE_STATUSES).toEqual(['exploring', 'active', 'complete', 'archived']); + expect(INITIATIVE_MARKDOWN_FILE_NAMES).toEqual([ + 'requirements.md', + 'design.md', + 'decisions.md', + 'questions.md', + 'tasks.md', + ]); + expect(INITIATIVE_FILE_NAMES).toEqual([ + 'initiative.yaml', + 'requirements.md', + 'design.md', + 'decisions.md', + 'questions.md', + 'tasks.md', + ]); + expect(INITIATIVE_FILE_NAMES).not.toContain('links.yaml'); + }); + + it('validates portable initiative ids', () => { + for (const id of ['launch-billing-flow', 'initiative2', 'api-v2-contracts']) { + expect(validateInitiativeId(id)).toBe(id); + expect(isValidInitiativeId(id)).toBe(true); + } + }); + + it('rejects unsafe initiative ids', () => { + for (const id of [ + '', + '.', + '..', + 'bad/name', + 'bad\\name', + 'Launch', + 'launch_flow', + 'launch.flow', + 'launch flow', + '-launch', + 'launch-', + 'launch--flow', + 'a\0b', + ]) { + expect(() => validateInitiativeId(id)).toThrow(); + expect(isValidInitiativeId(id)).toBe(false); + } + }); + + it('parses initiative.yaml and defaults optional collection metadata', () => { + expect( + parseInitiativeState(` +version: 1 +id: launch-billing-flow +title: Launch Billing Flow +summary: Coordinate billing launch. +status: active +created: "2026-05-21" +`) + ).toEqual({ + version: 1, + id: 'launch-billing-flow', + title: 'Launch Billing Flow', + summary: 'Coordinate billing launch.', + status: 'active', + created: '2026-05-21', + owners: [], + metadata: {}, + }); + }); + + it('serializes initiative.yaml with deterministic fields', () => { + const serialized = serializeInitiativeState(state); + + expect(parseInitiativeState(serialized)).toEqual(state); + expect(serialized).toContain('version: 1'); + expect(serialized).toContain('id: launch-billing-flow'); + expect(serialized).toContain('created: 2026-05-21'); + }); + + it('rejects invalid initiative.yaml input', () => { + const invalidCases = [ + 'not-an-object', + ` +version: 2 +id: launch-billing-flow +title: Launch Billing Flow +summary: Coordinate billing launch. +status: exploring +created: "2026-05-21" +`, + ` +version: 1 +id: Launch +title: Launch Billing Flow +summary: Coordinate billing launch. +status: exploring +created: "2026-05-21" +`, + ` +version: 1 +id: launch-billing-flow +title: Launch Billing Flow +summary: Coordinate billing launch. +status: paused +created: "2026-05-21" +`, + ` +version: 1 +id: launch-billing-flow +title: Launch Billing Flow +summary: Coordinate billing launch. +status: exploring +`, + ` +version: 1 +id: launch-billing-flow +title: Launch Billing Flow +summary: Coordinate billing launch. +status: exploring +created: "05/21/2026" +`, + ` +version: 1 +id: launch-billing-flow +title: "" +summary: Coordinate billing launch. +status: exploring +created: "2026-05-21" +`, + ` +version: 1 +id: launch-billing-flow +title: Launch Billing Flow +summary: Coordinate billing launch. +status: exploring +created: "2026-05-21" +owners: [""] +`, + ` +version: 1 +id: launch-billing-flow +title: Launch Billing Flow +summary: Coordinate billing launch. +status: exploring +created: "2026-05-21" +extra: nope +`, + ]; + + for (const content of invalidCases) { + expect(() => parseInitiativeState(content)).toThrow(); + } + }); + + it('rejects non-json metadata values on serialize', () => { + expect(() => + serializeInitiativeState({ + ...state, + metadata: { + notFinite: Number.NaN, + }, + }) + ).toThrow(/metadata/u); + }); +}); diff --git a/test/core/collections/initiatives/templates.test.ts b/test/core/collections/initiatives/templates.test.ts new file mode 100644 index 0000000000..f084a77922 --- /dev/null +++ b/test/core/collections/initiatives/templates.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { + INITIATIVE_MARKDOWN_FILE_NAMES, + buildDefaultInitiativeFiles, + buildInitiativeDecisionsTemplate, + buildInitiativeDesignTemplate, + buildInitiativeQuestionsTemplate, + buildInitiativeRequirementsTemplate, + buildInitiativeTasksTemplate, + type InitiativeState, +} from '../../../../src/core/collections/initiatives/index.js'; + +describe('initiative templates', () => { + const state: InitiativeState = { + version: 1, + id: 'launch-billing-flow', + title: 'Launch Billing Flow', + summary: 'Coordinate billing launch across product, API, and client surfaces.', + status: 'exploring', + created: '2026-05-21', + owners: [], + metadata: {}, + }; + + it('builds the default markdown files in the initiative file order', () => { + const files = buildDefaultInitiativeFiles(state); + + expect(files.map((file) => file.fileName)).toEqual(INITIATIVE_MARKDOWN_FILE_NAMES); + expect(files.map((file) => file.fileName)).not.toContain('links.yaml'); + for (const file of files) { + expect(file.content.endsWith('\n')).toBe(true); + expect(file.content).toMatch(/^# /u); + } + }); + + it('builds requirements content from initiative intent', () => { + const content = buildInitiativeRequirementsTemplate(state); + + expect(content).toContain('# Requirements'); + expect(content).toContain('## Product Intent'); + expect(content).toContain(state.summary); + expect(content).toContain('## Accepted Requirements'); + expect(content).toContain('## Out Of Scope'); + }); + + it('builds design content for coordination context', () => { + const content = buildInitiativeDesignTemplate(state); + + expect(content).toContain('# Design'); + expect(content).toContain('## Context'); + expect(content).toContain('## Approach'); + expect(content).toContain('## Affected Areas'); + expect(content).toContain('## Dependencies'); + expect(content).toContain('## Risks'); + }); + + it('builds decisions content with date and title context', () => { + const content = buildInitiativeDecisionsTemplate(state); + + expect(content).toContain('# Decisions'); + expect(content).toContain(`### ${state.created}: ${state.title}`); + expect(content).toContain('- Decision: TBD'); + expect(content).toContain('- Why: TBD'); + expect(content).toContain('- Implications: TBD'); + }); + + it('builds questions and coordination tasks content', () => { + expect(buildInitiativeQuestionsTemplate()).toContain('## Open Questions'); + expect(buildInitiativeQuestionsTemplate()).toContain('## Resolved Questions'); + expect(buildInitiativeTasksTemplate()).toContain('## Coordination Tasks'); + expect(buildInitiativeTasksTemplate()).toContain('- [ ] TBD'); + }); +}); diff --git a/test/core/collections/runtime.test.ts b/test/core/collections/runtime.test.ts new file mode 100644 index 0000000000..1e977e3348 --- /dev/null +++ b/test/core/collections/runtime.test.ts @@ -0,0 +1,214 @@ +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 { + createCollectionRegistry, + mountCollections, + parseCollectionPath, + validateCollectionId, + validateMount, + type MountedCollectionContext, +} from '../../../src/core/collections/index.js'; + +describe('collection runtime', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-context-store-collections-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('collection id and mount validation', () => { + it('accepts portable kebab-case ids and mounts', () => { + for (const value of ['initiatives', 'decisions', 'api-catalog', 'context2']) { + expect(validateCollectionId(value)).toBe(value); + expect(validateMount(value)).toBe(value); + } + }); + + it('rejects unsafe ids and mounts', () => { + for (const invalidValue of [ + '', + '.', + '..', + 'bad/name', + 'bad\\name', + 'Acme', + 'acme_context', + 'acme.context', + 'acme context', + '-acme', + 'acme-', + 'acme--context', + 'a\0b', + ]) { + expect(() => validateCollectionId(invalidValue)).toThrow(); + expect(() => validateMount(invalidValue)).toThrow(); + } + + expect(() => validateMount('.openspec-store')).toThrow(/reserved/u); + }); + }); + + describe('collection path parsing', () => { + it('parses logical paths inside a collection mount', () => { + expect(parseCollectionPath()).toBe(''); + expect(parseCollectionPath('')).toBe(''); + expect(parseCollectionPath('launch-billing-flow/initiative.yaml')).toBe( + 'launch-billing-flow/initiative.yaml' + ); + expect(parseCollectionPath('initiatives-old/file.md')).toBe('initiatives-old/file.md'); + }); + + it('rejects paths that are absolute, ambiguous, or outside the mount', () => { + for (const invalidPath of [ + '.', + './x', + 'x/.', + '..', + '../x', + 'x/..', + 'x/../y', + 'x//y', + 'x/', + '/x', + '//server/share/file', + 'C:/x', + 'C:\\x', + '\\\\server\\share\\x', + 'bad\\path', + 'a\0b', + ]) { + expect(() => parseCollectionPath(invalidPath)).toThrow(); + } + }); + }); + + describe('collection registry', () => { + it('lists, gets, and requires collection definitions deterministically', () => { + const registry = createCollectionRegistry([ + { id: 'decisions', mount: 'decisions' }, + { id: 'initiatives', mount: 'initiatives' }, + ]); + + expect(registry.list().map((definition) => definition.id)).toEqual([ + 'decisions', + 'initiatives', + ]); + expect(registry.get('initiatives')).toEqual({ + id: 'initiatives', + mount: 'initiatives', + }); + expect(registry.get('missing')).toBeUndefined(); + expect(registry.require('decisions').mount).toBe('decisions'); + expect(() => registry.require('missing')).toThrow(/Unknown collection/u); + }); + + it('rejects duplicate collection ids and mounts', () => { + expect(() => + createCollectionRegistry([ + { id: 'initiatives', mount: 'initiatives' }, + { id: 'initiatives', mount: 'initiative-plans' }, + ]) + ).toThrow(/Duplicate collection id/u); + + expect(() => + createCollectionRegistry([ + { id: 'initiatives', mount: 'shared-context' }, + { id: 'decisions', mount: 'shared-context' }, + ]) + ).toThrow(/Duplicate collection mount/u); + }); + }); + + describe('mounted collections', () => { + it('mounts initiatives as a generic collection without creating files', () => { + const storeRoot = path.join(tempDir, 'acme-context'); + const registry = createCollectionRegistry([{ id: 'initiatives', mount: 'initiatives' }]); + const mounted = mountCollections({ storeRoot, collections: registry }); + const initiatives = mounted.require('initiatives'); + + expect(initiatives.collectionId).toBe('initiatives'); + expect(initiatives.mount).toBe('initiatives'); + expect(initiatives.mountRoot).toBe(path.join(storeRoot, 'initiatives')); + expect(initiatives.resolvePath('launch-billing-flow/initiative.yaml')).toBe( + path.join(storeRoot, 'initiatives', 'launch-billing-flow', 'initiative.yaml') + ); + expect(initiatives.resolvePath('..draft/notes.md')).toBe( + path.join(storeRoot, 'initiatives', '..draft', 'notes.md') + ); + expect(initiatives.resolvePath()).toBe(path.join(storeRoot, 'initiatives')); + expect(initiatives.toStorePath('launch-billing-flow/initiative.yaml')).toBe( + 'initiatives/launch-billing-flow/initiative.yaml' + ); + expect(initiatives.toStorePath()).toBe('initiatives'); + expect(fs.existsSync(path.join(storeRoot, 'initiatives'))).toBe(false); + }); + + it('preserves Windows-style store roots when resolving filesystem paths', () => { + const registry = createCollectionRegistry([{ id: 'initiatives', mount: 'initiatives' }]); + const mounted = mountCollections({ + storeRoot: 'D:\\stores\\acme-context', + collections: registry, + }); + const initiatives = mounted.require('initiatives'); + + expect(initiatives.mountRoot).toBe('D:\\stores\\acme-context\\initiatives'); + expect(initiatives.resolvePath('launch/initiative.yaml')).toBe( + 'D:\\stores\\acme-context\\initiatives\\launch\\initiative.yaml' + ); + expect(initiatives.toStorePath('launch/initiative.yaml')).toBe( + 'initiatives/launch/initiative.yaml' + ); + }); + + it('passes mounted context into collection handles', () => { + const seenContexts: MountedCollectionContext[] = []; + const registry = createCollectionRegistry([ + { + id: 'initiatives', + mount: 'initiatives', + createHandle(context) { + seenContexts.push(context); + return { + rootPath: context.resolvePath(), + storePath: context.toStorePath('launch/initiative.yaml'), + }; + }, + }, + { id: 'decisions', mount: 'decisions' }, + ]); + + const storeRoot = path.join(tempDir, 'acme-context'); + const mounted = mountCollections({ storeRoot, collections: registry }); + const initiatives = mounted.require<{ + rootPath: string; + storePath: string; + }>('initiatives'); + const decisions = mounted.require('decisions'); + + expect(seenContexts).toHaveLength(1); + expect(seenContexts[0].collectionId).toBe('initiatives'); + expect(initiatives.handle).toEqual({ + rootPath: path.join(storeRoot, 'initiatives'), + storePath: 'initiatives/launch/initiative.yaml', + }); + expect(decisions.handle).toBeUndefined(); + expect(mounted.get('missing')).toBeUndefined(); + expect(() => mounted.require('missing')).toThrow(/Unknown mounted collection/u); + }); + + it('rejects empty store roots', () => { + const registry = createCollectionRegistry([{ id: 'initiatives', mount: 'initiatives' }]); + + expect(() => mountCollections({ storeRoot: '', collections: registry })).toThrow( + /must not be empty/u + ); + }); + }); +}); diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts new file mode 100644 index 0000000000..34ff08e248 --- /dev/null +++ b/test/core/completions/command-registry.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import type { Command } from 'commander'; + +import { COMMAND_REGISTRY } from '../../../src/core/completions/command-registry.js'; +import { program } from '../../../src/cli/index.js'; +import type { + CommandDefinition, + FlagDefinition, + PositionalDefinition, +} from '../../../src/core/completions/types.js'; + +function command(name: string) { + return COMMAND_REGISTRY.find((entry) => entry.name === name); +} + +describe('command completion registry', () => { + function registryChildren(commandList: CommandDefinition[] | undefined): Map<string, CommandDefinition> { + return new Map((commandList ?? []).map((entry) => [entry.name, entry])); + } + + function visibleChildCommands(command: Command): Command[] { + return command.commands.filter((child) => !(child as unknown as { _hidden?: boolean })._hidden); + } + + function commandAliases(command: Command): string[] { + return command.aliases(); + } + + interface FlagShape { + name: string; + short?: string; + takesValue?: true; + } + + interface PositionalShape { + name: string; + optional?: true; + } + + function normalizeName(name: string): string { + return name.replace(/[^a-z0-9]/giu, '').toLowerCase(); + } + + function toFlagShape(flag: FlagDefinition): FlagShape { + return { + name: flag.name, + ...(flag.short ? { short: flag.short } : {}), + ...(flag.takesValue ? { takesValue: true as const } : {}), + }; + } + + function toCommanderFlagShape(command: Command): FlagShape[] { + return command.options + .filter((option) => !option.hidden) + .map((option) => ({ + name: option.long.replace(/^--/u, ''), + ...(option.short ? { short: option.short.replace(/^-/, '') } : {}), + ...(option.required || option.optional ? { takesValue: true as const } : {}), + })); + } + + function sortedFlags(flags: FlagShape[]): FlagShape[] { + return [...flags].sort((left, right) => left.name.localeCompare(right.name)); + } + + function toPositionalShape(positional: PositionalDefinition): PositionalShape { + return { + name: normalizeName(positional.name), + ...(positional.optional ? { optional: true as const } : {}), + }; + } + + function toCommanderPositionalShapes(command: Command): PositionalShape[] { + return command.registeredArguments.map((argument) => ({ + name: normalizeName(argument.name()), + ...(argument.required ? {} : { optional: true as const }), + })); + } + + function assertPositionalParity( + commandPath: string, + command: Command, + entry: CommandDefinition + ): void { + const commandPositionals = toCommanderPositionalShapes(command); + + if (commandPositionals.length === 0) { + expect(entry.acceptsPositional ?? false, `${commandPath} accepts positional`).toBe(false); + expect(entry.positionals ?? [], `${commandPath} positionals`).toEqual([]); + return; + } + + expect(entry.acceptsPositional, `${commandPath} accepts positional`).toBe(true); + expect( + (entry.positionals ?? []).map(toPositionalShape), + `${commandPath} positionals` + ).toEqual(commandPositionals); + } + + function assertCommandShape( + commandPath: string, + command: Command, + entry: CommandDefinition + ): void { + expect(sortedFlags(entry.flags.map(toFlagShape)), `${commandPath} flags`).toEqual( + sortedFlags(toCommanderFlagShape(command)) + ); + assertPositionalParity(commandPath, command, entry); + } + + function assertRegistryParity( + command: Command, + registry: CommandDefinition[], + parentPath = '' + ): void { + const registryByName = registryChildren(registry); + + for (const child of visibleChildCommands(command)) { + const commandPath = parentPath ? `${parentPath} ${child.name()}` : child.name(); + const names = [child.name(), ...commandAliases(child)]; + for (const name of names) { + expect(registryByName.has(name), `missing completion entry for ${commandPath} alias ${name}`).toBe(true); + } + + const entry = registryByName.get(child.name()); + if (!entry) { + continue; + } + + assertCommandShape(commandPath, child, entry); + + for (const alias of commandAliases(child)) { + const aliasEntry = registryByName.get(alias); + expect(aliasEntry, `${commandPath} alias ${alias}`).toBeDefined(); + if (aliasEntry) { + assertCommandShape(`${commandPath} alias ${alias}`, child, aliasEntry); + } + } + + assertRegistryParity(child, entry.subcommands ?? [], commandPath); + } + } + + it('matches visible Commander command flags and aliases', () => { + assertRegistryParity(program, COMMAND_REGISTRY); + }); + + it('tracks top-level workflow commands', () => { + for (const name of ['status', 'instructions', 'templates', 'schemas', 'new', 'set']) { + expect(command(name), `${name} command`).toBeDefined(); + } + + 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', + ]); + }); + + it('tracks context-store commands and aliases', () => { + const contextStore = command('context-store'); + + expect(contextStore?.subcommands?.map((entry) => entry.name)).toEqual([ + 'setup', + 'register', + 'list', + 'ls', + 'doctor', + ]); + + const setup = contextStore?.subcommands?.find((entry) => entry.name === 'setup'); + expect(setup?.flags.map((flag) => flag.name)).toEqual([ + 'path', + 'init-git', + 'no-init-git', + 'json', + ]); + }); +}); diff --git a/test/core/context-store/foundation.test.ts b/test/core/context-store/foundation.test.ts new file mode 100644 index 0000000000..6921ac1f20 --- /dev/null +++ b/test/core/context-store/foundation.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir } from '../../../src/core/global-config.js'; +import { + CONTEXT_STORE_METADATA_DIR_NAME, + CONTEXT_STORE_METADATA_FILE_NAME, + CONTEXT_STORE_REGISTRY_FILE_NAME, + CONTEXT_STORES_DIR_NAME, + getContextStoreMetadataDir, + getContextStoreMetadataPath, + getContextStoreRegistryPath, + getContextStoresDir, + isContextStoreRoot, + isValidContextStoreId, + listContextStoreRegistryEntries, + parseContextStoreMetadataState, + parseContextStoreRegistryState, + readContextStoreMetadataState, + readContextStoreRegistryState, + readOptionalContextStoreMetadataState, + resolveGitContextStoreBackendConfig, + serializeContextStoreMetadataState, + serializeContextStoreRegistryState, + validateContextStoreId, + writeContextStoreMetadataState, + writeContextStoreRegistryState, +} from '../../../src/core/context-store/index.js'; + +describe('context store foundation', () => { + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-context-store-foundation-')); + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function expectedExistingPath(existingPath: string): string { + return fs.realpathSync.native(existingPath); + } + + function expectSameExistingPath(actualPath: string, expectedPath: string): void { + expect(fs.realpathSync.native(actualPath)).toBe(expectedExistingPath(expectedPath)); + } + + describe('path helpers', () => { + it('exposes context store constants', () => { + expect(CONTEXT_STORE_METADATA_DIR_NAME).toBe('.openspec-store'); + expect(CONTEXT_STORE_METADATA_FILE_NAME).toBe('store.yaml'); + expect(CONTEXT_STORES_DIR_NAME).toBe('context-stores'); + expect(CONTEXT_STORE_REGISTRY_FILE_NAME).toBe('registry.yaml'); + }); + + it('returns registry and metadata paths', () => { + process.env.XDG_DATA_HOME = tempDir; + const storeRoot = path.join(tempDir, 'acme-context'); + + expect(getContextStoresDir()).toBe(path.join(tempDir, 'openspec', 'context-stores')); + expect(getContextStoreRegistryPath()).toBe( + path.join(tempDir, 'openspec', 'context-stores', 'registry.yaml') + ); + expect(getContextStoreMetadataDir(storeRoot)).toBe( + path.join(storeRoot, '.openspec-store') + ); + expect(getContextStoreMetadataPath(storeRoot)).toBe( + path.join(storeRoot, '.openspec-store', 'store.yaml') + ); + }); + + it('uses global data dir options for registry locations', () => { + const dataDir = getGlobalDataDir({ + env: {}, + platform: 'linux', + homedir: '/home/tabish', + }); + + expect(getContextStoresDir({ globalDataDir: dataDir })).toBe( + '/home/tabish/.local/share/openspec/context-stores' + ); + expect(getContextStoreRegistryPath({ globalDataDir: dataDir })).toBe( + '/home/tabish/.local/share/openspec/context-stores/registry.yaml' + ); + }); + + it('preserves Windows-style store root strings when building metadata paths', () => { + expect(getContextStoreMetadataPath('D:\\repos\\acme-context')).toBe( + 'D:\\repos\\acme-context\\.openspec-store\\store.yaml' + ); + }); + }); + + describe('id validation', () => { + it('accepts kebab-case context store ids', () => { + expect(validateContextStoreId('acme')).toBe('acme'); + expect(isValidContextStoreId('acme-context')).toBe(true); + expect(isValidContextStoreId('context2')).toBe(true); + }); + + it('rejects ids that are not safe kebab-case folder names', () => { + for (const invalidId of [ + '', + '.', + '..', + 'bad/name', + 'bad\\name', + 'Acme', + 'acme_context', + 'acme.context', + 'acme context', + '-acme', + 'acme-', + 'acme--context', + ]) { + expect(isValidContextStoreId(invalidId)).toBe(false); + } + }); + }); + + describe('registry parsing and serialization', () => { + it('parses and serializes a strict Git/local context store registry', () => { + const registry = parseContextStoreRegistryState(`version: 1 +stores: + zeta-context: + backend: + type: git + local_path: /repos/zeta-context + acme-context: + backend: + type: git + local_path: /repos/acme-context + remote: git@github.com:acme/context.git + branch: main +`); + + expect(registry.stores['acme-context'].backend).toEqual({ + type: 'git', + local_path: '/repos/acme-context', + remote: 'git@github.com:acme/context.git', + branch: 'main', + }); + expect(listContextStoreRegistryEntries(registry).map((entry) => entry.id)).toEqual([ + 'acme-context', + 'zeta-context', + ]); + expect(parseContextStoreRegistryState(serializeContextStoreRegistryState(registry))).toEqual( + registry + ); + }); + + it('rejects invalid registry structure and ids', () => { + expect(() => + parseContextStoreRegistryState(`version: 2 +stores: {} +`) + ).toThrow(/Invalid context store registry state/u); + + expect(() => + parseContextStoreRegistryState(`version: 1 +stores: + Acme: + backend: + type: git + local_path: /repos/acme +`) + ).toThrow(/Invalid context store id/u); + + expect(() => + parseContextStoreRegistryState(`version: 1 +stores: + acme: + backend: + type: memory + local_path: /repos/acme +`) + ).toThrow(/Invalid context store registry state/u); + + expect(() => + parseContextStoreRegistryState(`version: 1 +stores: + acme: + backend: + type: git + local_path: "" +`) + ).toThrow(/Invalid context store registry state/u); + }); + + it('rejects unknown registry fields', () => { + expect(() => + parseContextStoreRegistryState(`version: 1 +stores: {} +extra: true +`) + ).toThrow(/Invalid context store registry state/u); + + expect(() => + parseContextStoreRegistryState(`version: 1 +stores: + acme: + backend: + type: git + local_path: /repos/acme + depth: 1 +`) + ).toThrow(/Invalid context store registry state/u); + }); + }); + + describe('metadata parsing and serialization', () => { + it('parses and serializes portable store metadata', () => { + const metadata = parseContextStoreMetadataState(`version: 1 +id: acme-context +`); + + expect(metadata).toEqual({ + version: 1, + id: 'acme-context', + }); + expect(parseContextStoreMetadataState(serializeContextStoreMetadataState(metadata))).toEqual( + metadata + ); + }); + + it('rejects invalid metadata state', () => { + expect(() => + parseContextStoreMetadataState(`version: 1 +id: Acme +`) + ).toThrow(/Context store id must be kebab-case/u); + + expect(() => + parseContextStoreMetadataState(`version: 1 +id: acme +local_path: /repos/acme +`) + ).toThrow(/Invalid context store metadata state/u); + }); + }); + + describe('registry IO', () => { + it('returns null for a missing local registry', async () => { + await expect(readContextStoreRegistryState({ globalDataDir: tempDir })).resolves.toBeNull(); + }); + + it('writes and reads the machine-local registry', async () => { + const registry = { + version: 1 as const, + stores: { + 'acme-context': { + backend: { + type: 'git' as const, + local_path: path.join(tempDir, 'acme-context'), + remote: 'git@github.com:acme/context.git', + }, + }, + }, + }; + + await writeContextStoreRegistryState(registry, { globalDataDir: tempDir }); + + expect(fs.existsSync(getContextStoreRegistryPath({ globalDataDir: tempDir }))).toBe(true); + await expect(readContextStoreRegistryState({ globalDataDir: tempDir })).resolves.toEqual( + registry + ); + }); + }); + + describe('store metadata IO', () => { + it('writes and reads portable metadata inside the store root', async () => { + const storeRoot = path.join(tempDir, 'acme-context'); + + await expect(isContextStoreRoot(storeRoot)).resolves.toBe(false); + await writeContextStoreMetadataState(storeRoot, { + version: 1, + id: 'acme-context', + }); + + await expect(isContextStoreRoot(storeRoot)).resolves.toBe(true); + await expect(readContextStoreMetadataState(storeRoot)).resolves.toEqual({ + version: 1, + id: 'acme-context', + }); + await expect(readOptionalContextStoreMetadataState(storeRoot)).resolves.toEqual({ + version: 1, + id: 'acme-context', + }); + }); + + it('returns null only when optional metadata is missing', async () => { + const storeRoot = path.join(tempDir, 'missing-store'); + + await expect(readOptionalContextStoreMetadataState(storeRoot)).resolves.toBeNull(); + + fs.mkdirSync(path.dirname(getContextStoreMetadataPath(storeRoot)), { recursive: true }); + fs.writeFileSync(getContextStoreMetadataPath(storeRoot), 'version: nope\n'); + + await expect(readOptionalContextStoreMetadataState(storeRoot)).rejects.toThrow( + /Invalid context store metadata state/u + ); + }); + }); + + describe('Git/local backend config', () => { + it('resolves an existing local checkout path without creating or managing it', async () => { + const storesDir = path.join(tempDir, 'stores'); + const localPath = path.join(storesDir, 'acme-context'); + fs.mkdirSync(localPath, { recursive: true }); + + const backend = await resolveGitContextStoreBackendConfig( + { + localPath: 'acme-context', + remote: 'git@github.com:acme/context.git', + branch: 'main', + }, + storesDir + ); + + expect(backend).toEqual({ + type: 'git', + local_path: expect.any(String), + remote: 'git@github.com:acme/context.git', + branch: 'main', + }); + expectSameExistingPath(backend.local_path, localPath); + expect(fs.readdirSync(localPath)).toEqual([]); + }); + + it('rejects missing paths and empty optional Git config values', async () => { + await expect( + resolveGitContextStoreBackendConfig({ localPath: '' }, tempDir) + ).rejects.toThrow(/must not be empty/u); + + await expect( + resolveGitContextStoreBackendConfig({ localPath: 'missing' }, tempDir) + ).rejects.toThrow(/does not exist/u); + + const localPath = path.join(tempDir, 'acme-context'); + fs.mkdirSync(localPath, { recursive: true }); + + await expect( + resolveGitContextStoreBackendConfig({ localPath, remote: '' }, tempDir) + ).rejects.toThrow(/remote must not be empty/u); + + await expect( + resolveGitContextStoreBackendConfig({ localPath, branch: '' }, tempDir) + ).rejects.toThrow(/branch must not be empty/u); + }); + }); +}); diff --git a/test/core/context-store/registry.test.ts b/test/core/context-store/registry.test.ts new file mode 100644 index 0000000000..2122a584bd --- /dev/null +++ b/test/core/context-store/registry.test.ts @@ -0,0 +1,462 @@ +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 { + getContextStoreMetadataPath, + getGlobalDataDir, + createPathContextStoreBinding, + createRegisteredContextStoreBinding, + mountInitiativesCollection, + prepareContextStoreSetup, + readContextStoreMetadataState, + readContextStoreRegistryState, + registerContextStore, + resolveContextStoreBinding, + resolveRegisteredContextStore, + listRegisteredContextStores, + setupPreparedContextStore, + writeContextStoreMetadataState, + writeContextStoreRegistryState, +} from '../../../src/core/index.js'; + +describe('context store registry facade', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-context-store-registry-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function mkdir(relativePath: string): string { + const dirPath = path.join(tempDir, relativePath); + fs.mkdirSync(dirPath, { recursive: true }); + return dirPath; + } + + function canonicalPath(existingPath: string): string { + return fs.realpathSync.native(existingPath); + } + + function expectSameExistingPath(actualPath: string, expectedPath: string): void { + expect(canonicalPath(actualPath)).toBe(canonicalPath(expectedPath)); + } + + it('registers a local Git context store by writing metadata and registry state', async () => { + const storesDir = mkdir('stores'); + const storeRoot = mkdir('stores/acme-context'); + + const registered = await registerContextStore({ + id: 'acme-context', + localPath: 'acme-context', + remote: 'git@github.com:acme/context.git', + branch: 'main', + cwd: storesDir, + globalDataDir: tempDir, + }); + + expect(registered).toEqual({ + id: 'acme-context', + storeRoot: expect.any(String), + backend: { + type: 'git', + local_path: expect.any(String), + remote: 'git@github.com:acme/context.git', + branch: 'main', + }, + }); + expectSameExistingPath(registered.storeRoot, storeRoot); + expectSameExistingPath(registered.backend.local_path, storeRoot); + + await expect(readContextStoreMetadataState(storeRoot)).resolves.toEqual({ + version: 1, + id: 'acme-context', + }); + const registry = await readContextStoreRegistryState({ globalDataDir: tempDir }); + expect(registry).toEqual({ + version: 1, + stores: { + 'acme-context': { + backend: { + type: 'git', + local_path: expect.any(String), + remote: 'git@github.com:acme/context.git', + branch: 'main', + }, + }, + }, + }); + expectSameExistingPath( + registry?.stores['acme-context'].backend.local_path ?? '', + storeRoot + ); + }); + + it('rejects a registered path rewrite for an existing id', async () => { + const oldRoot = mkdir('old/acme-context'); + const newRoot = mkdir('new/acme-context'); + const zetaRoot = mkdir('zeta-context'); + + await writeContextStoreMetadataState(newRoot, { version: 1, id: 'acme-context' }); + await writeContextStoreRegistryState( + { + version: 1, + stores: { + 'zeta-context': { + backend: { + type: 'git', + local_path: zetaRoot, + }, + }, + 'acme-context': { + backend: { + type: 'git', + local_path: oldRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + + await expect( + registerContextStore({ + id: 'acme-context', + localPath: newRoot, + globalDataDir: tempDir, + }) + ).rejects.toThrow(/already registered/u); + + const stores = await listRegisteredContextStores({ globalDataDir: tempDir }); + expect(stores.map((store) => store.id)).toEqual(['acme-context', 'zeta-context']); + expectSameExistingPath(stores[0].storeRoot, oldRoot); + expectSameExistingPath(stores[0].backend.local_path, oldRoot); + expectSameExistingPath(stores[1].storeRoot, zetaRoot); + expectSameExistingPath(stores[1].backend.local_path, zetaRoot); + }); + + it('rejects registration when existing store metadata has a different id', async () => { + const storeRoot = mkdir('acme-context'); + await writeContextStoreMetadataState(storeRoot, { version: 1, id: 'other-context' }); + + await expect( + registerContextStore({ + id: 'acme-context', + localPath: storeRoot, + globalDataDir: tempDir, + }) + ).rejects.toThrow(/does not match registered id/u); + + await expect(readContextStoreRegistryState({ globalDataDir: tempDir })).resolves.toBeNull(); + }); + + it('rejects invalid registration input before writing registry state', async () => { + const storeRoot = mkdir('acme-context'); + + await expect( + registerContextStore({ + id: 'Acme', + localPath: storeRoot, + globalDataDir: tempDir, + }) + ).rejects.toThrow(/kebab-case/u); + + await expect( + registerContextStore({ + id: 'acme-context', + localPath: storeRoot, + remote: '', + globalDataDir: tempDir, + }) + ).rejects.toThrow(/remote must not be empty/u); + + await expect(readContextStoreRegistryState({ globalDataDir: tempDir })).resolves.toBeNull(); + }); + + it('removes newly created store metadata when the registry write fails', async () => { + const storeRoot = mkdir('acme-context'); + const blockedGlobalDataDir = path.join(tempDir, 'blocked-data-dir'); + fs.writeFileSync(blockedGlobalDataDir, 'not a directory\n'); + + await expect( + registerContextStore({ + id: 'acme-context', + localPath: storeRoot, + globalDataDir: blockedGlobalDataDir, + }) + ).rejects.toThrow(); + + expect(fs.existsSync(getContextStoreMetadataPath(storeRoot))).toBe(false); + }); + + it('commits prepared setup against the latest registry state', async () => { + const originalEnv = { ...process.env }; + const dataHome = path.join(tempDir, 'data-home'); + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + }; + + try { + const globalDataDir = getGlobalDataDir(); + const preparedRoot = path.join(tempDir, 'team-context'); + const prepared = await prepareContextStoreSetup({ + id: 'team-context', + path: preparedRoot, + }); + const otherRoot = mkdir('other-context'); + await writeContextStoreMetadataState(otherRoot, { + version: 1, + id: 'other-context', + }); + await writeContextStoreRegistryState( + { + version: 1, + stores: { + 'other-context': { + backend: { + type: 'git', + local_path: otherRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + await setupPreparedContextStore(prepared, { initGit: false }); + + const registry = await readContextStoreRegistryState({ globalDataDir }); + expect(Object.keys(registry?.stores ?? {})).toEqual(['other-context', 'team-context']); + expectSameExistingPath(registry?.stores['other-context'].backend.local_path ?? '', otherRoot); + expectSameExistingPath(registry?.stores['team-context'].backend.local_path ?? '', preparedRoot); + } finally { + process.env = originalEnv; + } + }); + + it('lists registered context stores from the machine-local registry', async () => { + const acmeRoot = mkdir('acme-context'); + const zetaRoot = mkdir('zeta-context'); + + await expect(listRegisteredContextStores({ globalDataDir: tempDir })).resolves.toEqual([]); + + await writeContextStoreRegistryState( + { + version: 1, + stores: { + 'zeta-context': { + backend: { + type: 'git', + local_path: zetaRoot, + }, + }, + 'acme-context': { + backend: { + type: 'git', + local_path: acmeRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + + const stores = await listRegisteredContextStores({ globalDataDir: tempDir }); + expect(stores).toEqual([ + { + id: 'acme-context', + storeRoot: expect.any(String), + backend: { + type: 'git', + local_path: expect.any(String), + }, + }, + { + id: 'zeta-context', + storeRoot: expect.any(String), + backend: { + type: 'git', + local_path: expect.any(String), + }, + }, + ]); + expectSameExistingPath(stores[0].storeRoot, acmeRoot); + expectSameExistingPath(stores[0].backend.local_path, acmeRoot); + expectSameExistingPath(stores[1].storeRoot, zetaRoot); + expectSameExistingPath(stores[1].backend.local_path, zetaRoot); + }); + + it('resolves a registered context store and validates portable metadata identity', async () => { + const storeRoot = mkdir('acme-context'); + await writeContextStoreMetadataState(storeRoot, { version: 1, id: 'acme-context' }); + await writeContextStoreRegistryState( + { + version: 1, + stores: { + 'acme-context': { + backend: { + type: 'git', + local_path: storeRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + + const resolved = await resolveRegisteredContextStore({ + id: 'acme-context', + globalDataDir: tempDir, + }); + expect(resolved).toEqual({ + id: 'acme-context', + storeRoot: expect.any(String), + backend: { + type: 'git', + local_path: expect.any(String), + }, + }); + expectSameExistingPath(resolved.storeRoot, storeRoot); + expectSameExistingPath(resolved.backend.local_path, storeRoot); + }); + + it('resolves registry and path context store bindings', async () => { + const registeredRoot = mkdir('registered-context'); + const pathRoot = mkdir('path-context'); + await writeContextStoreMetadataState(registeredRoot, { + version: 1, + id: 'registered-context', + }); + await writeContextStoreMetadataState(pathRoot, { + version: 1, + id: 'path-context', + }); + await writeContextStoreRegistryState( + { + version: 1, + stores: { + 'registered-context': { + backend: { + type: 'git', + local_path: registeredRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + + const registered = await resolveContextStoreBinding( + createRegisteredContextStoreBinding('registered-context'), + { globalDataDir: tempDir } + ); + expect(registered).toEqual( + expect.objectContaining({ + id: 'registered-context', + root: expect.any(String), + source: 'registry', + warnings: [], + }) + ); + expectSameExistingPath(registered.root, registeredRoot); + + const pathBound = await resolveContextStoreBinding( + createPathContextStoreBinding({ + id: 'path-context', + path: pathRoot, + }), + { globalDataDir: tempDir } + ); + expect(pathBound).toEqual( + expect.objectContaining({ + id: 'path-context', + root: expect.any(String), + source: 'path', + warnings: [], + }) + ); + expectSameExistingPath(pathBound.root, pathRoot); + }); + + it('warns when a path binding resolves to a different metadata id', async () => { + const storeRoot = mkdir('renamed-context'); + await writeContextStoreMetadataState(storeRoot, { + version: 1, + id: 'new-context', + }); + + const resolved = await resolveContextStoreBinding({ + id: 'old-context', + selector: { + kind: 'path', + path: storeRoot, + observed_id: 'old-context', + }, + }); + + expect(resolved.id).toBe('new-context'); + expect(resolved.warnings).toEqual([ + expect.objectContaining({ + code: 'context_store_binding_id_changed', + }), + ]); + }); + + it('rejects missing registry entries and bad registered metadata', async () => { + await expect( + resolveRegisteredContextStore({ id: 'missing-context', globalDataDir: tempDir }) + ).rejects.toThrow(/No context store registry found/u); + + const missingMetadataRoot = mkdir('missing-metadata'); + const mismatchedRoot = mkdir('mismatched'); + await writeContextStoreMetadataState(mismatchedRoot, { version: 1, id: 'other-context' }); + await writeContextStoreRegistryState( + { + version: 1, + stores: { + 'missing-metadata': { + backend: { + type: 'git', + local_path: missingMetadataRoot, + }, + }, + mismatched: { + backend: { + type: 'git', + local_path: mismatchedRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + + await expect( + resolveRegisteredContextStore({ id: 'unknown-context', globalDataDir: tempDir }) + ).rejects.toThrow(/Unknown context store/u); + + await expect( + resolveRegisteredContextStore({ id: 'missing-metadata', globalDataDir: tempDir }) + ).rejects.toThrow(new RegExp(getContextStoreMetadataPath(missingMetadataRoot).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u')); + + await expect( + resolveRegisteredContextStore({ id: 'mismatched', globalDataDir: tempDir }) + ).rejects.toThrow(/does not match registered id/u); + }); + + it('mounts the initiatives collection for a resolved store root', async () => { + const storeRoot = mkdir('acme-context'); + const initiatives = mountInitiativesCollection(storeRoot); + + expect(initiatives.collectionId).toBe('initiatives'); + expect(initiatives.mountRoot).toBe(path.join(storeRoot, 'initiatives')); + expect(initiatives.toStorePath('launch-billing-flow/initiative.yaml')).toBe( + 'initiatives/launch-billing-flow/initiative.yaml' + ); + }); +}); diff --git a/test/core/planning-home.test.ts b/test/core/planning-home.test.ts index d15fd29ed0..57c0275169 100644 --- a/test/core/planning-home.test.ts +++ b/test/core/planning-home.test.ts @@ -66,4 +66,29 @@ describe('planning home paths', () => { expect(planningHome.kind).toBe('workspace'); expect(planningHome.root).toBe(fs.realpathSync.native(realWorkspaceRoot)); }); + + it('surfaces invalid current workspace state instead of falling back to legacy state', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-planning-home-')); + tempDirs.push(tempDir); + const workspaceRoot = path.join(tempDir, 'workspace'); + + fs.mkdirSync(path.join(workspaceRoot, '.openspec-workspace'), { recursive: true }); + fs.writeFileSync( + path.join(workspaceRoot, 'workspace.yaml'), + 'version: 1\nname: bad/name\ncontext: null\nlinks: {}\n', + 'utf-8' + ); + fs.writeFileSync( + path.join(workspaceRoot, '.openspec-workspace', 'workspace.yaml'), + 'version: 1\nname: legacy-platform\nlinks: {}\n', + 'utf-8' + ); + + expect(() => + resolveCurrentPlanningHomeSync({ + startPath: workspaceRoot, + allowImplicitRepoRoot: false, + }) + ).toThrow(/Workspace name/u); + }); }); diff --git a/test/core/workspace/foundation.test.ts b/test/core/workspace/foundation.test.ts index f06ee6cc88..af2b38a306 100644 --- a/test/core/workspace/foundation.test.ts +++ b/test/core/workspace/foundation.test.ts @@ -8,11 +8,9 @@ import { FileSystemUtils } from '../../../src/utils/file-system.js'; import { MANAGED_WORKSPACES_DIR_NAME, WORKSPACE_CHANGES_DIR_NAME, - WORKSPACE_LOCAL_STATE_FILE_NAME, - WORKSPACE_LOCAL_STATE_IGNORE_PATTERN, WORKSPACE_METADATA_DIR_NAME, WORKSPACE_REGISTRY_FILE_NAME, - WORKSPACE_SHARED_STATE_FILE_NAME, + WORKSPACE_VIEW_STATE_FILE_NAME, applyWorkspaceGuidanceBlock, buildWorkspaceCodeWorkspaceContent, buildWorkspaceGuidanceBlock, @@ -22,33 +20,28 @@ import { getWorkspaceCodeWorkspaceFileName, getWorkspaceCodeWorkspacePath, getWorkspaceChangesDir, - getWorkspaceLocalStatePath, getWorkspaceMetadataDir, getWorkspacePortableIgnorePatterns, getWorkspaceRegistryPath, - getWorkspaceSharedStatePath, + getWorkspaceViewStatePath, isValidWorkspaceLinkName, isValidWorkspaceName, isWorkspaceRoot, isWorkspaceExecutableAvailable, listWorkspaceRegistryEntries, listWorkspaceOpenerChoices, - parseWorkspaceLocalState, parseWorkspacePreferredOpenerValue, parseWorkspaceRegistryState, - parseWorkspaceSharedState, parseWorkspaceSetupLinkInput, - readWorkspaceLocalState, - readOptionalWorkspaceLocalState, + parseWorkspaceViewState, readWorkspaceRegistryState, - readWorkspaceSharedState, - serializeWorkspaceLocalState, + readWorkspaceViewState, + serializeWorkspaceViewState, syncWorkspaceOpenSurface, workspaceChangesDirExists, - writeWorkspaceLocalState, + writeWorkspaceViewState, writeWorkspaceRegistryState, } from '../../../src/core/workspace/index.js'; - describe('workspace foundation', () => { let tempDir: string; let originalEnv: NodeJS.ProcessEnv; @@ -65,19 +58,13 @@ describe('workspace foundation', () => { function createWorkspaceRoot(name = 'platform'): string { const workspaceRoot = path.join(tempDir, name); - fs.mkdirSync(path.join(workspaceRoot, WORKSPACE_METADATA_DIR_NAME), { recursive: true }); - fs.mkdirSync(path.join(workspaceRoot, WORKSPACE_CHANGES_DIR_NAME), { recursive: true }); + fs.mkdirSync(workspaceRoot, { recursive: true }); fs.writeFileSync( - path.join(workspaceRoot, WORKSPACE_METADATA_DIR_NAME, WORKSPACE_SHARED_STATE_FILE_NAME), + getWorkspaceViewStatePath(workspaceRoot), `version: 1 name: ${name} +context: null links: {} -` - ); - fs.writeFileSync( - path.join(workspaceRoot, WORKSPACE_METADATA_DIR_NAME, WORKSPACE_LOCAL_STATE_FILE_NAME), - `version: 1 -paths: {} ` ); @@ -85,14 +72,18 @@ paths: {} } function expectedExistingPath(existingPath: string): string { - return process.platform === 'win32' ? fs.realpathSync.native(existingPath) : existingPath; + return fs.realpathSync.native(existingPath); + } + + function expectSameExistingPath(actualPath: string | null, expectedPath: string): void { + expect(actualPath).not.toBeNull(); + expect(fs.realpathSync.native(actualPath as string)).toBe(expectedExistingPath(expectedPath)); } describe('path helpers', () => { it('exposes the workspace constants', () => { expect(WORKSPACE_METADATA_DIR_NAME).toBe('.openspec-workspace'); - expect(WORKSPACE_SHARED_STATE_FILE_NAME).toBe('workspace.yaml'); - expect(WORKSPACE_LOCAL_STATE_FILE_NAME).toBe('local.yaml'); + expect(WORKSPACE_VIEW_STATE_FILE_NAME).toBe('workspace.yaml'); expect(WORKSPACE_CHANGES_DIR_NAME).toBe('changes'); expect(MANAGED_WORKSPACES_DIR_NAME).toBe('workspaces'); expect(WORKSPACE_REGISTRY_FILE_NAME).toBe('registry.yaml'); @@ -104,11 +95,8 @@ paths: {} expect(getWorkspaceMetadataDir(workspaceRoot)).toBe( path.join(workspaceRoot, '.openspec-workspace') ); - expect(getWorkspaceSharedStatePath(workspaceRoot)).toBe( - path.join(workspaceRoot, '.openspec-workspace', 'workspace.yaml') - ); - expect(getWorkspaceLocalStatePath(workspaceRoot)).toBe( - path.join(workspaceRoot, '.openspec-workspace', 'local.yaml') + expect(getWorkspaceViewStatePath(workspaceRoot)).toBe( + path.join(workspaceRoot, 'workspace.yaml') ); expect(getWorkspaceChangesDir(workspaceRoot)).toBe(path.join(workspaceRoot, 'changes')); expect(getWorkspaceCodeWorkspaceFileName('platform')).toBe('platform.code-workspace'); @@ -120,11 +108,8 @@ paths: {} it('preserves Windows-style location strings when building workspace file paths', () => { const workspaceRoot = 'D:\\repos\\platform-workspace'; - expect(getWorkspaceSharedStatePath(workspaceRoot)).toBe( - 'D:\\repos\\platform-workspace\\.openspec-workspace\\workspace.yaml' - ); - expect(getWorkspaceLocalStatePath(workspaceRoot)).toBe( - 'D:\\repos\\platform-workspace\\.openspec-workspace\\local.yaml' + expect(getWorkspaceViewStatePath(workspaceRoot)).toBe( + 'D:\\repos\\platform-workspace\\workspace.yaml' ); }); @@ -165,10 +150,8 @@ paths: {} }); it('exposes the portable collaboration ignore rule for local state', () => { - expect(WORKSPACE_LOCAL_STATE_IGNORE_PATTERN).toBe('.openspec-workspace/local.yaml'); - expect(getWorkspacePortableIgnorePatterns()).toEqual(['.openspec-workspace/local.yaml']); + expect(getWorkspacePortableIgnorePatterns()).toEqual([]); expect(getWorkspacePortableIgnorePatterns('platform')).toEqual([ - '.openspec-workspace/local.yaml', 'platform.code-workspace', ]); }); @@ -214,12 +197,8 @@ paths: {} fs.mkdirSync(nestedDir, { recursive: true }); await expect(isWorkspaceRoot(workspaceRoot)).resolves.toBe(true); - await expect(findWorkspaceRoot(workspaceRoot)).resolves.toBe( - expectedExistingPath(workspaceRoot) - ); - await expect(findWorkspaceRoot(nestedDir)).resolves.toBe( - expectedExistingPath(workspaceRoot) - ); + expectSameExistingPath(await findWorkspaceRoot(workspaceRoot), workspaceRoot); + expectSameExistingPath(await findWorkspaceRoot(nestedDir), workspaceRoot); await expect(workspaceChangesDirExists(workspaceRoot)).resolves.toBe(true); }); @@ -248,102 +227,116 @@ paths: {} const linkedPath = path.join(workspaceRoot, 'external-folder'); fs.mkdirSync(linkedPath, { recursive: true }); - await expect(findWorkspaceRoot(linkedPath)).resolves.toBe( - expectedExistingPath(workspaceRoot) + expectSameExistingPath(await findWorkspaceRoot(linkedPath), workspaceRoot); + }); + + it('keeps detected workspace roots comparable through symlink or junction aliases', async () => { + const workspaceRoot = createWorkspaceRoot('real-platform'); + const aliasRoot = path.join(tempDir, 'alias-platform'); + fs.symlinkSync(workspaceRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir'); + + expectSameExistingPath(await findWorkspaceRoot(aliasRoot), workspaceRoot); + expectSameExistingPath( + await findWorkspaceRoot(path.join(aliasRoot, 'changes', 'add-billing')), + workspaceRoot ); }); - it('canonicalizes detected workspace roots on Windows before returning them', async () => { + it('canonicalizes detected workspace roots before returning them', async () => { const workspaceRoot = createWorkspaceRoot(); - const canonicalWorkspaceRoot = path.join(tempDir, 'canonical-platform'); - const originalPlatform = process.platform; - const canonicalize = vi - .spyOn(FileSystemUtils, 'canonicalizeExistingPath') - .mockImplementation((targetPath) => - targetPath === workspaceRoot ? canonicalWorkspaceRoot : targetPath - ); - - Object.defineProperty(process, 'platform', { value: 'win32' }); + const canonicalize = vi.spyOn(FileSystemUtils, 'canonicalizeExistingPath'); try { - await expect(findWorkspaceRoot(workspaceRoot)).resolves.toBe(canonicalWorkspaceRoot); + await expect(findWorkspaceRoot(workspaceRoot)).resolves.toBe(expectedExistingPath(workspaceRoot)); expect(canonicalize).toHaveBeenCalledWith(workspaceRoot); } finally { canonicalize.mockRestore(); - Object.defineProperty(process, 'platform', { value: originalPlatform }); } }); }); describe('state parsing', () => { - it('parses shared workspace state with stable link names', () => { - const state = parseWorkspaceSharedState(`version: 1 + it('parses canonical workspace state with stable link names and paths', () => { + const state = parseWorkspaceViewState(`version: 1 name: platform +context: null links: - api: {} - web: - note: planning only + api: /repos/api + web: null `); expect(state).toEqual({ version: 1, name: 'platform', + context: null, links: { - api: {}, - web: { note: 'planning only' }, + api: '/repos/api', + web: null, }, }); }); - it('rejects invalid shared-state versions, names, and link maps', () => { - expect(() => parseWorkspaceSharedState('version: 2\nname: platform\nlinks: {}\n')).toThrow( - /Invalid workspace shared state/ - ); - expect(() => parseWorkspaceSharedState('version: 1\nname: bad/name\nlinks: {}\n')).toThrow( - /Workspace name/ - ); - expect(() => - parseWorkspaceSharedState('version: 1\nname: platform\nlinks:\n bad/name: {}\n') - ).toThrow(/workspace link name/); - expect(() => - parseWorkspaceSharedState('version: 1\nname: platform\nlinks:\n api: nope\n') - ).toThrow(/Invalid workspace shared state/); - }); - - it('parses local state while preserving native Windows and WSL2-style paths', () => { - const state = parseWorkspaceLocalState(String.raw`version: 1 -paths: - windows: D:\repos\api - wsl: /mnt/d/repos/api - linux: /home/tabish/repos/api + it('parses path-bound initiative context in workspace state', () => { + const state = parseWorkspaceViewState(`version: 1 +name: scratch-launch +context: + kind: initiative + store: + id: scratch-context + selector: + kind: path + path: /Users/me/context/scratch + observed_id: scratch-context + initiative: + id: scratch-launch +links: {} `); - expect(state.paths.windows).toBe('D:\\repos\\api'); - expect(state.paths.wsl).toBe('/mnt/d/repos/api'); - expect(state.paths.linux).toBe('/home/tabish/repos/api'); + expect(state.context).toEqual({ + kind: 'initiative', + store: { + id: 'scratch-context', + selector: { + kind: 'path', + path: '/Users/me/context/scratch', + observed_id: 'scratch-context', + }, + }, + initiative: { + id: 'scratch-launch', + }, + }); + expect(parseWorkspaceViewState(serializeWorkspaceViewState(state))).toEqual(state); }); - it('parses and serializes structured preferred openers while accepting older local state', () => { - expect(parseWorkspaceLocalState('version: 1\npaths: {}\n')).toEqual({ - version: 1, - paths: {}, - }); + it('rejects the unshipped flat initiative context shape', () => { + expect(() => + parseWorkspaceViewState(`version: 1 +name: billing-launch +context: + store: platform + initiative: billing-launch +links: {} +`) + ).toThrow(/Invalid workspace state/); + }); - const codexState = parseWorkspaceLocalState(`version: 1 -paths: + it('parses and serializes structured preferred openers in canonical state', () => { + const state = parseWorkspaceViewState(`version: 1 +name: platform +context: null +links: api: /repo/api preferred_opener: kind: agent id: codex `); - expect(codexState.preferred_opener).toEqual({ + expect(state.preferred_opener).toEqual({ kind: 'agent', id: 'codex', }); - expect(parseWorkspaceLocalState(serializeWorkspaceLocalState(codexState))).toEqual( - codexState - ); + expect(parseWorkspaceViewState(serializeWorkspaceViewState(state))).toEqual(state); expect(parseWorkspacePreferredOpenerValue('editor')).toEqual({ kind: 'editor', id: 'vscode', @@ -354,41 +347,39 @@ preferred_opener: }); }); - it('serializes and writes local state without normalizing runtime-local paths', async () => { + it('writes canonical view state without normalizing paths', async () => { const workspaceRoot = path.join(tempDir, 'roundtrip'); - const localState = { + const viewState = { version: 1 as const, - paths: { + name: 'roundtrip', + context: null, + links: { windows: 'D:\\repos\\api', wsl: '/mnt/d/repos/api', }, }; - expect(parseWorkspaceLocalState(serializeWorkspaceLocalState(localState))).toEqual( - localState - ); - - await writeWorkspaceLocalState(workspaceRoot, localState); + await writeWorkspaceViewState(workspaceRoot, viewState); - await expect(readWorkspaceLocalState(workspaceRoot)).resolves.toEqual(localState); + await expect(readWorkspaceViewState(workspaceRoot)).resolves.toEqual(viewState); }); - it('rejects invalid local-state versions, link names, and path maps', () => { - expect(() => parseWorkspaceLocalState('version: 2\npaths: {}\n')).toThrow( - /Invalid workspace local state/ - ); - expect(() => parseWorkspaceLocalState('version: 1\npaths:\n ../api: /repo\n')).toThrow( - /workspace local path name/ - ); - expect(() => parseWorkspaceLocalState('version: 1\npaths:\n api: 42\n')).toThrow( - /Invalid workspace local state/ - ); - expect(() => parseWorkspaceLocalState('version: 1\npaths: []\n')).toThrow( - /Invalid workspace local state/ - ); + it('rejects invalid canonical state versions, link names, paths, and openers', () => { + expect(() => + parseWorkspaceViewState('version: 2\nname: platform\ncontext: null\nlinks: {}\n') + ).toThrow(/Invalid workspace state/); + expect(() => + parseWorkspaceViewState('version: 1\nname: bad/name\ncontext: null\nlinks: {}\n') + ).toThrow(/Workspace name/); + expect(() => + parseWorkspaceViewState('version: 1\nname: platform\ncontext: null\nlinks:\n bad/name: /repo\n') + ).toThrow(/workspace link name/); + expect(() => + parseWorkspaceViewState('version: 1\nname: platform\ncontext: null\nlinks:\n api: 42\n') + ).toThrow(/Invalid workspace state/); expect(() => - parseWorkspaceLocalState( - 'version: 1\npaths: {}\npreferred_opener:\n kind: agent\n id: editor\n' + parseWorkspaceViewState( + 'version: 1\nname: platform\ncontext: null\nlinks: {}\npreferred_opener:\n kind: agent\n id: editor\n' ) ).toThrow(/Unsupported workspace opener/); expect(() => parseWorkspacePreferredOpenerValue('cursor')).toThrow( @@ -396,33 +387,12 @@ preferred_opener: ); }); - it('reads shared and local state from a workspace folder', async () => { + it('rejects invalid canonical state instead of treating it as missing', async () => { const workspaceRoot = createWorkspaceRoot(); + fs.writeFileSync(getWorkspaceViewStatePath(workspaceRoot), 'version: 1\npaths: []\n'); - await expect(readWorkspaceSharedState(workspaceRoot)).resolves.toEqual({ - version: 1, - name: 'platform', - links: {}, - }); - await expect(readWorkspaceLocalState(workspaceRoot)).resolves.toEqual({ - version: 1, - paths: {}, - }); - }); - - it('returns null only when optional local state is absent', async () => { - const workspaceRoot = createWorkspaceRoot(); - fs.rmSync(getWorkspaceLocalStatePath(workspaceRoot)); - - await expect(readOptionalWorkspaceLocalState(workspaceRoot)).resolves.toBeNull(); - }); - - it('rejects invalid optional local state instead of treating it as missing', async () => { - const workspaceRoot = createWorkspaceRoot(); - fs.writeFileSync(getWorkspaceLocalStatePath(workspaceRoot), 'version: 1\npaths: []\n'); - - await expect(readOptionalWorkspaceLocalState(workspaceRoot)).rejects.toThrow( - /Invalid workspace local state/ + await expect(readWorkspaceViewState(workspaceRoot)).rejects.toThrow( + /Invalid workspace state/ ); }); }); @@ -504,24 +474,21 @@ After block. fs.mkdirSync(api, { recursive: true }); fs.writeFileSync(path.join(workspaceRoot, 'AGENTS.md'), '# Existing\n'); fs.writeFileSync(path.join(workspaceRoot, '.gitignore'), '*.code-workspace\n'); - const sharedState = { + const workspaceState = { version: 1 as const, name: 'platform', + context: null, links: { - api: {}, - missing: {}, - noPath: {}, - }, - }; - const localState = { - version: 1 as const, - paths: { api, missing, + noPath: null, }, }; - const result = await syncWorkspaceOpenSurface(workspaceRoot, sharedState, localState); + const result = await syncWorkspaceOpenSurface( + workspaceRoot, + workspaceState + ); expect(result.links).toEqual([{ name: 'api', path: api }]); expect(result.skipped).toEqual([ @@ -529,7 +496,7 @@ After block. { name: 'noPath', path: null, reason: 'missing-local-path' }, ]); expect(fs.readFileSync(path.join(workspaceRoot, 'AGENTS.md'), 'utf-8')).toContain( - 'Make implementation edits after the user explicitly asks' + 'Use initiatives for durable cross-team or cross-repo intent' ); expect(JSON.parse(fs.readFileSync(getWorkspaceCodeWorkspacePath(workspaceRoot, 'platform'), 'utf-8')).folders).toEqual([ { @@ -541,7 +508,7 @@ After block. }, ]); expect(fs.readFileSync(path.join(workspaceRoot, '.gitignore'), 'utf-8')).toContain( - '*.code-workspace\n.openspec-workspace/local.yaml\nplatform.code-workspace\n' + '*.code-workspace\nplatform.code-workspace\n' ); }); }); diff --git a/test/core/workspace/legacy-state.test.ts b/test/core/workspace/legacy-state.test.ts new file mode 100644 index 0000000000..82a9605927 --- /dev/null +++ b/test/core/workspace/legacy-state.test.ts @@ -0,0 +1,218 @@ +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 { + getWorkspaceMetadataDir, + getWorkspaceViewStatePath, + parseWorkspacePreferredOpenerValue, + parseWorkspaceViewState, + readWorkspaceViewState, + serializeWorkspaceViewState, + writeWorkspaceViewState, +} from '../../../src/core/workspace/index.js'; +import { + WORKSPACE_LEGACY_LOCAL_STATE_FILE_NAME, + WORKSPACE_LEGACY_LOCAL_STATE_IGNORE_PATTERN, + WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME, + getWorkspaceLegacyLocalStatePath, + getWorkspaceLegacySharedStatePath, + parseWorkspaceLocalState, + parseWorkspaceSharedState, + serializeWorkspaceLocalState, + workspaceStatePartsToViewState, + workspaceViewToLocalState, + workspaceViewToSharedState, +} from '../../../src/core/workspace/legacy-state.js'; + +describe('workspace legacy state compatibility', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-legacy-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function createWorkspaceRoot(name = 'platform'): string { + const workspaceRoot = path.join(tempDir, name); + fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.writeFileSync( + getWorkspaceViewStatePath(workspaceRoot), + `version: 1 +name: ${name} +context: null +links: {} +` + ); + + return workspaceRoot; + } + + it('keeps legacy file helpers isolated from canonical workspace helpers', () => { + const workspaceRoot = path.join(tempDir, 'platform'); + + expect(WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME).toBe('workspace.yaml'); + expect(WORKSPACE_LEGACY_LOCAL_STATE_FILE_NAME).toBe('local.yaml'); + expect(WORKSPACE_LEGACY_LOCAL_STATE_IGNORE_PATTERN).toBe('.openspec-workspace/local.yaml'); + expect(getWorkspaceLegacySharedStatePath(workspaceRoot)).toBe( + path.join(workspaceRoot, '.openspec-workspace', 'workspace.yaml') + ); + expect(getWorkspaceLegacyLocalStatePath(workspaceRoot)).toBe( + path.join(workspaceRoot, '.openspec-workspace', 'local.yaml') + ); + expect(getWorkspaceLegacyLocalStatePath('D:\\repos\\platform-workspace')).toBe( + 'D:\\repos\\platform-workspace\\.openspec-workspace\\local.yaml' + ); + }); + + it('parses and validates legacy shared state', () => { + const state = parseWorkspaceSharedState(`version: 1 +name: platform +links: + api: {} + web: + note: planning only +`); + + expect(state).toEqual({ + version: 1, + name: 'platform', + context: null, + links: { + api: {}, + web: { note: 'planning only' }, + }, + }); + expect(() => parseWorkspaceSharedState('version: 2\nname: platform\nlinks: {}\n')).toThrow( + /Invalid workspace shared state/ + ); + expect(() => parseWorkspaceSharedState('version: 1\nname: bad/name\nlinks: {}\n')).toThrow( + /Workspace name/ + ); + expect(() => + parseWorkspaceSharedState('version: 1\nname: platform\nlinks:\n bad/name: {}\n') + ).toThrow(/workspace link name/); + expect(() => + parseWorkspaceSharedState('version: 1\nname: platform\nlinks:\n api: nope\n') + ).toThrow(/Invalid workspace shared state/); + }); + + it('parses, serializes, and validates legacy local state', () => { + const state = parseWorkspaceLocalState(String.raw`version: 1 +paths: + windows: D:\repos\api + wsl: /mnt/d/repos/api + linux: /home/tabish/repos/api +`); + + expect(state.paths.windows).toBe('D:\\repos\\api'); + expect(state.paths.wsl).toBe('/mnt/d/repos/api'); + expect(state.paths.linux).toBe('/home/tabish/repos/api'); + + const codexState = parseWorkspaceLocalState(`version: 1 +paths: + api: /repo/api +preferred_opener: + kind: agent + id: codex +`); + expect(codexState.preferred_opener).toEqual({ + kind: 'agent', + id: 'codex', + }); + expect(parseWorkspaceLocalState(serializeWorkspaceLocalState(codexState))).toEqual( + codexState + ); + expect(parseWorkspacePreferredOpenerValue('editor')).toEqual({ + kind: 'editor', + id: 'vscode', + }); + + expect(() => parseWorkspaceLocalState('version: 2\npaths: {}\n')).toThrow( + /Invalid workspace local state/ + ); + expect(() => parseWorkspaceLocalState('version: 1\npaths:\n ../api: /repo\n')).toThrow( + /workspace local path name/ + ); + expect(() => parseWorkspaceLocalState('version: 1\npaths:\n api: 42\n')).toThrow( + /Invalid workspace local state/ + ); + expect(() => + parseWorkspaceLocalState( + 'version: 1\npaths: {}\npreferred_opener:\n kind: agent\n id: editor\n' + ) + ).toThrow(/Unsupported workspace opener/); + }); + + it('converts legacy state parts to and from canonical view state', async () => { + const workspaceRoot = path.join(tempDir, 'roundtrip'); + const viewState = workspaceStatePartsToViewState( + { + version: 1, + name: 'roundtrip', + context: null, + links: { + api: {}, + web: {}, + }, + }, + { + version: 1, + paths: { + api: '/repos/api', + }, + } + ); + + expect(viewState.links).toEqual({ + api: '/repos/api', + web: null, + }); + expect(parseWorkspaceViewState(serializeWorkspaceViewState(viewState))).toEqual(viewState); + expect(workspaceViewToSharedState(viewState).links).toEqual({ + api: {}, + web: {}, + }); + expect(workspaceViewToLocalState(viewState).paths).toEqual({ + api: '/repos/api', + }); + + await writeWorkspaceViewState(workspaceRoot, viewState); + await expect(readWorkspaceViewState(workspaceRoot)).resolves.toEqual(viewState); + }); + + it('reads legacy split state through the canonical view-state reader', async () => { + const workspaceRoot = createWorkspaceRoot(); + fs.rmSync(getWorkspaceViewStatePath(workspaceRoot)); + fs.mkdirSync(getWorkspaceMetadataDir(workspaceRoot), { recursive: true }); + fs.writeFileSync( + getWorkspaceLegacySharedStatePath(workspaceRoot), + `version: 1 +name: platform +context: null +links: + api: {} +` + ); + fs.writeFileSync( + getWorkspaceLegacyLocalStatePath(workspaceRoot), + `version: 1 +paths: + api: /repos/api +` + ); + + await expect(readWorkspaceViewState(workspaceRoot)).resolves.toEqual({ + version: 1, + name: 'platform', + context: null, + links: { + api: '/repos/api', + }, + }); + }); +}); diff --git a/test/utils/change-metadata.test.ts b/test/utils/change-metadata.test.ts index a8c1238369..002fa01feb 100644 --- a/test/utils/change-metadata.test.ts +++ b/test/utils/change-metadata.test.ts @@ -10,7 +10,7 @@ import { validateSchemaName, ChangeMetadataError, } from '../../src/utils/change-metadata.js'; -import { ChangeMetadataSchema } from '../../src/core/artifact-graph/types.js'; +import { ChangeMetadataSchema } from '../../src/core/change-metadata/index.js'; describe('ChangeMetadataSchema', () => { describe('valid metadata', () => { @@ -36,6 +36,24 @@ describe('ChangeMetadataSchema', () => { expect(result.data.created).toBeUndefined(); } }); + + it('should accept a portable initiative link', () => { + const result = ChangeMetadataSchema.safeParse({ + schema: 'spec-driven', + initiative: { + store: 'platform', + id: 'billing-launch', + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.initiative).toEqual({ + store: 'platform', + id: 'billing-launch', + }); + } + }); }); describe('invalid metadata', () => { @@ -68,6 +86,36 @@ describe('ChangeMetadataSchema', () => { }); expect(result.success).toBe(false); }); + + it('should reject initiative links with local paths or copied content', () => { + const result = ChangeMetadataSchema.safeParse({ + schema: 'spec-driven', + initiative: { + store: 'platform', + id: 'billing-launch', + path: '/tmp/context-store/initiatives/billing-launch', + summary: 'Copied initiative prose', + }, + }); + + expect(result.success).toBe(false); + }); + + it('should reject unsafe initiative link identifiers', () => { + for (const initiative of [ + { store: '/tmp/platform', id: 'billing-launch' }, + { store: 'platform', id: 'billing/launch' }, + { store: 'Platform', id: 'billing-launch' }, + { store: 'platform', id: 'billing launch' }, + ]) { + const result = ChangeMetadataSchema.safeParse({ + schema: 'spec-driven', + initiative, + }); + + expect(result.success).toBe(false); + } + }); }); }); @@ -142,6 +190,27 @@ describe('readChangeMetadata', () => { }); }); + it('should read portable initiative metadata', async () => { + const metaPath = path.join(changeDir, '.openspec.yaml'); + await fs.writeFile( + metaPath, + [ + 'schema: spec-driven', + 'initiative:', + ' store: platform', + ' id: billing-launch', + '', + ].join('\n'), + 'utf-8' + ); + + const result = readChangeMetadata(changeDir); + expect(result?.initiative).toEqual({ + store: 'platform', + id: 'billing-launch', + }); + }); + it('should throw ChangeMetadataError for invalid YAML', async () => { const metaPath = path.join(changeDir, '.openspec.yaml'); await fs.writeFile(metaPath, '{ invalid yaml', 'utf-8'); @@ -200,14 +269,12 @@ describe('resolveSchemaForChange', () => { expect(result).toBe('spec-driven'); }); - it('should return default when metadata read fails', async () => { + it('should fail when metadata exists but cannot be read', async () => { // Create an invalid metadata file const metaPath = path.join(changeDir, '.openspec.yaml'); await fs.writeFile(metaPath, '{ invalid yaml', 'utf-8'); - // Should fall back to default, not throw - const result = resolveSchemaForChange(changeDir); - expect(result).toBe('spec-driven'); + expect(() => resolveSchemaForChange(changeDir)).toThrow(ChangeMetadataError); }); it('should use project config schema when no metadata exists', async () => {