An OpenSpec store (openspec 1.13.1) is a standalone, registered planning
repo: its own openspec/ tree of specs and changes, versioned and shared over
git like any repo. cospec is store-aware — every change-lifecycle command
takes a --store <id> global flag and runs its full typed workflow against that
store instead of the local repo, and the store lifecycle itself
(cospec store setup|register|unregister|remove|list|doctor, plus
cospec context and cospec workset) is a first-class cospec command, never a
bare openspec call.
Each command resolves exactly one operating root — a qualifying-ancestor walk
ported from upstream's own resolver, so cospec and bare openspec agree on
which root a command targets from any directory:
- an explicit
--store <id>flag selects that store outright; - else cospec walks upward from the canonical cwd for the nearest
openspec/that has a planning shape (specs/orchanges/existing as a directory, not a store checkout's own metadata) or, failing that, a config file (config.yaml, elseconfig.yml). A bareopenspec/with neither is skipped — this is what stops a~/openspec/<id>store layout from making$HOMEa phantom root; - at that ancestor, a planning root always wins (a
store:pointer inside one is ignored, with a one-time stderr warning except ontemplatesandschema); only a config-onlyopenspec/follows its pointer — and a malformed one there (unparseable YAML, or a non-stringstore:value) hard-errors withinvalid_store_pointerrather than falling through; - else, once the walk finds nothing, the machine-global
defaultStoreas the last fallback, read raw from the global config file at the pathopenspec config pathprints and parsed as JSON, as upstream'sgetGlobalConfig()reads it (no trimming or stringifying; falsy is unset; a file that can't be read or parsed — missing, a directory, unreadable, not JSON — or a non-object root is unset, and a file that isn't JSON prints upstream'sWarning: Invalid JSON in <path>, using defaultsonce, except ontemplatesandschema); - else, with any stores registered, a hard error naming them
(
no_root_with_registered_stores); with none, the cwd is an implicit root, and each command's own missing-openspec/check reports it from there.
cospec init ports the binary's pointer guard (initPointerRefusal,
core/root.ts): at its target it finds the nearest openspec/ at or above it,
and when that directory is config-only (no planning shape) with a store:
value, or a malformed one, it refuses with the binary's message spelled
cospec, before the --language check, any legacy tool-root move or any write.
A planning root's store: line is ignored, as everywhere else.
Every store selection (steps 1, 3's pointer branch, and 4) is verified on disk —
identity metadata, then root health — before it is used, and announced once on
stderr in human mode by the store banner (never on templates or schema:
upstream never selects a root for them, so resolveRoot({ quiet: true }) prints
none of the resolver's lines there), whose user-visible contract the site owns:
Store verification.
Every command resolves the enclosing root from a subdirectory this way, not only
the exact cwd. cospec templates and cospec schema which|validate|fork|init
go further and spawn the wrapped call inside the resolved root itself — a
deliberate superset of openspec, which reads its own working directory there
and so can't see a project's schemas from a subdirectory. When selection fails
with no explicit --store, those two spawn in the invocation cwd instead and
answer as the binary does there (isCwdFallback, passthrough-command.ts).
Every other wrapped call threads --store <id> only for an explicit --store;
a pointer- or defaultStore-selected root spawns in the invocation cwd instead
and lets the binary re-derive the same root itself, so relayed JSON reports
upstream's own declared/global_default root.source.
The full user-facing account — setup, the config.yaml store: pointer, the
resolution order, cospec context/workset, and what cospec owns vs. what
OpenSpec owns — is owned by the site:
Stores.
cospec new feat cross-repo-epic --store platform # authored in the store
cospec apply cross-repo-epic --store platform # the gate, over the store
cospec archive cross-repo-epic --store platform # verified move, in the storecospec store and cospec workset answer a missing or unknown subcommand with
the binary's own refusal (core/forward-relay.ts relayGroupRefusal), its one
document under --json; store's rendered and relayed diagnostics, workset's
next-step lines and every failed relay spell the binary's remedies through
core/remedies.ts. workset open refuses its argv and --json before any
handover and runs piped with no terminal, or when its read-only
workset list --json pre-flight shows the binary would refuse the open (an
unreadable worksets file, an unsaved name, no member folder on this machine).
The site's Commands page owns
the user-facing account.
A resolved Root carries three things: the store's on-disk base (cospec's
filesystem readers key on it — a store's layout is identical to a repo's,
<base>/openspec/…), the cwd wrapped calls spawn in, and storeArgs — the
--store <id> args a wrapped call appends. storeArgs is set only for an
explicit --store selection (source: store); a pointer- or
defaultStore-selected root gets storeArgs: [] and the wrapped call spawns in
cwd instead, letting the binary re-derive the same root itself. templates
and schema never take storeArgs at all and instead spawn with
cwd: root.base (spawnInRoot, passthrough-command.ts), since both wrapped
subcommands reject --store. The store's path is resolved from the machine
registry via openspec store ls --json, wrapped with the same expected-exit /
post-condition discipline as every other wrapped call — see
architecture.md.