portable-layout¶
Provenance¶
- Source:
.spec/spexcode/spec-cli/source-of-truth/portable-layout/spec.md - Source SHA-256:
a6cf85295e500a95491ab9cd0b76a414ab4f071d1aa039ee3f0e62ac79e71e04
raw source¶
Our convention — main at the repo root, worktrees in .worktrees/, branch node/<id>, and the session's
record in the per-user global store ([[runtime]], NOT the worktree) — should be the default plug, not an
assumption baked into the tool. Mechanism vs
policy: reading .spec and git log is mechanism; where those live is policy. Someone whose main
lives elsewhere, or who names branches differently, should point the tool at their structure without
forking it. A fresh clone reproduces the tool identically — the Node version is pinned, lockfiles
are tracked, and nothing machine-specific leaks into the tree — so a clean checkout never diverges from
"works on my machine".
expanded spec¶
packages/spec-core/src/layout.ts is the one seam. resolveLayout() answers — where is main, which branch is
its source of truth, how to enumerate the other checkouts, how each declares its node — and exposes the
result at GET /api/settings (its layout half). Everything downstream consumes the resolved layout, never a hardcoded path or
branch name.
Policy is read from an optional spexcode.json at the repo root; absent, the defaults are our
convention:
The same spexcode.json (read through readConfig) is also where adjacent project policy is DECLARED rather
than baked in — including the harnesses delivery-target set [[harness-select]] owns (which harnesses spex
materialize delivers into; default = every native harness). Layout resolution doesn't consume it, but it rides
the same committed-config-with-a-spexcode.local.json-overlay seam: persistent, re-read on every materialize.
The same seam carries [[host-resource-budget]]'s per-session RSS, per-backend RSS, idle-CPU, and sampling
budgets, and [[file-attach]]'s one uploads policy: attachment limit, chunk size, batch concurrency, request
timeout/retry, stale-transfer lifetime/reaper cadence, backend free-space reserve, and eval-evidence ceiling.
packages/spec-core/templates/spexcode.json is the one shipped seed and numeric-default source: readUploadPolicy()
and spex init both read it, then the former overlays the resolved project/local uploads object and validates
every field loudly. Thus a pre-existing project may
omit the section and still receive the portable defaults, while one host can override only (for example) its
chunk size in gitignored spexcode.local.json; no upload-only configuration reader or environment-variable
shadow path exists. Machine-local overrides tune one host without committing its capacity profile, while
malformed values fail loud rather than silently disabling governance.
The config read is the ONE fail-loud seam here (readJsonConfig): an absent file is the legitimate
default (yields {}), but a present-but-malformed one is a user error we never swallow — a JSON typo
would otherwise silently drop every tuned setting the file holds (layout, launchers, and the lint budgets
[[spec-lint]]'s loadConfig reads through the same helper) and revert to defaults with no diagnostic. It
fails LOUD instead, naming the file and the parse error, so the author sees exactly what broke.
The source-of-truth branch — what worktrees fork from, merges land on, and reviews diff against — is a
stable project fact, never the mutable branch currently checked out in a particular directory. spex init
records the root checkout's branch in mainBranch; an explicit pre-existing value wins, and an older project
with no value uses the conventional main. This one-time adoption detection lets a staging/feat-x repo
work without hand configuration while an ordinary later git switch node/x cannot redefine that feature
branch as trunk. This single resolution is surfaced two ways downstream — GET /api/settings
for the dashboard and spex internal trunk (one line, for shell consumers like the [[main-guard]] pre-commit hook,
which asks "is HEAD the trunk?" instead of hardcoding main). Both resolve via the shared git common
dir, so they answer identically from the main checkout, a linked worktree, or a commit hook:
mainCheckout() exposes the root working tree itself
(dirname of the common dir), which a harness keying a per-PROJECT artifact to the root checkout uses — e.g.
Codex's hook shim + trust materialize at mainCheckout(proj), not the worktree (see [[harness-adapter]]).
The common-dir and toplevel queries are memoized by their resolved input path for the process lifetime: the
no-argument common-dir query keys itself by the current working directory, so one process can deliberately
move between isolated repositories without inheriting another checkout's identity. Git's answer for a live
checkout is stable while that checkout exists, and one render must not fork the same identity probe once per
artifact. Different path inputs remain distinct, so a linked worktree still receives
its own tree slot while sharing only the common-dir-derived project root.
mainRoot(proj?) is the lighter sibling for consumers that need the configured source-of-truth path rather
than the physical root checkout: it follows the same common-dir resolution, reads only the root config, and
resolves its optional main relative to that checkout. It must not call resolveLayout() or enumerate
session/worktree rows; creation authority and any other identity guard can compare canonical main roots without
turning a small identity question into a board read.
A managed session's node id comes from its global record (node, the ref the session was bound to —
which the branch slug's -<id4> suffix can't give), falling back to the branch (strip branchPrefix) when
absent. Beyond resolution, the seam produces the board's raw
material: for each governed record it computes that worktree's pending spec-node changes vs main (ops,
consumed by [[sessions]]' buildBoard) — the board ENUMERATES the global store (filtered to governed:true),
NOT git worktree list, so an unmanaged scratch worktree (agent-*) never appears. A shelved record
([[archive]]) is the one governed row that still enumerates but computes NO ops: that git-history probe is
the seam's dominant per-row cost and shelving is the human declining to spend it, so its row is served bare
and its cached delta is evicted rather than kept alive by a row nobody is watching.
The graph supplies the exact ids from its already-frozen public session projection when it resolves this layout.
That projection is the archive authority for graph overlays too: a valid cold archived row stays bare, while an
invalid cold witness or resident-runtime hazard is projected back into the working set and retains its worktree
delta. The full graph producer and the session splice therefore cannot disagree about whether that root contributes
ops; callers that do not publish the graph's session state omit the projection and retain the raw-record default.
Layout rows are a public record projection, not an internal-readiness view. resolveLayout() consumes the same
layout-owned three-way parser as the session list and resource report: a valid launch-readiness-pending row
keeps the frozen original status/archive fields and explicit offline liveness; a malformed fence remains a
present corrupt row with unknown liveness and performs no worktree delta walk. Semantic lifecycle/proposal
enum violations are malformed too. Thus /api/settings cannot
publish an idle/online candidate or silently drop an unreadable session while another surface stays fail-closed.
One public generation of the layout owns one exact overlay flight. Concurrent consumers such as
/api/settings and /api/graph join when main tip, worktree paths, worktree HEADs, and .spec signatures are
identical; completion is evicted immediately, so this is coordination rather than a second cache. The flight,
not whichever consumer arrived first, owns the bounded Git context. Each caller owns only its wait: a cancelled
graph build leaves a concurrent settings read alive, while the generation aborts its Git children once no caller
remains. Thus caller order cannot leak or erase cancellation authority. The existing
per-worktree result cache remains the only retained state. A cold miss plans the whole governed, non-archived
set together and lets [[git-exec]] batch clean immutable pairs; dirty and untracked worktrees retain their exact
working-tree semantics. A missing worktree is still omitted, a transient per-worktree failure still serves only
that row degraded, and a batch failure is never published as an empty successful overlay. The optimization may
reduce children, never rows, ops, rename attribution, dirty state, or fail-loud behavior.
Because the record left the worktree, an agent's spex session done/park/ask finds its OWN session in the
ENVIRONMENT (envSessionId()), with a harness-aware precedence: a harness's per-thread env var
(sessionEnvVar) that ALIASES to a governed record (via harness_session_id, [[runtime]]) beats
SPEXCODE_SESSION_ID. Codex needs this — its ONE shared per-project app-server ([[harness-adapter]]) runs
the agent's shell under the FIRST session's baked SPEXCODE_SESSION_ID, while codex injects the acting
thread's CODEX_THREAD_ID per command, which aliases correctly. Claude is unchanged (its env var already
equals its record id); a raw, un-aliased harness id is the last resort, below SPEXCODE_SESSION_ID.
The lookup reads the adapter-neutral HarnessIdentity registry, the same sole source from which each full
harness adapter obtains its sessionEnvVar; layout never imports the launcher-bearing adapter registry and
does not carry a copied environment-variable list.
Alias search answers a question only about ids the store does not already own, and both layers —
readAliasedRecordEntry and the shell twin hp_store_dir — apply that rule identically. Absence splits in
two. An id that owns a STORE DIRECTORY is already one of ours: "this session exists and has written no record
yet" — the sentinel-only self-launched agent above — is a settled fact about that session, so resolution
stops there and reports absence. Only an id owning no store directory can be some record's
harness_session_id, and only then may the search over records run. Collapsing the two halves is wrong twice
over. It is a mis-resolution: an unrelated record whose harness id happens to equal a live session's name
would answer under that name, displacing the session the id actually denotes. And it is unbounded work:
because the turn-failure supervisor reconciles every record once a second ([[sessions-core]]), a single
record-less store directory re-read and re-parsed the WHOLE store on every tick — measured on a live
339-session store with 176 record-less directories at 60,003 synchronous reads per second, which saturates
the event loop and starves every request behind it, the board build included. The cost of the correct rule is
one directory check, so the search stays linear only where a search is the actual question.
The same policy-not-hardcode rule governs where the config loaders look. The spec tree's root
node — the single top-level directory under .spec/ that holds a spec.md — is detected at read
time, never assumed by name: the dogfood repo's is spexcode, a spex init adopter's is project. So
[[source-of-truth]]'s specs.ts resolves the two plugin roots (<root>/.plugins and <root>/plugin-system,
scanned by loadSurface per [[surface]]) from that detected root, not a baked-in spexcode. Without
it an adopter's loadSystemConfig finds nothing — the .plugins/core contract never loads, launched agents
get no system prompt — so portability is only real when the config root travels with the rename.
The reproducibility contract is concrete: .nvmrc pins Node (22) and both package-locks are tracked, so
installs are deterministic. Machine-local artifacts never enter the tree: a host-specific launcher cmd
lives in the gitignored spexcode.local.json's sessions.launchers entry (readConfig overlays it on
committed spexcode.json; no env override — [[launcher-select]]), so a host-specific launcher path has a
durable home surviving restarts,
never committed. (The old HOST-personal render vote that lived in this overlay is retired with the whole
axis — [[residence]]: materialized artifacts are never tracked, and a lingering render/private field is ignored
with a loud notice.) A launch generates NO per-session SpexCode files in the worktree: the
record and the launcher products (prompt, launch, launch.sh, recorded comms) live in the per-user global
store ([[runtime]]), keyed by session_id, outside the tree — so nothing per-session is left to ignore or
commit (the contract instead reaches the agent by materializing into the worktree's OWN tracked
CLAUDE.md/AGENTS.md, not by hiding it into the store — see [[harness-delivery]]). No absolute machine
path is baked anywhere in the checkout.