openspec/work/simplify-context-and-workspace-model/slices/store-canonical-remote/spec.md
Teammate onboarding stops dead-ending at "register the store". A store can record where it is cloned from — once, in its committed identity file — and every surface that today says "get a checkout from a teammate" can instead say exactly where to clone from: doctor shows the remote, the unresolved-reference warning names the clone source, and register guidance carries it. Recording a remote is not sync: nothing clones, pulls, pushes, or branches.
.openspec-store/store.yaml (the
shared, committed home), populated at setup/register when known.<remote>, then register").store.yaml (committed; the answer to
"where SHOULD this be cloned from"). The observed origin is
machine-local and lives in the registry entry's existing-but-dormant
remote field (foundation.ts:24,51,171 — the roadmap's "registry
already supports an optional remote but nothing populates it"),
captured read-only from git remote get-url origin in BOTH setup
and register (a fresh git init simply has no origin; probing in
both flows keeps storeBackendsMatch — registry.ts:169, which
compares remotes — consistent, so the 1.3 rerun-is-a-no-op contract
survives: a rerun re-observes the same origin, matches, and reports
already_registered: true without rewriting anything). A
re-register after the origin URL changed refreshes the recorded
value — that is the only un-staling mechanism. Display surfaces
probe live; the persisted registry value exists for surfaces that
cannot probe (3.6 relationship-health groundwork). Guidance prefers
canonical, falls back to the observed origin.store setup gains --remote <url>,
written into store.yaml BEFORE the initial commit so the canonical
remote ships in the committed store shape. Register's 1.3 contract,
stated precisely: it never COMMITS and never MODIFIES an existing
store.yaml; it may still create the missing identity file in the
confirmed-conversion path (operations.ts:708-712,
writeMetadataIfMissing) — and that created metadata does NOT
include a remote (observed origin is not team-authored canonical).
Register records the observed origin in the machine-local registry
entry only. Hand-editing store.yaml is the supported
retrofit path (it is plain YAML; the next doctor/register picks it
up); setup --remote against a path whose store.yaml already
exists FAILS with a fix naming the hand-edit
("Edit <abs path>/.openspec-store/store.yaml and commit it") —
silent acceptance that ignores the flag is the one forbidden
outcome.references: entries therefore accept
either a plain id string (3.1 shape, unchanged) or a map
{ id, remote? }. Parsing normalizes to {id, remote?}[]
(ProjectConfig.references changes type; consumers:
instructions.ts and the assembler input). Dedup keys on id,
order-preserving; the FIRST entry carrying a remote wins for that id
(a later duplicate never overrides, matching the 3.1
first-occurrence rule). When the remote is known,
reference_unresolved's fix becomes pasteable verbatim using the
default-path convention rendered ABSOLUTE
(<home>/openspec/<id> via os.homedir() — ~ does not expand
outside a shell, and agent JSON consumers execute argv directly):
git clone <remote> /home/me/openspec/<id> && openspec store register /home/me/openspec/<id> --id <id>;
without it, the current teammate-checkout fix stands. Resolved index
entries do NOT gain a remote field — once registered, the --store
fetch recipe suffices.MetadataStateSchema ({version: 1, id}
.strict(), foundation.ts:184-187; parse-side reconstruction at
265-282) gains an optional non-empty remote. The real
compatibility contract: the new CLI reads old and new files; an OLD
CLI REJECTS a remote-bearing store.yaml (strict()). Accepted —
the store format is pre-release and strictness catches typos like
remot: — but recorded as a standing constraint: any future
store.yaml field is a cross-version protocol change requiring a
version bump or a strictness revisit, and 3.4 must not put target
declarations in store.yaml without addressing this.metadata section gains the
canonical remote (null when absent); the git section gains
origin_url (the observed URL, live-probed like the section's other
facts, null when no origin) beside the existing has_remote
boolean. Human output shows one Remote line preferring canonical.
When canonical and observed disagree, JSON simply carries both
differing values and human shows the canonical line — no new
diagnostic codes anywhere in this slice (3.6 may add a health note).
Setup/register/list JSON keeps the shared StoreOutput shape
unchanged (no remote field there); doctor is the inspection surface.The store author records the canonical remote at creation:
openspec store setup team-context --path ~/src/team-context \
--remote [email protected]:acme/team-context.git
store.yaml (committed in the initial commit):
version: 1
id: team-context
remote: [email protected]:acme/team-context.git
A teammate cloning the app repo sees instructions that no longer dead-end:
<referenced_stores>
Store team-context: not registered on this machine.
Fix: git clone [email protected]:acme/team-context.git /Users/dev/openspec/team-context && openspec store register /Users/dev/openspec/team-context --id team-context
</referenced_stores>
(That remote came from the app repo's own declaration:
references: [{ id: team-context, remote: [email protected]:acme/team-context.git }].)
And doctor tells the truth about both remotes, read-only (the existing doctor layout, store.ts:500-528, plus exactly one new line):
$ openspec store doctor team-context
Store doctor
team-context
Location: /Users/dev/src/team-context
OpenSpec root: ok
Metadata: ok
Remote: [email protected]:acme/team-context.git
Git: repository detected (commits: yes, uncommitted changes: no, remote: yes)
In scope:
remote in MetadataStateSchema +
StoreMetadataState + parseStoreMetadataState +
serializeStoreMetadataState (foundation.ts:44,184-187,265-282,302);
validation: non-empty string when present (matching the registry's
nonEmptyOptionalString).--remote <url> flag; written into store.yaml before
the initial commit; rejected when empty; FAILS with the hand-edit fix
when store.yaml already exists; setup also probes the origin for
its registry entry (consistency with register, rerun no-op
preserved). JSON stays the shared StoreOutput shape — decision 5
wins; doctor is the inspection surface (plan review resolved the
earlier contradiction here).git remote get-url origin (new
function in src/core/store/git.ts beside the existing probes);
observed origin recorded in the registry entry's remote field;
re-register refreshes it; never commits, never modifies an existing
store.yaml; the confirmed-conversion identity write stays
{version, id} only (existing contracts pinned).metadata.remote (canonical) and git.origin_url
(observed, live-probed) in JSON; one human Remote line preferring
canonical.references: entries accept string | {id, remote?}
(parser keeps the 3.1 raw-and-resilient style: map entries without a
string id are dropped with a warning; remote kept when a
non-empty string; normalized in-memory shape {id, remote?}[];
dedup by id, order-preserving; the first entry carrying a remote
supplies it, i.e. a later duplicate fills a missing remote but never
overrides one); the assembler threads the declared remote into
reference_unresolved's fix
(git clone <remote> <home>/openspec/<id> && openspec store register <home>/openspec/<id> --id <id>,
the home directory rendered absolute).docs/cli.md store section documents --remote, the
store.yaml field, and the reference-with-remote form.--remote lands in the
initial commit; setup/register rerun stays a no-op and never erases
the recorded remote; register records the observed origin, refreshes
it on re-register, never commits, never modifies existing
store.yaml; conversion-created metadata has no remote; doctor JSON
[x, {id: x, remote: r}] → one entry, first
remote wins) and map-with-invalid-id (reference_invalid_id wins,
remote ignored); unresolved fix with and without a declared remote;
--no-init-git setup records the remote in the working-tree
store.yaml without a commit; e2e onboarding flow — app repo
declares {id, remote} with a local-path remote, fresh machine
state, instructions name the clone command, executing it verbatim +
register + rerun shows the resolved index.Out of scope:
git remote get-url reads local
config).store.yaml at
register time (register never modifies an existing identity file);
a future store set-remote command (later idea if hand-editing
proves insufficient).store setup team-context --path <p> --remote <url><p>/.openspec-store/store.yaml contains remote: <url>--remote "" fails cleanly before creating anything--remote produces today's byte-identical
store.yamlstore.yaml (version + id only) and a 3.3
one carrying remote:--store resolution run against each<url>remote is <url>store.yaml is not modified and no commit is
created (the confirmed-conversion path may still create a missing
identity file, and that file carries no remote)already_registered: true)
and the recorded remote is untouchedstore setup with --remote against a path whose
store.yaml already existsmetadata.remote and git.origin_urlreferences: [{id: team-context, remote: <url>}] and no local
registrationreference_unresolved fix is
git clone <url> <home>/openspec/team-context && openspec store register <home>/openspec/team-context --id team-context
with <home> rendered as the absolute home directory[team-context, {id: team-context, remote: <url>}] indexes
as one entry whose unresolved fix carries the remotereference_invalid_id, its remote ignoredstore.yaml records a canonical remote{id, remote}
where the remote is a local-path Git remote (no network in tests)