diff --git a/openspec/changes/project-scoped-store-discovery/.openspec.yaml b/openspec/changes/project-scoped-store-discovery/.openspec.yaml new file mode 100644 index 0000000000..1b9acb7fd0 --- /dev/null +++ b/openspec/changes/project-scoped-store-discovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 diff --git a/openspec/changes/project-scoped-store-discovery/design.md b/openspec/changes/project-scoped-store-discovery/design.md new file mode 100644 index 0000000000..5d2d48ac09 --- /dev/null +++ b/openspec/changes/project-scoped-store-discovery/design.md @@ -0,0 +1,114 @@ +## Context + +Store resolution today (`src/core/root-selection.ts`, `resolveOpenSpecRoot`) follows a strict precedence chain: `--store ` → nearest `openspec/` root (with optional `store:` pointer in config.yaml) → global `defaultStore` → registered-stores hint → implicit root. All store ID lookups go through one global registry file at `~/.local/share/openspec/stores/registry.yaml` (`src/core/store/foundation.ts`, `getStoreRegistryPath`). Every registry function accepts an optional `globalDataDir` (`StorePathOptions`) but always resolves to that single global location. + +The existing `findQualifyingRootSync` in `root-selection.ts` walks up the directory tree to find the nearest `openspec/` root, using `findRepoPlanningRootSync` from `planning-home.ts` as the underlying walk. This same pattern can be reused to discover a project-scoped registry file. + +## Goals / Non-Goals + +**Goals:** +- Let a project declare store bindings in a committed file, discovered automatically by walking up from cwd. +- Resolve store paths relative to the registry file's directory, using `path.join` / `path.resolve` for cross-platform safety. +- Preserve full backward compatibility — no behavior change when the project-scoped registry file does not exist. + +**Non-Goals:** +- Automatic creation of the project-scoped registry file. Users create it manually or via an optional `--scope project` flag on `store register`. +- Merging or layering multiple project-scoped registries. Only the nearest one (closest to cwd) is used. +- Changing the global registry format or location. +- Adding clone/pull/push/sync for stores. The project-scoped registry only maps IDs to local paths. + +## Decisions + +### D1: Registry file location and name + +**Decision:** `.openspec-store/registry.yaml` in the project root. + +**Rationale:** The `.openspec-store/` directory is already established by store identity metadata (`store.yaml`). Placing the project-scoped registry there keeps store infrastructure in one place. The name `registry.yaml` mirrors the global registry file name. + +**Alternative considered:** `.openspec/registry.yaml` — rejected because `.openspec/` is the planning directory (specs, changes, config), not store infrastructure. + +### D2: Registry file format + +**Decision:** A YAML file with a `version` field and a `stores` map. Each store entry maps a store ID to a relative path: + +```yaml +version: 1 +stores: + platform-specs: + path: platform-specs + design-system-specs: + path: design-system-specs +``` + +**Rationale:** This is the format described in issue #1950 and in the spec. It is simpler than the global registry's `backend: { type: git, local_path: ... }` shape because project-scoped entries only need a path — backend type, remote, and branch are irrelevant when the store is already on disk. The `version` field allows future schema evolution. + +**Alternative considered:** Reusing the global `StoreRegistryState` schema with `backend.type` / `backend.local_path` — rejected because it carries fields (`remote`, `branch`) that have no meaning in a project-scoped context, and the path field name (`local_path`) is confusing when the value is relative. A dedicated parser for the simpler format is straightforward. + +### D3: Path resolution + +**Decision:** `path.resolve(registryDir, entry.path)` where `registryDir` is the directory containing `registry.yaml` and `entry.path` is the relative path from the store entry. Always use `path.resolve` / `path.join` — never string concatenation. + +**Rationale:** Cross-platform requirement (config rule). `path.resolve` handles platform separators and normalizes `..` segments correctly. + +### D4: Discovery walk + +**Decision:** Walk up from `process.cwd()`, checking for `.openspec-store/registry.yaml` at each level. Stop at the first match (nearest wins). Reuse the existing `findRepoPlanningRootSync` pattern from `planning-home.ts`. + +**Rationale:** Same pattern npm/yarn/pnpm use for config file discovery, and that OpenSpec already uses for `openspec/` root discovery. Nearest-wins avoids ambiguity. The walk reuses the pattern from `findRepoPlanningRootSync` (checking for a specific file at each level) but is a separate function because the target file (`.openspec-store/registry.yaml`) differs from the planning-root marker. + +**Alternative considered:** Walking up to the filesystem root and collecting all registries — rejected as unnecessary complexity. The nearest registry is sufficient for all identified use cases. + +### D5: Precedence in resolveOpenSpecRoot + +**Decision:** Insert the project-scoped registry lookup between the nearest-root walk (step 2) and the global `defaultStore` fallback (step 3). When a store ID is looked up, the project-scoped and global registries are merged (project-scoped wins on conflict — see D8 for the full merge semantics and alternatives): + +1. `--store ` → merged registry (project-scoped + global, project-scoped wins on conflict — see D8) +2. Nearest `openspec/` root (with `store:` pointer → merged registry) +3. Project-scoped registry discovery (any store, not just a named one) +4. Global `defaultStore` +5. Registered-stores hint / implicit root + +**Rationale:** Project-scoped bindings are more specific than machine-global defaults but less specific than an explicit `--store` flag or a root found by walking up. This preserves backward compatibility: without a project-scoped registry, the chain is unchanged. + +**Alternative considered:** Project-scoped registry before nearest root — rejected because a local `openspec/` root with a planning shape is the most specific signal and should win. + +### D6: Threading scope through existing functions + +**Decision:** Extend `StorePathOptions` with an optional `projectRoot` field. When set, `getStoreRegistryPath` resolves to `path.join(projectRoot, STORE_METADATA_DIR_NAME, STORE_REGISTRY_FILE_NAME)` using existing constants, instead of the global path. Registry read functions (`readStoreRegistryState`, `listRegisteredStores`) accept the extended options. Registration and conflict-detection functions (`commitStoreRegistration`, `assertNoRegisteredStoreConflict`) also accept `projectRoot` but write the simpler `{ path: ... }` entry format (D2) rather than the global `backend` shape. + +**Rationale:** Minimal API surface change. The `StorePathOptions` threading pattern is already established. Functions that call `readStoreRegistryState()` with no options (in `operations.ts`) will need explicit propagation, but the signature stays the same. + +### D7: New OpenSpecRootSource and diagnostic codes + +**Decision:** Add `'project_store'` to the `OpenSpecRootSource` union. Add diagnostic codes: `project_registry_malformed`, `project_registry_not_found` (informational, not an error). + +**Rationale:** The `source` field is how JSON output tells consumers where a root came from. New diagnostic codes follow the existing taxonomy pattern in `RootSelectionDiagnostic`. + +### D8: Resolution when project-scoped registry exists but store ID is absent + +**Decision:** Merge project-scoped and global registries. When a store ID is present in both, the project-scoped entry wins. When a store ID is present only in the global registry, it resolves from the global registry with a warning in human mode and the existing `source: 'store'` marker in JSON mode. When a store ID is present only in the project-scoped registry, it resolves from the project-scoped registry with the new `source: 'project_store'` marker. + +**Three approaches considered:** + +**Variant A — Merge (npm-style).** Project-scoped and global registries are merged. Project-scoped overrides on ID conflict. Store IDs absent from the project-scoped registry but present in the global registry resolve from the global registry silently. +- *Pros:* Backward-compatible — global stores always accessible. Familiar pattern (npm/yarn/pnpm merge config files). +- *Cons:* Isolation is leaky — a store ID resolves from the machine's global registry, which may point to a different store on another machine. Developer may not realize where the store came from. + +**Variant B — Nearest wins (isolation).** When a project-scoped registry is found, it fully replaces the global registry for resolution. A store ID absent from the project-scoped registry is an error, even if it exists globally. +- *Pros:* Full isolation. Explicit — developer knows all stores are declared in the project. +- *Cons:* Breaks backward compatibility — `--store ` stops working when a project-scoped registry exists. Not consistent with the npm-style merge pattern referenced in the proposal. + +**Variant C — Merge with source indication (chosen).** Same merge as Variant A, but the resolution source is reported: `source: 'project_store'` or `source: 'store'` (global) in JSON output, and a warning in human mode when a store ID falls back to the global registry while a project-scoped registry is present. +- *Pros:* Backward-compatible. Predictable — developer sees where the store came from. Isolation is preserved for IDs declared in the project-scoped registry (project-scoped wins on conflict). Closest to the npm-style merge pattern. +- *Cons:* Slightly more complex implementation — resolution must track and report the source of each store lookup. + +**Why C over A:** Silent fallback (A) creates a surprise — a store resolves from an unknown location. The source indication in C makes the behavior visible without blocking it. + +**Why C over B:** B breaks backward compatibility — a developer who uses `--store ` alongside a project-scoped registry would get an error. C preserves their workflow while making the resolution path visible. + +## Risks / Trade-offs + +- **[Stale project-scoped registry pointing at a moved directory]** → The `inspectRegisteredStore` health check already validates that a resolved store root exists and has a healthy `openspec/` shape. A stale entry produces the same `unhealthy_store_root` diagnostic as a stale global registration. +- **[Registry file committed with machine-specific paths]** → The spec recommends relative paths. If a user commits absolute paths, `path.resolve` still works — absolute paths are returned as-is. No validation rejects them, but documentation should recommend relative paths. +- **[Two project-scoped registries in the same ancestor chain]** → Nearest wins, by design. This is documented in the spec and matches the behavior of package manager config discovery. +- **[Performance of the discovery walk]** → The walk is bounded by filesystem depth and stops at the first match. The same cost as the existing `openspec/` root walk. No measurable impact. diff --git a/openspec/changes/project-scoped-store-discovery/proposal.md b/openspec/changes/project-scoped-store-discovery/proposal.md new file mode 100644 index 0000000000..d6922d6ad5 --- /dev/null +++ b/openspec/changes/project-scoped-store-discovery/proposal.md @@ -0,0 +1,27 @@ +## Why + +OpenSpec stores (beta) use a machine-level registry that maps store IDs to absolute filesystem paths. After cloning a repository that references a store, every developer must manually run `openspec store register ` before the store is discoverable. Registering a second copy of the same store on the same machine under the same ID fails — only one checkout per store ID is supported. This makes stores impractical for teams that use meta-repositories, side-by-side clones, or any workflow where store bindings should travel with the repository rather than live on each machine. + +## What Changes + +- Add an optional **project-scoped store registry** file (`.openspec-store/registry.yaml`) that maps store IDs to paths relative to that file. +- Discover this file automatically by searching from the current working directory and walking up the directory tree. This is a well-established discovery pattern used by package managers (npm, yarn, pnpm discover config files like `package.json`, `.npmrc`, `.yarnrc` by walking up the directory tree). OpenSpec uses a similar mechanism to find the nearest `openspec/` root. +- When a store ID is resolved (`--store`, `references:`, `store:` in config.yaml), a project-scoped registry will take precedence over the global registry. The exact resolution mechanism is a design decision — see design.md. Existing setups without a project-scoped registry will not be affected. +- The project-scoped registry file can be created manually or generated with `openspec store register --scope project`. No new commands — a new `--scope` parameter will be added to the existing `store register` command. + +## Capabilities + +### New Capabilities + +- `store-discovery`: Project-scoped store registry discovery and resolution — how OpenSpec finds a store by ID when a project-level registry file exists, how relative paths are resolved, and how project-scoped and global registries interact. + +## Impact + +- `src/core/store/foundation.ts` — path resolution for the registry file; support for a project-scoped registry location alongside the global one. +- `src/core/store/registry.ts` — store lookup, conflict detection, and listing operations must accept and propagate a project scope option. +- `src/core/store/operations.ts` — bare `readStoreRegistryState()` calls propagate scope options from callers. +- `src/core/root-selection.ts` — the resolution chain in `resolveOpenSpecRoot` gains a project-scoped registry step between the nearest-root walk and the global `defaultStore` fallback. +- `src/commands/store.ts` — new `--scope project` flag for `store register`, `store list`, and `store unregister`. +- `src/core/references.ts` — referenced-store index assembly resolves store IDs through the merged registry (project-scoped + global) when a project-scoped registry is available. +- Tests across `test/core/root-selection.test.ts`, `test/core/store/`, and `test/cli-e2e/` — new test coverage for project-scoped discovery, relative path resolution, and precedence over the global registry. +- `docs/stores-beta/user-guide.md` — documentation of the project-scoped registry feature. diff --git a/openspec/changes/project-scoped-store-discovery/specs/store-discovery/spec.md b/openspec/changes/project-scoped-store-discovery/specs/store-discovery/spec.md new file mode 100644 index 0000000000..526d341711 --- /dev/null +++ b/openspec/changes/project-scoped-store-discovery/specs/store-discovery/spec.md @@ -0,0 +1,119 @@ +## Purpose + +Lets teams declare store bindings inside their repository so that OpenSpec discovers stores automatically after clone, without requiring each developer to run `openspec store register` on their machine. + +## ADDED Requirements + +### Requirement: Project-scoped store registry discovery + +The system SHALL discover a project-scoped store registry by walking up from the current working directory, looking for a `.openspec-store/registry.yaml` file. When found, the system SHALL merge it with the global registry, with project-scoped entries taking precedence on store ID conflicts. + +#### Scenario: Store resolved from project-scoped registry +- **WHEN** a project contains `.openspec-store/registry.yaml` mapping a store ID to a relative path +- **AND** the user runs a command with `--store ` from within that project +- **THEN** the system resolves the store to the path relative to the registry file's directory +- **AND** no global registry registration is required + +#### Scenario: Project-scoped registry not found +- **WHEN** no `.openspec-store/registry.yaml` exists in the current directory or any ancestor +- **THEN** the system resolves store IDs from the global registry +- **AND** existing behavior is unchanged + +#### Scenario: Store ID not in project-scoped registry, present in global +- **WHEN** a project-scoped registry exists but does not contain the requested store ID +- **AND** the global registry contains the requested store ID +- **THEN** the system resolves the store from the global registry +- **AND** in JSON output, sets `source` to `'store'`; in human output, displays a warning that the store was resolved from the global registry + +#### Scenario: Store ID not in project-scoped registry, not in global +- **WHEN** a project-scoped registry exists but does not contain the requested store ID +- **AND** the global registry also does not contain the requested store ID +- **THEN** the system reports an error listing available stores from both registries + +#### Scenario: Project-scoped registry takes precedence over global registry +- **WHEN** a store ID is registered in both the project-scoped registry and the global registry +- **AND** the project-scoped registry maps the ID to a different path than the global registry +- **THEN** the system uses the path from the project-scoped registry +- **AND** in JSON output, sets `source` to `'project_store'` + +#### Scenario: Project-scoped registry discovered from subdirectory +- **WHEN** `.openspec-store/registry.yaml` exists at the project root +- **AND** the user runs a command from a subdirectory of the project +- **THEN** the system discovers the registry by walking up to the project root + +### Requirement: Relative path resolution in project-scoped registry + +The system SHALL resolve store paths in a project-scoped registry relative to the directory containing the registry file, using platform-appropriate path joining. + +#### Scenario: Relative path resolved from registry directory +- **WHEN** `.openspec-store/registry.yaml` at `/project/` maps store ID `specs` to path `specs` +- **THEN** the system resolves the store root to `/project/specs` + +#### Scenario: Parent directory relative path +- **WHEN** `.openspec-store/registry.yaml` at `/project/app/` maps a store ID to path `../specs` +- **THEN** the system resolves the store root to `/project/specs` + +#### Scenario: Cross-platform path handling +- **WHEN** the registry file is on Windows at `C:\project\` +- **AND** the store path is `specs` +- **THEN** the system resolves the store root using platform-appropriate path separators (`C:\project\specs`) + +### Requirement: Project-scoped registry file format + +The system SHALL accept a YAML file with a `version` field and a `stores` map. Each store entry maps a store ID to a path relative to the registry file's directory. + +#### Scenario: Valid registry file +- **WHEN** `.openspec-store/registry.yaml` contains: + ```yaml + version: 1 + stores: + platform-specs: + path: platform-specs + ``` +- **THEN** the system parses the file and makes store ID `platform-specs` resolvable + +#### Scenario: Malformed registry file +- **WHEN** `.openspec-store/registry.yaml` exists but contains invalid YAML +- **THEN** the system reports an error identifying the file and the parse failure +- **AND** resolves store IDs from the global registry + +#### Scenario: Registry file with unsupported version +- **WHEN** `.openspec-store/registry.yaml` contains `version: 2` +- **THEN** the system reports an error identifying the file and the unsupported version +- **AND** resolves store IDs from the global registry + +### Requirement: Backward compatibility with existing setups + +The system SHALL NOT change behavior when no project-scoped registry file exists. All existing store resolution through the global registry SHALL continue to work without modification. + +#### Scenario: No project-scoped registry, global registry used +- **WHEN** no `.openspec-store/registry.yaml` exists in the current directory or any ancestor +- **AND** a store is registered in the global registry +- **THEN** the system resolves the store from the global registry exactly as before + +#### Scenario: No project-scoped registry, no global registration +- **WHEN** no `.openspec-store/registry.yaml` exists +- **AND** no store is registered in the global registry +- **THEN** the system reports the same error as before this feature was introduced + +### Requirement: Project-scoped registry with multiple stores + +The system SHALL support multiple store entries in a single project-scoped registry file, each mapping to an independent path. + +#### Scenario: Multiple stores in one registry +- **WHEN** `.openspec-store/registry.yaml` contains: + ```yaml + version: 1 + stores: + platform-specs: + path: platform-specs + design-system-specs: + path: design-system-specs + ``` +- **THEN** both store IDs are resolvable from within the project + +#### Scenario: Multiple clones of the same repository +- **WHEN** two clones of the same repository exist on the same machine +- **AND** each clone has its own `.openspec-store/registry.yaml` with the same store IDs +- **THEN** each clone resolves store IDs from its own project-scoped registry +- **AND** no conflict occurs between the clones, even if the global registry contains the same store IDs diff --git a/openspec/changes/project-scoped-store-discovery/tasks.md b/openspec/changes/project-scoped-store-discovery/tasks.md new file mode 100644 index 0000000000..16f16bd17f --- /dev/null +++ b/openspec/changes/project-scoped-store-discovery/tasks.md @@ -0,0 +1,43 @@ +## 1. Foundation: project-scoped registry path resolution + +- [ ] 1.1 Add `projectRoot` field to `StorePathOptions` in `src/core/store/foundation.ts` and update `getStoreRegistryPath` to resolve to `path.join(projectRoot, STORE_METADATA_DIR_NAME, STORE_REGISTRY_FILE_NAME)` when set, reusing existing constants — verify with a unit test that the path is correct on both POSIX and Windows +- [ ] 1.2 Add `findProjectRegistryDir(startPath)` function that walks up from `startPath` looking for `.openspec-store/registry.yaml`, returning the directory containing it or null — verify with a unit test that it finds the nearest match and stops at the first one +- [ ] 1.3 Add `readProjectStoreRegistryState(registryDir)` that reads and parses the project-scoped registry, resolving `path` fields relative to `registryDir` using `path.resolve` — verify with a unit test that relative paths and `..` segments resolve correctly + +## 2. Root selection: project-scoped registry in resolution chain + +- [ ] 2.1 Add `'project_store'` to `OpenSpecRootSource` union and new diagnostic codes (`project_registry_malformed`) in `src/core/root-selection.ts` — verify TypeScript compiles (`pnpm exec tsc --noEmit`) +- [ ] 2.2 Add `resolveMergedStoreRoot(id, projectRoot)` that reads both the project-scoped registry (if discovered) and the global registry, merges them (project-scoped wins on conflict), resolves the store ID to a path, and runs `inspectRegisteredStore` — verify with a unit test that a store in a project-scoped registry resolves correctly and a store only in the global registry also resolves +- [ ] 2.3 Update `resolveStoreRoot` to delegate to `resolveMergedStoreRoot` when a project-scoped registry is discovered, falling back to the current global-only path when no project registry is found — verify with a unit test that project-scoped takes precedence over global on ID conflict, and global IDs resolve when absent from project-scoped +- [ ] 2.4 Insert project-scoped registry discovery step in `resolveOpenSpecRoot` between the nearest-root walk and the global `defaultStore` fallback — verify with a unit test that the full resolution chain works end-to-end + +## 3. Registry operations: scope-aware register, list, and unregister + +- [ ] 3.1 Update `registerStore` in `src/core/store/registry.ts` to accept and propagate `projectRoot` through `StorePathOptions`, writing the simpler `{ path: ... }` entry format when project-scoped — verify with a unit test that registration writes to the project-scoped file in the correct format when `projectRoot` is set +- [ ] 3.2 Update `listRegisteredStores` to accept and propagate `projectRoot` — verify with a unit test that listing reads from the project-scoped file when set +- [ ] 3.3 Update `unregisterStoreRegistration` to accept and propagate `projectRoot` — verify with a unit test that unregister removes the entry from the project-scoped file +- [ ] 3.4 Update `assertNoRegisteredStoreConflict` so that a project-scoped entry with the same ID as a global entry is not a conflict (project-scoped wins by design) — verify with a unit test that registering a store ID that exists globally but with a different path succeeds when `projectRoot` is set +- [ ] 3.5 Update bare `readStoreRegistryState()` calls in `src/core/store/operations.ts` to propagate scope options from callers — verify with a unit test that `store list --scope project` lists project-scoped stores + +## 4. CLI: --scope flag + +- [ ] 4.1 Add `--scope ` flag to `store register`, `store list`, and `store unregister` subcommands in `src/commands/store.ts`, mapping `project` to `projectRoot: process.cwd()` — verify with a unit test that the flag is parsed and passed through +- [ ] 4.2 Update `store register` output to indicate whether registration went to the project-scoped or global registry — verify with a unit test that the JSON output includes the scope +- [ ] 4.3 Update `store doctor` to inspect project-scoped stores when a project-scoped registry is present — verify with a unit test that doctor reports health for project-scoped stores + +## 5. References: project-scoped store resolution + +- [ ] 5.1 Update `assembleReferenceIndex` in `src/core/references.ts` to resolve referenced store IDs through the merged registry (project-scoped + global) when a project-scoped registry is available — verify with a unit test that references resolve from the merged registry + +## 6. Tests: integration and edge cases + +- [ ] 6.1 Add integration test in `test/core/root-selection.test.ts` for the full precedence chain: `--store` with merged registry (project-scoped + global) → defaultStore → hint — verify all precedence scenarios pass +- [ ] 6.2 Add test for malformed project-scoped registry (invalid YAML, missing `version`, missing `stores`) — verify the system reports `project_registry_malformed` and resolves store IDs from the global registry +- [ ] 6.3 Add test for multiple clones of the same repository on the same machine — verify each clone resolves store IDs independently without conflict +- [ ] 6.4 Add e2e test in `test/cli-e2e/` for the full lifecycle: create project-scoped registry, run `openspec status --store `, verify resolution — verify the e2e test passes +- [ ] 6.5 Add Windows path handling tests using `path.join` for all expected paths — verify tests pass on Windows CI + +## 7. Documentation + +- [ ] 7.1 Update `docs/stores-beta/user-guide.md` with a section on project-scoped registry: file format, discovery, precedence, and use cases — verify the file is updated and renders correctly +- [ ] 7.2 Add `pnpm changeset` describing the new feature for users — verify the changeset file is created with a clear description