Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/add-global-default-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@fission-ai/openspec": patch
---

### Features

- **One default store for every repo on your machine** — `openspec config set defaultStore <id>` 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`.
7 changes: 4 additions & 3 deletions docs/agent-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ All root-resolving commands (`list`, `show`, `validate`, `status`, `instructions

1. `--store <id>` → 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 <id>`) → 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.
Expand Down
6 changes: 6 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`. 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 <id>` (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?
Expand Down Expand Up @@ -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

Expand Down
21 changes: 20 additions & 1 deletion docs/stores-beta/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/core/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/core/global-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
63 changes: 57 additions & 6 deletions src/core/root-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
* - `--store <id>` 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);
Expand All @@ -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;
Expand Down Expand Up @@ -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<ResolvedOpenSpecRoot> {
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 <path> --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<ResolvedOpenSpecRoot> {
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions test/commands/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
16 changes: 15 additions & 1 deletion test/commands/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 14 additions & 1 deletion test/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
79 changes: 79 additions & 0 deletions test/commands/global-default-store.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading