diff --git a/.changeset/add-global-default-store.md b/.changeset/add-global-default-store.md new file mode 100644 index 0000000000..95475d8341 --- /dev/null +++ b/.changeset/add-global-default-store.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": patch +--- + +### Features + +- **One default store for every repo on your machine** — `openspec config set defaultStore ` sets a machine-level fallback root: any command run outside a planning root, with no `--store` flag and no project `store:` pointer, resolves to that store. It sits at the bottom of the precedence list, so `--store`, a local root, and a project pointer all still win. The root banner and JSON `root` block report the distinct provenance `source: "global_default"`, so users and tooling can tell a machine-wide default from a repo's own pointer. A stale id degrades to the underlying store error with a fix that names `openspec config unset defaultStore`. diff --git a/docs/agent-contract.md b/docs/agent-contract.md index dae386b9f7..0d849caa83 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -31,13 +31,14 @@ All root-resolving commands (`list`, `show`, `validate`, `status`, `instructions 1. `--store ` → the registered store's root (`source: "store"`). 2. Otherwise, nearest ancestor with `openspec/`: planning shape → `source: "nearest"` (a `store:` pointer is ignored with a stderr warning); config-only dir with a valid `store:` pointer → that store, `source: "declared"`. -3. No nearest root + registered stores exist → error `no_root_with_registered_stores`. -4. No root, no stores: scaffolding commands treat the cwd as `source: "implicit"`; diagnostic commands (`doctor`, `context`) fail with `no_openspec_root` instead — they inspect, never scaffold. +3. No nearest root + global `defaultStore` set (`openspec config set defaultStore `) → that store, `source: "global_default"`; a stale id fails with the underlying store error and a `fix` naming `openspec config unset defaultStore`. +4. No nearest root, no default + registered stores exist → error `no_root_with_registered_stores`. +5. No root, no default, no stores: scaffolding commands treat the cwd as `source: "implicit"`; diagnostic commands (`doctor`, `context`) fail with `no_openspec_root` instead — they inspect, never scaffold. Successful JSON payloads embed the root: ```json -"root": { "path": "/abs/path", "source": "store" | "declared" | "nearest" | "implicit", "store_id": "id (only when store-selected)" } +"root": { "path": "/abs/path", "source": "store" | "declared" | "global_default" | "nearest" | "implicit", "store_id": "id (only when store-selected)" } ``` **Root-failure contract**: in JSON mode a resolution failure prints `{ ...commandNullShape, "status": [diagnostic] }` on stdout and exits 1. diff --git a/docs/cli.md b/docs/cli.md index fb591f2bcc..0e1ea4231a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -322,6 +322,8 @@ store: team-context Normal commands then resolve to the declared store automatically; the root banner and JSON `root` block report `source: "declared"` with the store id, and printed hints still carry `--store `. The declaration is a fallback, never an override: explicit `--store` always wins, and a directory with real planning folders ignores the pointer (with a warning). To convert a pointer repo into a local OpenSpec root, remove the `store:` line and run `openspec init` — init refuses to scaffold while the declaration is present. +A machine-level variant covers every repo at once: `openspec config set defaultStore ` (see Configuration). It is consulted only after `--store`, a local root, and a project pointer have all failed to resolve; the root banner and JSON `root` block then report `source: "global_default"`. + ## Doctor (relationship health) One read-only question, one place: is the OpenSpec root healthy, and are the stores it references available on this machine? @@ -1044,6 +1046,10 @@ openspec config set user.name "My Name" --string # Remove a custom setting openspec config unset user.name +# Set a machine-level default store (fallback root when no --store, +# local root, or project store: pointer resolves) +openspec config set defaultStore team-plans + # Reset all configuration openspec config reset --all --yes diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 3711777e15..be7cdab35d 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -148,6 +148,23 @@ The pointer is a fallback, never an override: an explicit `--store` always wins, and if the repo grows real planning folders of its own, those win (with a warning to remove the stale pointer). +**One default for every repo on your machine.** If you work across many +code repos that all plan into the same store, set it once, globally, +instead of adding the `store:` line to each repo: + +```bash +openspec config set defaultStore team-plans +``` + +Now any command run outside a planning root — and with no `--store` and no +project pointer — resolves to `team-plans`. It sits at the bottom of the +precedence list, so `--store`, a local root, and a project `store:` pointer +all still win. The root banner and JSON `root` block report +`source: "global_default"` with the store id, so you can always tell a +machine-wide default from a repo's own pointer. Clear it with +`openspec config unset defaultStore`. If the id is not registered, commands +error and tell you to register it or clear the stale default. + ## Story: requirements that cross team lines A platform team owns the requirements. Product teams build against them, @@ -289,7 +306,9 @@ Every normal command resolves its root the same way, in this order: 2. nearest openspec/ a real planning root here → this repo (walking up from cwd) 3. store: pointer config.yaml declares a store → that store -4. none of the above stores registered on this → error with a +4. defaultStore global config sets a machine → that store + default +5. none of the above stores registered on this → error with a machine? selection hint no stores registered? → the current directory diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index e8445fbda2..d216c0c7bd 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -50,7 +50,8 @@ async function gatherHealth( registryUnreadable, }; - // Store facts for store-backed roots (explicit --store or declared). + // Store facts for store-backed roots (explicit --store, a declared + // pointer, or the global default). // Missing/invalid metadata never reaches here: store resolution // verifies identity first and fails with the existing taxonomy // (recorded amendment - corrupt store.yaml is an exit-1 resolution diff --git a/src/core/config-schema.ts b/src/core/config-schema.ts index b1d694a301..ab48226294 100644 --- a/src/core/config-schema.ts +++ b/src/core/config-schema.ts @@ -21,6 +21,12 @@ export const GlobalConfigSchema = z workflows: z .array(z.string()) .optional(), + defaultStore: z + .string() + .optional() + .describe( + 'Store id used as fallback root when no explicit --store, local root, or project-level store: pointer resolves' + ), }) .passthrough(); @@ -35,7 +41,7 @@ export const DEFAULT_CONFIG: GlobalConfigType = { delivery: 'both', }; -const KNOWN_TOP_LEVEL_KEYS = new Set([...Object.keys(DEFAULT_CONFIG), 'workflows']); +const KNOWN_TOP_LEVEL_KEYS = new Set([...Object.keys(DEFAULT_CONFIG), 'workflows', 'defaultStore']); /** * Validate a config key path for CLI set operations. diff --git a/src/core/global-config.ts b/src/core/global-config.ts index 26cb03fed3..97ebebdc0c 100644 --- a/src/core/global-config.ts +++ b/src/core/global-config.ts @@ -17,6 +17,11 @@ export interface GlobalConfig { profile?: Profile; delivery?: Delivery; workflows?: string[]; + /** + * Machine-level fallback store id, consulted during root resolution only + * when no --store flag, local root, or project-level store: pointer resolves. + */ + defaultStore?: string; /** Workset opener rows (slice 7.1); hand-edited, validated on use. */ openers?: unknown; } diff --git a/src/core/root-selection.ts b/src/core/root-selection.ts index aeb4e0a350..21108f5967 100644 --- a/src/core/root-selection.ts +++ b/src/core/root-selection.ts @@ -7,8 +7,11 @@ * - `--store ` selects a registered store's root. * - Without `--store`, the nearest ancestor containing `openspec/` wins. * Leftover workspace view state is never considered a root here. - * - With no nearest root, registered stores produce a selection hint error; - * otherwise commands may treat the current directory as an implicit root. + * - With no nearest root, a global `defaultStore` (if set) is the last + * machine-level fallback before the selection hint error. + * - With no nearest root and no default, registered stores produce a + * selection hint error; otherwise commands may treat the current + * directory as an implicit root. * * Diagnostic codes reuse the store taxonomy where an error passes * through unchanged (`invalid_store_id`, metadata parse failures); @@ -34,9 +37,15 @@ import { getStoreRootForBackend } from './store/registry.js'; import { inspectOpenSpecRoot } from './openspec-root.js'; import { findRepoPlanningRootSync, type PlanningHome } from './planning-home.js'; import { classifyOpenSpecDir, storePointerProblem } from './project-config.js'; +import { getGlobalConfig } from './global-config.js'; import { FileSystemUtils } from '../utils/file-system.js'; -export type OpenSpecRootSource = 'store' | 'declared' | 'nearest' | 'implicit'; +export type OpenSpecRootSource = + | 'store' + | 'declared' + | 'global_default' + | 'nearest' + | 'implicit'; export interface StoreSelectorOptions { store?: string; @@ -346,6 +355,40 @@ async function resolveNearestOrDeclaredRoot( } } +/** + * The machine-level fallback: the global `defaultStore` resolved as a root, + * with its own provenance (`global_default`) so JSON surfaces can tell a + * machine-wide default from a repo's `store:` pointer. Mirrors the + * declared-pointer catch — a stale or unregistered id degrades to the + * underlying error, reshaped to point at clearing the global default + * rather than passing --store. + */ +async function resolveDefaultStoreRoot( + id: string, + globalDataDir?: string +): Promise { + try { + return await resolveStoreRoot(id, globalDataDir, 'global_default'); + } catch (error) { + if (error instanceof RootSelectionError) { + const staleFix = + error.diagnostic.code === 'unknown_store' || + error.diagnostic.code === 'no_registered_stores' + ? `Register the store (openspec store register --id ${id}) or clear the stale global default (openspec config unset defaultStore).` + : error.diagnostic.fix; + throw new RootSelectionError( + `Global defaultStore '${id}': ${error.message}`, + error.diagnostic.code, + { + ...(error.diagnostic.target ? { target: error.diagnostic.target } : {}), + ...(staleFix ? { fix: staleFix } : {}), + } + ); + } + throw error; + } +} + export async function resolveOpenSpecRoot( options: ResolveOpenSpecRootOptions = {} ): Promise { @@ -370,6 +413,14 @@ export async function resolveOpenSpecRoot( return resolveNearestOrDeclaredRoot(nearestRoot, options.globalDataDir); } + // Machine-level fallback: a global defaultStore is consulted only after + // --store, the nearest local root, and project-level pointers have all + // failed to resolve — it changes the failure path, never the precedence. + const defaultStore = getGlobalConfig().defaultStore; + if (defaultStore) { + return resolveDefaultStoreRoot(defaultStore, options.globalDataDir); + } + let registry; try { registry = await readStoreRegistryState( @@ -423,9 +474,9 @@ export function toRootOutput(root: ResolvedOpenSpecRoot): RootOutput { } /** - * A store-selected root — explicit `--store` or the declared fallback. - * Cross-root behavior (absolute paths, --store hints, suppressed - * noun-form suggestions) keys on this, never on `source` directly. + * A store-selected root — explicit `--store`, a declared pointer, or the + * global default. Cross-root behavior (absolute paths, --store hints, + * suppressed noun-form suggestions) keys on this, never on `source` directly. */ export function isStoreSelectedRoot( root: ResolvedOpenSpecRoot diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index 9d3541b686..1e4f7e73d0 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -116,6 +116,20 @@ describe('config command integration', () => { 'Set workflows = new,ff,apply,archive' ); }); + + it('should set, get, and unset defaultStore', async () => { + await runConfigCommand(['set', 'defaultStore', 'team-plans']); + + const { getGlobalConfig } = await import('../../src/core/global-config.js'); + expect(getGlobalConfig().defaultStore).toBe('team-plans'); + expect(consoleLogSpy).toHaveBeenCalledWith('Set defaultStore = "team-plans"'); + + await runConfigCommand(['get', 'defaultStore']); + expect(consoleLogSpy).toHaveBeenCalledWith('team-plans'); + + await runConfigCommand(['unset', 'defaultStore']); + expect(getGlobalConfig().defaultStore).toBeUndefined(); + }); }); describe('config command shell completion registry', () => { @@ -214,6 +228,16 @@ describe('config key validation', () => { const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); expect(validateConfigKeyPath('workflows').valid).toBe(true); }); + + it('allows defaultStore key', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('defaultStore').valid).toBe(true); + }); + + it('rejects nested keys under defaultStore', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('defaultStore.nested').valid).toBe(false); + }); }); describe('config profile command', () => { diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 14471afadc..ed1551fd13 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -104,7 +104,21 @@ describe('openspec context (4.1)', () => { const declared = await runCLI(['context', '--json'], { cwd: pointerRepo, env }); expect(parseJson(declared).root.source).toBe('declared'); expect(parseJson(declared).members).toHaveLength(2); - }); + + // Global-default session: no root, no pointer — provenance must name + // the machine-level default, not masquerade as a repo pointer. + fs.mkdirSync(path.join(tempDir, 'config', 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'config', 'openspec', 'config.json'), + JSON.stringify({ defaultStore: 'team-context' }) + '\n' + ); + const scratch = path.join(tempDir, 'no-root-here'); + fs.mkdirSync(scratch, { recursive: true }); + const fallback = await runCLI(['context', '--json'], { cwd: scratch, env }); + expect(parseJson(fallback).root.source).toBe('global_default'); + expect(parseJson(fallback).root.store_id).toBe('team-context'); + expect(parseJson(fallback).members).toHaveLength(2); + }, CONTEXT_MATRIX_TIMEOUT_MS); it('distinguishes self-reference omission from nothing declared', async () => { fs.writeFileSync( diff --git a/test/commands/doctor.test.ts b/test/commands/doctor.test.ts index f677da01e9..30718f0125 100644 --- a/test/commands/doctor.test.ts +++ b/test/commands/doctor.test.ts @@ -99,7 +99,20 @@ describe('openspec doctor (3.6)', () => { const declared = await runCLI(['doctor', '--json'], { cwd: pointerRepo, env }); expect(parseJson(declared).root.source).toBe('declared'); expect(parseJson(declared).store.id).toBe('team-context'); - }); + + // Global-default session: no root, no pointer — provenance must name + // the machine-level default, not masquerade as a repo pointer. + fs.mkdirSync(path.join(tempDir, 'config', 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'config', 'openspec', 'config.json'), + JSON.stringify({ defaultStore: 'team-context' }) + '\n' + ); + const fallback = await runCLI(['doctor', '--json'], { cwd: mkdir('no-root-here'), env }); + const fallbackHealth = parseJson(fallback); + expect(fallbackHealth.root.source).toBe('global_default'); + expect(fallbackHealth.root.store_id).toBe('team-context'); + expect(fallbackHealth.store.id).toBe('team-context'); + }, 30_000); it('renders none-declared sections distinguishably', async () => { const result = await runCLI(['doctor', '--store', 'team-context'], { cwd: tempDir, env }); diff --git a/test/commands/global-default-store.test.ts b/test/commands/global-default-store.test.ts new file mode 100644 index 0000000000..50f0c83013 --- /dev/null +++ b/test/commands/global-default-store.test.ts @@ -0,0 +1,79 @@ +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, registerStore } from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js'; + +describe('global defaultStore fallback (#1359)', () => { + let tempDir: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + let storeRoot: string; + let scratch: string; + + beforeEach(async () => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-global-default-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + + storeRoot = path.join(tempDir, 'team-context'); + createOpenSpecRoot(storeRoot); + await registerStore({ id: 'team-context', localPath: storeRoot, globalDataDir }); + + scratch = path.join(tempDir, 'no-root-here'); + fs.mkdirSync(scratch, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + + function parseJson(result: RunCLIResult): any { + return JSON.parse(result.stdout); + } + + function setDefaultStore(id: string): void { + fs.mkdirSync(path.join(tempDir, 'config', 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'config', 'openspec', 'config.json'), + JSON.stringify({ defaultStore: id }) + '\n' + ); + } + + it('reports global_default provenance in status JSON and the root banner', async () => { + setDefaultStore('team-context'); + + const status = await runCLI(['status', '--json'], { cwd: scratch, env }); + expect(status.exitCode).toBe(0); + expect(parseJson(status).root).toEqual({ + path: fs.realpathSync.native(storeRoot), + source: 'global_default', + store_id: 'team-context', + }); + + const human = await runCLI(['status'], { cwd: scratch, env }); + expect(human.exitCode).toBe(0); + expect(human.stderr).toContain('Using OpenSpec root: team-context'); + }, 30_000); + + it('reports a stale default in the JSON failure payload with the clearing fix', async () => { + setDefaultStore('ghost-plans'); + + const status = await runCLI(['status', '--json'], { cwd: scratch, env }); + expect(status.exitCode).toBe(1); + const [diagnostic] = parseJson(status).status; + expect(diagnostic.code).toBe('unknown_store'); + expect(diagnostic.message).toContain("Global defaultStore 'ghost-plans'"); + expect(diagnostic.fix).toContain('openspec config unset defaultStore'); + }, 30_000); +}); diff --git a/test/core/root-selection.test.ts b/test/core/root-selection.test.ts index f20a503810..3a09d2ee55 100644 --- a/test/core/root-selection.test.ts +++ b/test/core/root-selection.test.ts @@ -12,11 +12,13 @@ import { writeStoreMetadataState, writeStoreRegistryState, } from '../../src/core/store/foundation.js'; +import { saveGlobalConfig } from '../../src/core/global-config.js'; describe('resolveOpenSpecRoot', () => { let tempDir: string; let globalDataDir: string; let savedXdgDataHome: string | undefined; + let savedXdgConfigHome: string | undefined; beforeEach(() => { tempDir = fs.realpathSync.native( @@ -29,6 +31,11 @@ describe('resolveOpenSpecRoot', () => { // a missed arg can never pollute the developer's home registry. savedXdgDataHome = process.env.XDG_DATA_HOME; process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg'); + // Root resolution now reads the global config for `defaultStore`. Pin + // XDG_CONFIG_HOME at an empty temp dir so tests never see the + // developer's real ~/.config/openspec/config.json. + savedXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = path.join(tempDir, 'xdg-config'); }); afterEach(() => { @@ -37,9 +44,18 @@ describe('resolveOpenSpecRoot', () => { } else { process.env.XDG_DATA_HOME = savedXdgDataHome; } + if (savedXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = savedXdgConfigHome; + } fs.rmSync(tempDir, { recursive: true, force: true }); }); + function setDefaultStore(id: string): void { + saveGlobalConfig({ defaultStore: id }); + } + function mkdir(relativePath: string): string { const dir = path.join(tempDir, relativePath); fs.mkdirSync(dir, { recursive: true }); @@ -505,4 +521,91 @@ describe('resolveOpenSpecRoot', () => { }); }); + describe('global defaultStore fallback (#1359)', () => { + it('resolves the global defaultStore when no local root or pointer exists', async () => { + const storeRoot = await registerStore('team-plans'); + setDefaultStore('team-plans'); + const scratch = mkdir('no-root-here'); + + const root = await resolveOpenSpecRoot({ startPath: scratch, globalDataDir }); + + expect(root.source).toBe('global_default'); + expect(root.storeId).toBe('team-plans'); + expect(root.path).toBe(storeRoot); + }); + + it('lets a nearest local root win over the global default', async () => { + await registerStore('team-plans'); + setDefaultStore('team-plans'); + const localRoot = mkdir('app'); + createOpenSpecRoot(localRoot); + const nested = path.join(localRoot, 'src'); + fs.mkdirSync(nested, { recursive: true }); + + const root = await resolveOpenSpecRoot({ startPath: nested, globalDataDir }); + + expect(root.source).toBe('nearest'); + expect(root.path).toBe(localRoot); + expect(root.storeId).toBeUndefined(); + }); + + it('lets a project-level store pointer win over the global default', async () => { + const pointed = await registerStore('team-plans'); + await registerStore('other-plans'); + setDefaultStore('other-plans'); + const pointerDir = mkdir('app-repo'); + fs.mkdirSync(path.join(pointerDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(pointerDir, 'openspec', 'config.yaml'), + 'store: team-plans\n' + ); + + const root = await resolveOpenSpecRoot({ startPath: pointerDir, globalDataDir }); + + expect(root.source).toBe('declared'); + expect(root.storeId).toBe('team-plans'); + expect(root.path).toBe(pointed); + }); + + it('lets explicit --store win over the global default', async () => { + const chosen = await registerStore('team-plans'); + await registerStore('other-plans'); + setDefaultStore('other-plans'); + const scratch = mkdir('no-root-here'); + + const root = await resolveOpenSpecRoot({ + startPath: scratch, + store: 'team-plans', + globalDataDir, + }); + + expect(root.source).toBe('store'); + expect(root.storeId).toBe('team-plans'); + expect(root.path).toBe(chosen); + }); + + it('degrades a stale defaultStore to an error that names how to clear it', async () => { + await registerStore('team-plans'); + setDefaultStore('ghost-plans'); + const scratch = mkdir('no-root-here'); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: scratch, globalDataDir }), + 'unknown_store' + ); + expect(error.message).toContain("Global defaultStore 'ghost-plans'"); + expect(error.diagnostic.fix).toContain('openspec config unset defaultStore'); + }); + + it('falls through to the registered-store hint when no default is set', async () => { + await registerStore('team-plans'); + const scratch = mkdir('no-root-here'); + + await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: scratch, globalDataDir }), + 'no_root_with_registered_stores' + ); + }); + }); + });