Stores
An OpenSpec store is a standalone, registered planning repo: its own openspec/ tree of specs and changes, versioned and shared over git like any repo. Stores exist for cross-repo work — one plan that several code repos implement against, or requirements one team owns and others consume.
cospec is store-aware: every change-lifecycle command takes a --store <id> global flag and runs its full typed workflow — schemas, the apply gate, the verified archive, the blocking-changes ledger — against that store instead of the local repo. Nothing about the workflow changes; only the operating root does.
cospec new feat cross-repo-epic --store platform # authored in the store
cospec validate cross-repo-epic --store platform --strict
cospec apply cross-repo-epic --store platform # the gate, over the store
cospec archive cross-repo-epic --store platform # verified move, in the store
cospec status --store platform # list/status against the storeWhat cospec owns vs. what OpenSpec owns
cospec wraps OpenSpec; it does not reimplement the store registry or its resolution semantics. But every store-management, cross-repo-context, and workset surface is a first-class cospec command — you never have to drop out to bare openspec for an everyday operation:
- cospec owns everything a user runs:
- the change lifecycle —
new,validate,apply,archive,status,list,instructions,sync-blockers,migrate— each with--store; - the store lifecycle —
cospec store setup|register|unregister|remove|list(ls)|doctor, which verifies each mutation on disk rather than trusting the wrapped exit code, and, on a successfulsetup/register, auto-runscospec init <root> --harness noneso a new or newly-registered store gets cospec's eleven typed schemas in the same command (opt out with--no-cospec-init); - the read-only cross-repo brief —
cospec context(with--jsonand--code-workspace/--force); - personal working views —
cospec workset create|list|remove|open.
- the change lifecycle —
- OpenSpec still owns the underlying machine registry, the on-disk store format, and the
references:config key (upstream specs surfaced into a code repo's instructions). cospec spawns the pinned binary for all of it, under the same disciplined-passthrough rigor as every other wrapped call; nothing about the registry format itself is reimplemented.
store, context, and workset carry no cospec gate — they're read-only or personal, not change-lifecycle steps — but they still get wrapped-call rigor (declared exit codes, a stdout deny-list, an observable post-condition) and, where the root is store-backed, the same --store threading as everything else.
Setup
Creating a store is a single command — cospec store setup registers the root and stamps it with cospec's typed schemas in one step:
# Create + register the store, and give it cospec's typed schemas in one go.
# (Auto-runs `cospec init <root> --harness none` on success — a store is
# planning-only, so no harness. Pass --no-cospec-init to skip that step.)
cospec store setup platform --path ./platform-store --remote git@github.com:acme/platform-store.git
# Work the store from anywhere by id.
cospec new feat some-epic --store platformTo adopt an already-existing OpenSpec root instead of creating one, cospec store register <path> registers it and runs the same auto cospec init. cospec store ls lists the registered stores, cospec store doctor [id] reports per-store health (git facts, metadata, root completeness — folded automatically into plain cospec doctor too, see below), and cospec store unregister/remove inherit OpenSpec's confirmation contract (unregister forgets the registry entry and leaves files on disk; remove also deletes the folder).
A code repo can also point at a store by default instead of passing --store every time, via its own openspec/config.yaml:
schema: feat
store: platform # cospec + openspec resolve commands against this store
references:
- platform # read-only upstream specs surfaced in instructionsThe full key reference for openspec/config.yaml lives on Configuration.
Resolution order
Each command resolves exactly one operating root — a qualifying-ancestor walk ported from OpenSpec'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 current directory looking for the nearest
openspec/that qualifies: either a planning shape (openspec/specs/oropenspec/changes/existing as a directory, not a store checkout's own metadata) or, failing that, a config file (openspec/config.yaml, elseopenspec/config.yml). A bareopenspec/with neither is skipped and the walk keeps going upward — this is what stops a~/openspec/<id>store layout from making your home directory a phantom root; - at the qualifying ancestor, a planning root always wins. A
store:pointer sitting inside a real planning root is ignored — with a one-time stderr warning naming the config file and the ignored id (except ontemplatesandschema, below) — because a real root is never redirected out from under itself. Only a config-onlyopenspec/(a config file with no planning shape) follows itsstore:pointer; - else, once the walk finds no qualifying ancestor at all, the machine-global
defaultStoreis consulted as the last fallback. cospec reads it asopenspecdoes, from the global config filecospec config pathnames: the JSON value exactly as written, so" beta ","beta\n"or["beta"]fail the same way they fail there. An empty string,false, a root that isn't an object, and a config file that can't be read or parsed at all (a directory, no read permission, not JSON) count as unset, as they do foropenspec; a file that isn't JSON also printsopenspec's ownWarning: Invalid JSON in <path>, using defaultson stderr, once (except ontemplatesandschema, below); - else, with any stores registered, cospec hard-errors naming them (
no_root_with_registered_stores) rather than silently falling back to an empty cwd; with none registered, the cwd is an implicit root and each command's own missing-openspec/check reports it from there.
references: is read-only context, never a root override at any step above — it does not change where a change is created or gated.
cospec init applies the pointer rule at its target before it writes anything: when the nearest openspec/ at or above the target is config-only and its store: line names a store, init refuses (This repo's planning is externalized to store '<id>' …) rather than scaffold a local root beside it, and a store: value that can't be read refuses the same way (The store declaration in <path> is invalid …). Remove the store: line first to turn the repo into a local root. The refusal comes before --language's own check and before any legacy tool-root move.
Store verification
Every store selection above — by --store, by a store: pointer, or by defaultStore — is verified on disk before it is used, not trusted from the registry listing. cospec reads the store's own .openspec-store/store.yaml and confirms it exists, parses, and names the same id the registry has (store_identity_mismatch if it's missing or names a different id; invalid_store_metadata if the file itself won't parse), then checks the store's root is a healthy OpenSpec tree — openspec/ exists, a config file exists, and none of specs/, changes/, changes/archive/ exists as something other than a directory (unhealthy_store_root otherwise, naming each problem it found).
A store selected this way is announced on stderr, before the command's own output, on every human-mode invocation that resolves to a store — by --store, a store: pointer, or defaultStore — except templates and schema (below):
Using OpenSpec root: <id> (<path>)It is the same line bare openspec prints, and it prints exactly once per command even when a relayed wrapped call prints it too. It is printed as soon as the store is selected, so it still appears when the command then fails. It is never printed under --json, and never for a local or implicit root. Scripts that read cospec's stderr for a store-backed root should expect this line, or pass --json.
Errors
A resolution failure exits 1 with a message and, where there is something to suggest, a Fix: line (both name cospec, never openspec) — the same as any other unhandled failure; see the tip below for how this differs from a gate result. Under --json the same failure is one document on stdout and nothing on stderr, the diagnostic in OpenSpec's status envelope (no fix key when there is nothing to suggest):
{
"status": [
{
"severity": "error",
"code": "invalid_store_id",
"message": "Store id must not be empty",
"target": "store.id",
"fix": "Use kebab-case with lowercase letters, numbers, and single hyphen separators."
}
]
}cospec context --json and cospec schemas --json put their command's empty payload ahead of status, exactly as OpenSpec does ("root": null, "members": [] for context; "schemas": [], "root": null for schemas). OpenSpec prints such a payload for other commands too ("changes": [], "root": null for list); there cospec prints the envelope alone.
| code | when |
|---|---|
invalid_store_pointer | a store: value that isn't parseable YAML, or isn't a single id string |
invalid_store_id | a followed pointer, --store or defaultStore is an empty or malformed id |
unknown_store | a --store, pointer, or defaultStore id that isn't registered |
no_registered_stores | the same, on a machine with no stores registered at all |
no_root_with_registered_stores | no qualifying root, no defaultStore, but stores are registered |
store_identity_mismatch | a selected store's metadata is missing, or names a different id |
invalid_store_metadata | a selected store's .openspec-store/store.yaml doesn't parse |
unhealthy_store_root | a selected store's OpenSpec tree is incomplete or damaged |
invalid_store_registry | the machine's store registry file doesn't parse as a registry |
directory_not_found | --cwd names a path that is not an existing directory |
directory_not_found is cospec's own (OpenSpec has no --cwd): it is checked before anything else, prints cospec: directory not found: <path> with no Fix: line, and never resolves an ancestor of the missing path.
A pointer or defaultStore failure is prefixed with its origin — Declared in <config path>: or Global defaultStore '<id>': — so the message names where the bad id came from, not just that it's bad.
A file cospec can't read at all isn't a selection error either — the store registry, or a selected store's .openspec-store/store.yaml or openspec/ tree, when it is (for example) a directory where a file belongs or has no read permission. The command fails with the operating system's message, as openspec prints it — for instance cospec: EACCES: permission denied, open '<registry path>' or cospec: EISDIR: illegal operation on a directory, read (a failed read names no path, unlike open and stat) — with no origin prefix and no Fix: line, exit 1, and under --json that message is the one status entry. A store: pointer file that can't be read fails like one that isn't YAML (invalid_store_pointer), and an openspec/ directory the walk can't look inside is not a root, as in openspec.
An unregistered --store id fails loudly rather than silently falling back to the local repo, so a typo can never write a change to the wrong place:
cospec: unknown store 'bogus-id' — register it with 'cospec store register <path>' or check 'cospec store ls'. Registered stores: platform
Fix: Pass a registered store id, or run cospec store list.Not a gate result This is a usage/resolution error, not a blocked gate —
it exits 1, the same code as any other unhandled failure, not the 2apply/archive use for a blocked change. Don't script against it as if it were a gate outcome; see Apply and archive for the codes that are. :::
templates and schema reach every root by working directory
cospec templates and cospec schema which|validate|fork|init spawn the wrapped call inside the resolved root itself, rather than the invocation directory, since every schema subcommand (and templates) rejects --store on the wrapped binary. This is a deliberate superset of openspec, which reads its own process.cwd() for these two commands: run from a subdirectory, openspec templates --schema feat can't see the project's feat schema, and openspec schema fork/init would create a stray openspec/ in the subdirectory instead of writing into the project. cospec resolves the enclosing root first and spawns there, so the same commands work from anywhere under the project.
These two commands never fail on root selection alone, since openspec never selects a root for them. When selection fails and you passed no --store — no qualifying root with stores registered, a malformed or unregistered store: pointer, a stale or broken defaultStore, or a store registry or selected store that can't be read — cospec runs them in your working directory, with the same output, exit code and files written as openspec there.
openspec never selects a root for these two commands, so it prints none of the lines root selection prints elsewhere, and neither does cospec: no ignored-pointer warning on a planning root with a store: pointer, no Using OpenSpec root: … banner on a store-selected root (even with --store), and no Warning: Invalid JSON … line for a global config that isn't JSON. Their stderr is openspec's alone. Every other command that selects a root prints each of those lines once, as openspec does. With an explicit --store the selection error stands (the Errors table above), since openspec has no --store on these commands to fall back to.
--store on the wrapped call
Every wrapped call other than templates/schema (which never take it) receives --store <id> only when you passed --store explicitly. A root selected through a store: pointer or defaultStore spawns the wrapped call in your own working directory instead, letting the binary re-derive the same root itself from the same pointer or global config — so relayed JSON (show --json, list --specs --json, and the like) reports upstream's own root.source: "declared" or "global_default", matching what bare openspec would report, instead of always reading "store".
Cross-repo context and worksets
Two more store-domain surfaces are first-class cospec commands, not gated change-lifecycle steps:
cospec context [--json] [--code-workspace <path>] [--force]— a read-only brief of the current working set across a repo and itsreferences:stores: which stores are in play, and what they contribute.--code-workspace <path>additionally writes a multi-root VS Code workspace file; cospec checks that the file actually landed on disk before reporting success, rather than trusting the wrapped exit code alone.cospec workset create|list|remove|open— personal, local working views over a store or repo.create/list/removebehave exactly like their OpenSpec counterparts, relayed as OpenSpec answers them with its next steps and remedies spelledcospec, and a missing or unknown subcommand gets OpenSpec's own refusal.openis different in kind: it hands the terminal over to whatever editor or agent session the workset points at — inherited stdio, the child's exact exit code — once nothing OpenSpec would refuse is left; under--jsonit opens nothing and relays OpenSpec's refusal of the mode, and with no terminal it runs piped (theworksetrow on Commands has the details). Worksets never take a--storeflag at all — they're local views, not store operations — socospec worksetnever threads--storethe way every other command on this page does.
cospec doctor also checks store health
Plain cospec doctor — no --store needed — folds in OpenSpec's own relationship diagnostics on every root: root-relationship and reference health from OpenSpec's doctor, and, for a store-backed root, the same git/metadata facts cospec store doctor reports, as openspec-* findings alongside cospec's own checks, with OpenSpec's report itself carried in cospec doctor --json. This is additive and read-only — it never repairs anything, only surfaces what's already there. An explicit --store <id>, a store: pointer or the global defaultStore makes the store the operating root, and cospec's own checks run on it — not on the directory you run it in — so cospec doctor --store <id> from a bare workspace with no openspec/, or from inside another project, checks the store and exits as openspec doctor --store <id> does. See the doctor row on Commands for the full finding set and the --json keys.
How it works
Under the hood a resolved root carries three things: the store's on-disk base path (cospec's filesystem readers key on it — a store's layout is identical to a repo's), the working directory a wrapped openspec call spawns in, and the --store <id> args appended to that call. For most commands the working directory is unchanged — your actual shell location — and only the base path and the appended store args vary with the resolved root, so relative-path flags you pass elsewhere on the command line still resolve against where you ran the command, not the store's. templates and schema are the exception: they spawn inside the resolved root's own base path instead (see above), and never receive --store args at all, since both wrapped subcommands reject the flag.