@spexcode/spec-core 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/layout.ts ADDED
@@ -0,0 +1,637 @@
1
+ import { readFileSync, existsSync, readdirSync } from 'node:fs'
2
+ import { join, dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { git, repoRoot, gitA, gitAbortError, currentGitBuildAbortSignal, gitInterpretationIdentity, headSha, worktreeSpecSig, worktreeSpecDelta, worktreeSpecDeltas, withGitAbortSignal, type NodeOp } from './git.js'
5
+ import { guardWorktree } from './resilience.js'
6
+ import { HARNESS_IDENTITIES, type HarnessId } from './harness-identity.js'
7
+ import { encodeProject, projectRuntimeRoot, spexcodeHome } from './project-store.js'
8
+
9
+
10
+ export type Config = {
11
+ main?: string // path to the source-of-truth checkout (default: the `main` worktree)
12
+ mainBranch?: string // stable source-of-truth branch stamped by init (default: "main")
13
+ branchPrefix?: string // how a branch names its node (default: "node/")
14
+ preset?: string // the SELECTED init preset — which cumulative .plugins tier `spex init` seeds (default 'default'; seed-time only, no launcher gate; read by init.ts; see [[init-preset]])
15
+ // RETIRED ([[residence]]) — the old three-word footprint vote. Materialized artifacts carry no facts and are never
16
+ // tracked now (one residence behavior: the per-clone exclude, plus the content filter for a mixed
17
+ // contract file), so the field is IGNORED with a loud non-fatal notice (materialize's retiredAxisNotice);
18
+ // it stays in the type only so the notice can read it. The schema deliberately has NO knob for the spec
19
+ // DATA: `.spec` + spexcode.json are ALWAYS tracked ("git is the database") — the vocabulary itself makes
20
+ // "untrack the spec" unsayable.
21
+ render?: string
22
+ // RETIRED (residence compat): the old private-overlay toggle — ignored with the same loud notice;
23
+ // its data-untrack semantics are long gone. See `spex guide footprint` MIGRATIONS.
24
+ private?: boolean
25
+ // which harness targets `spex materialize` delivers into — a native HarnessId or a {plugin:"<folder>"}
26
+ // bundle; resolved + validated by [[harness-select]] (harness-select.ts). REQUIRED — no default set; `spex init --harness` stamps it.
27
+ harnesses?: (string | { plugin?: string })[]
28
+ dashboard?: {
29
+ apiUrl?: string // the per-project backend the board proxies to (read frontend-side; see api-endpoint)
30
+ title?: string // override for the browser-tab name (default: the repo-root basename; see tab-title)
31
+ icon?: string // project identity icon: a picker preset id; existing emoji/Iconify/URL values remain supported ([[identity-config]])
32
+ showHeadlessLaunchers?: boolean // include headless harness profiles in the dashboard New Session picker (default false; [[launcher-visibility]])
33
+ }
34
+ uploads?: {
35
+ // One resumable attachment policy. Values default from templates/spexcode.json so a project may omit this
36
+ // section, while spexcode.local.json can tune the whole top-level section for one machine.
37
+ maxBytes?: number // maximum bytes in one attachment (default: templates/spexcode.json)
38
+ chunkBytes?: number // raw PATCH payload cap and client slice size (default: templates/spexcode.json)
39
+ concurrency?: number // simultaneous attachment streams in one dashboard batch (default: templates/spexcode.json)
40
+ requestTimeoutMs?: number // browser timeout for one chunk/complete request (default: templates/spexcode.json)
41
+ retryLimit?: number // automatic retries after the first failed transient chunk request (default: templates/spexcode.json)
42
+ retryDelayMs?: number // wait between automatic transient-request retries (default: templates/spexcode.json)
43
+ incompleteTtlMs?: number // idle staging lifetime before an unfinished transfer expires (default: templates/spexcode.json)
44
+ cleanupIntervalMs?: number // reaper interval for stale staging bytes (default: templates/spexcode.json)
45
+ minFreeBytes?: number // filesystem capacity retained while reserving attachments (default: templates/spexcode.json)
46
+ evidenceMaxBytes?: number // retained ceiling for eval-evidence POST bodies (default: templates/spexcode.json)
47
+ }
48
+ sessions?: {
49
+ maxActive?: number // concurrency cap: max agents AUTONOMOUSLY PROGRESSING at once (default 8; see sessions.ts maxActive)
50
+ // named launcher profiles: a session picks ONE by name at create time ([[launcher-select]]), fixing both
51
+ // its harness AND its exact launch command; the chosen NAME is persisted on the record so resume reuses the
52
+ // same auth. `harness` defaults to 'claude'. Host-specific `cmd`s (abs wrapper paths) belong in the
53
+ // gitignored spexcode.local.json — the name is portable, the cmd is a machine fact.
54
+ launchers?: { [name: string]: { harness?: HarnessId; cmd: string } }
55
+ defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses; required for no-choice creates
56
+ }
57
+ resources?: {
58
+ sessionRssMiB?: number // resident-memory budget for one session owner (default 1024)
59
+ backendRssMiB?: number // resident-memory budget for this project's backend instance (default 2048)
60
+ idleCpuPercent?: number // CPU budget for a non-progressing owner (default 2)
61
+ sampleMs?: number // CPU measurement window for an on-demand report (default 1000)
62
+ reportIntervalMs?: number // supervisor-owned snapshot cadence (default 60000)
63
+ }
64
+ serve?: {
65
+ // public-exposure config for `spex serve --public` (resolved gateway-side; see [[public-mode]] / gateway.ts).
66
+ // The password is NEVER read from here — flag/env only — so this file stays committable.
67
+ public?: {
68
+ enabled?: boolean // turn public mode on without the --public flag
69
+ http?: boolean // drop TLS (the --http escape hatch) — password then travels in cleartext
70
+ tls?: { cert?: string; key?: string } // PATHS to your own cert/key; omit for a cached self-signed default
71
+ }
72
+ }
73
+ issues?: {
74
+ enabled?: boolean // the [[local-issues]] issues-workflow on/off switch (default ON). OFF silences the post-merge nudge + hides the dashboard view; flip by editing this key (no CLI toggle verb — v0.3.0). A legacy `proposals.enabled` is NOT read; `spex doctor` reports it.
75
+ }
76
+ forge?: {
77
+ host?: string // explicit forge host id ('github'|'gitlab'|…) overriding the origin-remote derivation ([[forge-host]] — read by spec-forge drivers.ts resolveForgeHost, not here). A project fact → committed spexcode.json.
78
+ }
79
+ }
80
+ // the resolved LAYOUT convention — main/mainBranch/branchPrefix filled to defaults. `dashboard`, `sessions`,
81
+ // `serve`, `harnesses`, `render`, and `preset` are frontend/runtime/policy concerns (read separately via readConfig —
82
+ // preset by init.ts at seed time, harnesses by [[harness-select]]; see api-endpoint / sessions.ts maxActive /
83
+ // gateway.ts), NOT layout fields, so they stay out of the convention rather than forcing a default.
84
+ type Convention = Required<Omit<Config, 'dashboard' | 'uploads' | 'sessions' | 'resources' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'forge' | 'private' | 'render'>>
85
+
86
+ export type Worktree = {
87
+ path: string; branch: string | null; node: string | null
88
+ session: string | null; status: string | null; isMain: boolean
89
+ liveness?: 'offline' | 'unknown'
90
+ ops: NodeOp[] // pending spec-node changes this worktree makes vs main (the board's overlay)
91
+ }
92
+ export type Layout = { main: string; convention: Convention; worktrees: Worktree[] }
93
+
94
+ // Read an OPTIONAL JSON config file. An ABSENT file is the legitimate default (return {}); a
95
+ // PRESENT-but-malformed one is a user error we must NOT swallow — a typo would otherwise silently
96
+ // drop every tuned setting the file holds (lint budgets, launchers, layout) and revert to defaults
97
+ // with no diagnostic. Fail LOUD, naming the file and the parse error, so the author sees what broke.
98
+ export function readJsonConfig(p: string): any {
99
+ if (!existsSync(p)) return {}
100
+ try { return JSON.parse(readFileSync(p, 'utf8')) }
101
+ catch (e) {
102
+ const err = new Error(`malformed ${p}: ${(e as Error).message}\n → its settings were NOT applied. Fix the JSON syntax (an absent file is a fine default; a broken one is not).`)
103
+ err.name = 'ConfigError' // rendered message-only at the CLI boundary (like BackendError), not as a stack dump
104
+ throw err
105
+ }
106
+ }
107
+ // committed `spexcode.json` with an OPTIONAL machine-local `spexcode.local.json` layered on top (gitignored).
108
+ // The local layer is the durable home for HOST-SPECIFIC values that must never be committed — e.g. an
109
+ // absolute worker-launcher path (the host-path leak the repo otherwise warns against). Precedence per field:
110
+ // local over committed; a targeted env override (e.g. SPEXCODE_CODEX_SERVER_CMD) still wins at its read site.
111
+ export function readConfig(root: string): Config {
112
+ const committed = readJsonConfig(join(root, 'spexcode.json'))
113
+ const local = readJsonConfig(join(root, 'spexcode.local.json'))
114
+ const out: any = { ...committed }
115
+ for (const k of Object.keys(local)) {
116
+ const b = committed[k], o = local[k]
117
+ out[k] = (b && o && typeof b === 'object' && typeof o === 'object' && !Array.isArray(o)) ? { ...b, ...o } : o
118
+ }
119
+ return out
120
+ }
121
+
122
+ export type UploadPolicy = Required<NonNullable<Config['uploads']>>
123
+
124
+ export const templateConfigPath = fileURLToPath(new URL('../templates/spexcode.json', import.meta.url))
125
+ const MIN_POSITIVE_INTEGER = 1
126
+ const MIN_NONNEGATIVE_INTEGER = 0
127
+
128
+ function uploadConfigError(field: keyof UploadPolicy, rule: string): never {
129
+ const error = new Error(`uploads.${field} must be ${rule}`)
130
+ error.name = 'ConfigError'
131
+ throw error
132
+ }
133
+
134
+ function configuredInteger(value: unknown, field: keyof UploadPolicy, minimum: number): number {
135
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) {
136
+ uploadConfigError(field, minimum === MIN_POSITIVE_INTEGER ? 'a positive integer' : 'a non-negative integer')
137
+ }
138
+ return value
139
+ }
140
+
141
+ function resolveUploadPolicy(values: Record<keyof UploadPolicy, unknown>): UploadPolicy {
142
+ const policy: UploadPolicy = {
143
+ maxBytes: configuredInteger(values.maxBytes, 'maxBytes', MIN_POSITIVE_INTEGER),
144
+ chunkBytes: configuredInteger(values.chunkBytes, 'chunkBytes', MIN_POSITIVE_INTEGER),
145
+ concurrency: configuredInteger(values.concurrency, 'concurrency', MIN_POSITIVE_INTEGER),
146
+ requestTimeoutMs: configuredInteger(values.requestTimeoutMs, 'requestTimeoutMs', MIN_POSITIVE_INTEGER),
147
+ retryLimit: configuredInteger(values.retryLimit, 'retryLimit', MIN_NONNEGATIVE_INTEGER),
148
+ retryDelayMs: configuredInteger(values.retryDelayMs, 'retryDelayMs', MIN_NONNEGATIVE_INTEGER),
149
+ incompleteTtlMs: configuredInteger(values.incompleteTtlMs, 'incompleteTtlMs', MIN_POSITIVE_INTEGER),
150
+ cleanupIntervalMs: configuredInteger(values.cleanupIntervalMs, 'cleanupIntervalMs', MIN_POSITIVE_INTEGER),
151
+ minFreeBytes: configuredInteger(values.minFreeBytes, 'minFreeBytes', MIN_NONNEGATIVE_INTEGER),
152
+ evidenceMaxBytes: configuredInteger(values.evidenceMaxBytes, 'evidenceMaxBytes', MIN_POSITIVE_INTEGER),
153
+ }
154
+ if (policy.chunkBytes > policy.maxBytes) uploadConfigError('chunkBytes', 'no greater than uploads.maxBytes')
155
+ return policy
156
+ }
157
+
158
+ export function uploadPolicyDefaults(): UploadPolicy {
159
+ return resolveUploadPolicy(readJsonConfig(templateConfigPath).uploads as Record<keyof UploadPolicy, unknown>)
160
+ }
161
+
162
+ // The seed template is the sole default-value source. Existing projects may omit `uploads`; they receive this
163
+ // policy, while a committed/local value overrides it through readConfig's existing one-level merge.
164
+ export function readUploadPolicy(root: string): UploadPolicy {
165
+ const configured = readConfig(root).uploads
166
+ return resolveUploadPolicy({ ...uploadPolicyDefaults(), ...configured } as Record<keyof UploadPolicy, unknown>)
167
+ }
168
+
169
+ // The shared git common dir (env-stripped git() so a hook's exported GIT_DIR can't misdirect it).
170
+ // Cache by resolved checkout path: callers may deliberately chdir between isolated repositories in one process.
171
+ const commonDirsByPath = new Map<string, string>()
172
+ const topsByPath = new Map<string, string>()
173
+ export function gitCommonDir(): string {
174
+ return commonDirFor(process.cwd())
175
+ }
176
+ function commonDirFor(proj: string): string {
177
+ const key = resolve(proj)
178
+ const known = commonDirsByPath.get(key)
179
+ if (known) return known
180
+ const common = git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
181
+ commonDirsByPath.set(key, common)
182
+ return common
183
+ }
184
+ function topFor(proj: string): string {
185
+ const key = resolve(proj)
186
+ const known = topsByPath.get(key)
187
+ if (known) return known
188
+ const top = git(['-C', proj, 'rev-parse', '--show-toplevel']).trim()
189
+ topsByPath.set(key, top)
190
+ return top
191
+ }
192
+
193
+ export function mainBranch(): string {
194
+ let checkout: string
195
+ try { checkout = mainCheckout() } catch { return 'main' }
196
+ return readConfig(checkout).mainBranch?.trim() || 'main'
197
+ }
198
+
199
+ // the MAIN checkout (the root working tree) for a project — the SAME answer from main OR any linked worktree
200
+ // (dirname of the shared git common dir). Codex reads a LINKED worktree's PROJECT hooks from the root checkout's
201
+ // `.codex` (codex-rs hooks_config_folder override), NOT the worktree's, so the codex hooks shim + trust
202
+ // materialize here while AGENTS.md/skills stay per-worktree — see [[harness-adapter]] (harness.ts).
203
+ export function mainCheckout(proj?: string): string {
204
+ const gcd = proj ? commonDirFor(proj) : gitCommonDir()
205
+ return dirname(gcd)
206
+ }
207
+
208
+ // @@@ main root - identity checks need creation's configured-main answer without the record/overlay work in resolveLayout.
209
+ export function mainRoot(proj?: string): string {
210
+ if (proj) {
211
+ const checkout = mainCheckout(proj)
212
+ const configured = readConfig(checkout).main?.trim()
213
+ return configured ? resolve(checkout, configured) : checkout
214
+ }
215
+ try {
216
+ const checkout = mainCheckout()
217
+ const configured = readConfig(checkout).main?.trim()
218
+ return configured ? resolve(checkout, configured) : checkout
219
+ } catch { return repoRoot() }
220
+ }
221
+
222
+ // @@@ global per-session store - Fork A: NO SpexCode files live in the worktree any more, so the worktree's
223
+ // spec/code tree is pristine (zero per-session pollution). Every per-session runtime artifact — the
224
+ // structured record (session.json) AND the launcher products (prompt, launch, launch.sh) AND the recorded comms AND
225
+ // the spec-discipline sentinels — lives in a per-USER GLOBAL store, keyed by the harness `session_id` so two
226
+ // agents in one folder never clobber, and grouped PER PROJECT (mirroring Claude's ~/.claude/projects/<enc>/)
227
+ // so the board enumerates ONE directory. This is the single seam that knows where the store sits; sessions.ts
228
+ // and the shell hooks resolve through the SAME scheme (the hooks reimplement it in bash, so any change here
229
+ // must be mirrored in .plugins/core/*/). SPEXCODE_HOME overrides the root for test isolation.
230
+ // encode a project-root path into ONE safe directory segment (Claude's scheme: path separators → '-'). The
231
+ // SAME transform runs in TS and in the shell hooks, so a board read and a hook write land on the SAME dir.
232
+ // this project's per-PROJECT runtime tier — the sessions/ records AND the per-TREE materialize slots (below) —
233
+ // living under the SAME global per-project dir, so NOTHING SpexCode materializes stays in the worktree (the
234
+ // worktree holds only the harness-discovered CLAUDE.md/AGENTS.md + shims, which must sit in-tree).
235
+ // proj-aware for `spex init <dir>` / materialize(proj); cwd-based default for the hooks/board. The shell
236
+ // hooks mirror this as hp_runtime_dir.
237
+ export function runtimeRoot(proj?: string): string {
238
+ const gcd = proj ? commonDirFor(proj) : gitCommonDir()
239
+ return projectRuntimeRoot(gcd)
240
+ }
241
+ // the per-WORKTREE materialize slot — <runtime>/trees/<enc(worktree-toplevel)> — holding the materialize
242
+ // products that are a pure function of ONE tree's .plugins (hooks-manifest, content-hash, plugin-folders).
243
+ // Slotted per tree exactly like sessions/<id> is slotted per session: the old single global file made the
244
+ // last-materialized tree win, so dispatch ran tree A's compiled hook set inside tree B's sessions
245
+ // ([[hook-dispatch]]). Key = the sessions encodeProject transform over `rev-parse --show-toplevel`, the
246
+ // SAME derivation dispatch.sh's shell mirror (hp_tree_dir) runs from its own cwd — so writer and reader
247
+ // land on the same slot from the same tree, and only from the same tree. Throws when `wt` is not a live
248
+ // git tree (fail loud); a best-effort caller (the close-time GC) wraps it.
249
+ export function treeSlotDir(wt: string): string {
250
+ const top = topFor(wt)
251
+ return join(runtimeRoot(wt), 'trees', encodeProject(top || wt))
252
+ }
253
+ // this project's per-session records dir, one session's dir, its structured record, and a sibling artifact —
254
+ // all keyed by session_id under <home>/projects/<enc>/sessions/.
255
+ export function sessionsRoot(): string { return join(runtimeRoot(), 'sessions') }
256
+ export function sessionStoreDir(id: string): string { return join(sessionsRoot(), id) }
257
+ export function sessionRecordPath(id: string): string { return join(sessionStoreDir(id), 'session.json') }
258
+ export function sessionArtifactPath(id: string, name: string): string { return join(sessionStoreDir(id), name) }
259
+
260
+ // the structured per-session record, as it sits on disk. Written one-field-per-line with EVERY key present
261
+ // (see sessions.ts writeRecord) so the hot-path mark-active shell hook can value-replace status/proposal/note
262
+ // with sed and never needs jq. Read here for the overlay; sessions.ts owns the full typed read/write.
263
+ export type RawRecord = {
264
+ session_id: string; governed: boolean; worktree_path: string; branch: string | null
265
+ node: string | null; title: string | null; name: string | null; parent?: string | null
266
+ status: string; proposal: string | null; merges: number; review_epoch?: number; note: string | null
267
+ sortkey: number | null; createdAt: number; harness?: string; harness_session_id?: string
268
+ stopped?: boolean
269
+ archived?: boolean // the human ARCHIVED this session ([[archive]]) — only a proven cold/offline row; absent → false on old records
270
+ cold_proof?: string // durable exact leaf + adapter cold proof; absent on legacy archives, which remain visible hazards
271
+ adapter_recovery?: string // explicit lifecycle recovery required after a partial adapter mutation; absent on old records
272
+ launcher?: string // the launcher profile this session was created under ([[launcher-select]]); absent/empty only on old records predating launchers
273
+ launch_cmd?: string // the RESOLVED base launcher command PINNED at creation, so a resume replays the EXACT launcher (and its config-dir env) that made the conversation, never a since-changed default ([[launcher-select]] resume-launcher-pin); absent → old record, fall back to the launcher name / ambient
274
+ create_request_id?: string // SHA-256 digest of the create Idempotency-Key; the raw key is never persisted
275
+ create_payload_hash?: string // normalized create payload bound to create_request_id
276
+ base?: string // the exact fork point pinned at creation; absent/empty → the auto-detected source-of-truth branch
277
+ launch_readiness_pending?: '' | RawLaunchReadinessPending
278
+ }
279
+
280
+ export const SESSION_LIFECYCLES = ['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'] as const
281
+ export const SESSION_PROPOSALS = ['merge', 'nothing', 'close'] as const
282
+ export type SessionLifecycle = typeof SESSION_LIFECYCLES[number]
283
+ export type SessionProposal = typeof SESSION_PROPOSALS[number]
284
+ const sessionLifecycles = new Set<string>(SESSION_LIFECYCLES)
285
+ const sessionProposals = new Set<string>(SESSION_PROPOSALS)
286
+ export const isSessionLifecycle = (value: unknown): value is SessionLifecycle =>
287
+ typeof value === 'string' && sessionLifecycles.has(value)
288
+ export const isSessionProposal = (value: unknown): value is SessionProposal =>
289
+ typeof value === 'string' && sessionProposals.has(value)
290
+
291
+ export type RawLaunchReadinessOriginal = {
292
+ status: string
293
+ proposal: string | null
294
+ note: string | null
295
+ stopped: boolean
296
+ archived: boolean
297
+ cold_proof: string | null
298
+ adapter_recovery: string | null
299
+ }
300
+
301
+ export type RawLaunchReadinessPending = {
302
+ version: 1
303
+ startedAt: number
304
+ original: RawLaunchReadinessOriginal
305
+ }
306
+
307
+ // A launch candidate is durable before it is public. Readers of the authored lifecycle use this one parser
308
+ // so the board and the independent timeline observer cannot disagree about an in-flight resume. Invalid
309
+ // pending bytes throw: a damaged publication fence is unknowable state, never permission to project online.
310
+ export function rawLaunchReadinessOriginal(raw: RawRecord): RawLaunchReadinessOriginal | null {
311
+ const pending = raw.launch_readiness_pending
312
+ if (pending == null || pending === '') return null
313
+ const original = pending && typeof pending === 'object' ? pending.original : null
314
+ if (pending.version !== 1 || !Number.isFinite(pending.startedAt) || !original || typeof original !== 'object'
315
+ || !isSessionLifecycle(original.status)
316
+ || !(original.proposal === null || original.proposal === '' || isSessionProposal(original.proposal))
317
+ || !(typeof original.note === 'string' || original.note === null)
318
+ || typeof original.stopped !== 'boolean' || typeof original.archived !== 'boolean'
319
+ || !(typeof original.cold_proof === 'string' || original.cold_proof === null)
320
+ || !(typeof original.adapter_recovery === 'string' || original.adapter_recovery === null)) {
321
+ throw new Error(`session '${raw.session_id}' has an invalid launch_readiness_pending fence`)
322
+ }
323
+ return original
324
+ }
325
+
326
+ // the agent's OWN session id from the environment — the only locator now that the record left the worktree.
327
+ // Three tiers, in order:
328
+ // (1) a harness's per-thread env var (`sessionEnvVar`) RESOLVED VIA THE ALIAS — when it lands on a governed
329
+ // record (directly, or through that record's `harness_session_id`), that record's SpexCode id is the
330
+ // answer. This MUST win: codex's design-C runs ONE shared per-project app-server whose env carries the
331
+ // FIRST launched session's `SPEXCODE_SESSION_ID`, and the agent's shell tool (its `spex session
332
+ // done/park/ask`) runs INSIDE that app-server process, so `SPEXCODE_SESSION_ID` is contaminated with the
333
+ // wrong session. But codex injects the ACTING thread's id into every spawned command's env as
334
+ // CODEX_THREAD_ID (== codex's `sessionEnvVar`), so the per-thread var aliases to the RIGHT record while
335
+ // the shared `SPEXCODE_SESSION_ID` does not.
336
+ // (2) else `SPEXCODE_SESSION_ID` (the GOVERNED record id the launcher bakes in) — the claude path and the
337
+ // non-shared baseline.
338
+ // (3) else a harness's env var RAW — a self-launched, non-governed agent's own minted id, which has no
339
+ // governed record to alias to (codex CODEX_THREAD_ID / claude CLAUDE_CODE_SESSION_ID). The RAW form must
340
+ // stay BELOW (2): an un-aliased codex thread id is not a record key, so it must never beat a real
341
+ // `SPEXCODE_SESSION_ID`.
342
+ // Claude is UNCHANGED: its `sessionEnvVar` (CLAUDE_CODE_SESSION_ID) already EQUALS its record id, so tier (1)
343
+ // resolves to that very id — the same value `SPEXCODE_SESSION_ID` would have returned; there is no shared
344
+ // app-server to contaminate it. No worktree fallback. (sessions.ts's `ownSessionId` delegates here; spec-eval
345
+ // reads it to resolve the current node.)
346
+ export function envSessionId(): string | null {
347
+ for (const h of HARNESS_IDENTITIES) {
348
+ const v = process.env[h.sessionEnvVar]
349
+ if (v && v.trim()) { const r = readAliasedRawRecord(v.trim()); if (r) return r.session_id }
350
+ }
351
+ const o = process.env.SPEXCODE_SESSION_ID
352
+ if (o && o.trim()) return o.trim()
353
+ for (const h of HARNESS_IDENTITIES) { const v = process.env[h.sessionEnvVar]; if (v && v.trim()) return v.trim() }
354
+ return null
355
+ }
356
+ // @@@ RecordEntry - a record read has THREE outcomes, and collapsing them is what let a live session read as
357
+ // "no session record". ABSENT (no file) is the legitimate nothing — a self-launched agent that only ever wrote
358
+ // spec-discipline sentinels has a store dir and no record. CORRUPT (present but unparseable, or parseable but
359
+ // not a record) is a FACT about a session that exists, so it must reach the surfaces as itself instead of
360
+ // masquerading as absence: sessions-core refuses every writer on it and the board gives it its own row. Any
361
+ // OTHER read failure (permissions, I/O) still THROWS — a transient fault must not read as either.
362
+ export type RecordEntry =
363
+ | { kind: 'ok'; raw: RawRecord }
364
+ | { kind: 'absent' }
365
+ | { kind: 'corrupt'; path: string; error: string }
366
+
367
+ export type PublicRecordEntry =
368
+ | { kind: 'ok'; raw: RawRecord; liveness: 'offline' | null }
369
+ | { kind: 'absent' }
370
+ | { kind: 'corrupt'; sessionId: string; governed: boolean | null; path: string; error: string; liveness: 'unknown' }
371
+
372
+ export function readRecordEntry(id: string): RecordEntry {
373
+ const path = sessionRecordPath(id)
374
+ let text: string
375
+ try { text = readFileSync(path, 'utf8') }
376
+ catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'absent' }; throw e }
377
+ let raw: unknown
378
+ try { raw = JSON.parse(text) }
379
+ catch (e) { return { kind: 'corrupt', path, error: e instanceof Error ? e.message : String(e) } }
380
+ if (!raw || typeof raw !== 'object' || !(raw as RawRecord).session_id)
381
+ return { kind: 'corrupt', path, error: 'parsed, but carries no session_id — not a session record' }
382
+ return { kind: 'ok', raw: raw as RawRecord }
383
+ }
384
+
385
+ // The ONE public session-record parser. Internal mutation/readiness code uses readRecordEntry's exact raw
386
+ // candidate; every public projection passes through here. A valid pending fence replaces all lifecycle-facing
387
+ // fields with its frozen original and forces offline liveness. Malformed pending bytes remain a present,
388
+ // corrupt/unknown row instead of leaking candidate state or disappearing as absence.
389
+ export function projectPublicRecordEntry(id: string, entry: RecordEntry): PublicRecordEntry {
390
+ if (entry.kind === 'absent') return entry
391
+ if (entry.kind === 'corrupt') return {
392
+ kind: 'corrupt', sessionId: id, governed: null, path: entry.path, error: entry.error, liveness: 'unknown',
393
+ }
394
+ try {
395
+ const original = rawLaunchReadinessOriginal(entry.raw)
396
+ if (!original) return { kind: 'ok', raw: entry.raw, liveness: null }
397
+ return {
398
+ kind: 'ok',
399
+ raw: {
400
+ ...entry.raw,
401
+ status: original.status,
402
+ proposal: original.proposal || null,
403
+ note: original.note || null,
404
+ stopped: original.stopped,
405
+ archived: original.archived,
406
+ cold_proof: original.cold_proof ?? undefined,
407
+ adapter_recovery: original.adapter_recovery ?? undefined,
408
+ launch_readiness_pending: '',
409
+ },
410
+ liveness: 'offline',
411
+ }
412
+ } catch (error) {
413
+ return {
414
+ kind: 'corrupt',
415
+ sessionId: id,
416
+ governed: typeof entry.raw.governed === 'boolean' ? entry.raw.governed : null,
417
+ path: sessionRecordPath(id),
418
+ error: error instanceof Error ? error.message : String(error),
419
+ liveness: 'unknown',
420
+ }
421
+ }
422
+ }
423
+
424
+ export function readPublicRecordEntry(id: string): PublicRecordEntry {
425
+ return projectPublicRecordEntry(id, readRecordEntry(id))
426
+ }
427
+ export function readRawRecord(id: string): RawRecord | null {
428
+ try { const e = readRecordEntry(id); return e.kind === 'ok' ? e.raw : null }
429
+ catch { return null }
430
+ }
431
+ // resolve a possibly-ALIASED session id to its raw record. A codex hook or spawned command can carry the codex
432
+ // THREAD id — payload session_id / CODEX_THREAD_ID — not the SpexCode record id the store is keyed by. Direct id
433
+ // wins; else the one record that captured this id as `harness_session_id` (the backend stored it at thread/start,
434
+ // before any tool turn).
435
+ // Null when neither resolves. Mirrors the shell `hp_store_dir` alias grep — one resolution rule, both layers.
436
+ export function readAliasedRawRecord(id: string): RawRecord | null {
437
+ const e = readAliasedRecordEntry(id)
438
+ return e.kind === 'ok' ? e.raw : null
439
+ }
440
+ // the same alias resolution, keeping the three-way outcome. A CORRUPT record at the direct id settles the
441
+ // question — we found this session and cannot read it; walking on to the alias would report a corrupt record
442
+ // as absent, the exact collapse this type exists to prevent.
443
+ export function readAliasedRecordEntry(id: string): RecordEntry {
444
+ const direct = readRecordEntry(id)
445
+ if (direct.kind !== 'absent') return direct
446
+ // @@@ absence splits in two, and only one half is an alias question - an id owning a store dir is already
447
+ // one of ours (the sentinel-only agent above), so its emptiness is settled; searching would let an unrelated
448
+ // record answer under a live session's own name, and costs a whole-store re-parse per 1s supervisor tick.
449
+ if (existsSync(sessionStoreDir(id))) return { kind: 'absent' }
450
+ for (const sid of listSessionIds()) {
451
+ const r = readRawRecord(sid)
452
+ if (r && r.harness_session_id && r.harness_session_id === id) return { kind: 'ok', raw: r }
453
+ }
454
+ return { kind: 'absent' }
455
+ }
456
+ // every session_id this project has a record for (the board's enumeration source — replaces `git worktree
457
+ // list`). A MISSING store dir means no session ever launched → []. But any OTHER readdir failure THROWS
458
+ // (preserving the fail-loud-enumeration invariant `git worktree list` had): a transient FS error must never
459
+ // read as "every session vanished" — the watch poll skips the tick on a throw, never emitting a false mass-close.
460
+ export function listSessionIds(): string[] {
461
+ let ents
462
+ try { ents = readdirSync(sessionsRoot(), { withFileTypes: true }) }
463
+ catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; throw e }
464
+ return ents.filter((d) => d.isDirectory()).map((d) => d.name)
465
+ }
466
+
467
+ // @@@ branch -> session, so a moved ref can DERIVE its invalidation scope - a session's evaluation depends
468
+ // on its own branch tip, the base branch tip and their merge-base; no other ref participates. Without this
469
+ // map a ref watcher knows only that "something under refs/ moved" and the honest fallback is to invalidate
470
+ // every session ([[taste]] 19). One record read per session, and only when the session store itself moved.
471
+ export function sessionBranchIndex(): Map<string, string> {
472
+ const index = new Map<string, string>()
473
+ for (const id of listSessionIds()) {
474
+ let branch: unknown
475
+ try { branch = readRawRecord(id)?.branch } catch { continue } // a corrupt record narrows nothing
476
+ if (typeof branch === 'string' && branch) index.set(branch, id)
477
+ }
478
+ return index
479
+ }
480
+
481
+ // Retain only completed per-worktree overlays. Interpretation + main tip + HEAD + working signature completely
482
+ // determine the merge-base and projection; a landed main tip therefore dissolves now-moot ops on the next read.
483
+ const deltaCache = new Map<string, { key: string; ops: NodeOp[] }>()
484
+ const safeHead = (p: string): string => { try { return headSha(p) } catch { return '' } }
485
+ let layoutHeadWarned = false
486
+ type LayoutDeltaOutcome = { ops: NodeOp[] } | { error: unknown }
487
+ type LayoutDeltaFlight = {
488
+ promise: Promise<Map<string, LayoutDeltaOutcome>>
489
+ controller: AbortController
490
+ waiters: Set<symbol>
491
+ settled: boolean
492
+ }
493
+ const layoutDeltaFlights = new Map<string, LayoutDeltaFlight>()
494
+
495
+ // One exact public layout generation owns the cold overlay computation. The map is only an in-flight join:
496
+ // the entry is deleted at settlement and deltaCache remains the sole retained result state.
497
+ async function layoutDeltas(paths: string[], main: string, mainRef: string, mainSha: string): Promise<Map<string, LayoutDeltaOutcome>> {
498
+ const snapshots = paths.map((path) => ({ path, head: safeHead(path), sig: worktreeSpecSig(path) }))
499
+ const interpretation = gitInterpretationIdentity(main)
500
+ const flightKey = JSON.stringify([interpretation, mainRef, mainSha, snapshots.map(({ path, head, sig }) => [path, head, sig]).sort((a, b) => a[0].localeCompare(b[0]))])
501
+ let flight = layoutDeltaFlights.get(flightKey)
502
+ if (flight?.controller.signal.aborted) {
503
+ layoutDeltaFlights.delete(flightKey)
504
+ flight = undefined
505
+ }
506
+ if (!flight) {
507
+ const controller = new AbortController()
508
+ const entry: LayoutDeltaFlight = { promise: Promise.resolve(new Map()), controller, waiters: new Set(), settled: false }
509
+ entry.promise = withGitAbortSignal(controller.signal, async () => {
510
+ const outcomes = new Map<string, LayoutDeltaOutcome>()
511
+ const misses: typeof snapshots = []
512
+ for (const snapshot of snapshots) {
513
+ const key = `${interpretation}\0${mainSha}\0${snapshot.head}\0${snapshot.sig}`
514
+ const hit = snapshot.head && mainSha ? deltaCache.get(snapshot.path) : null
515
+ if (hit?.key === key) outcomes.set(snapshot.path, { ops: hit.ops })
516
+ else misses.push(snapshot)
517
+ }
518
+
519
+ const valid = misses.filter(({ head }) => !!head && !!mainSha)
520
+ const batched = await worktreeSpecDeltas(main, mainSha, valid.map(({ path, head }) => ({ path, head })), interpretation)
521
+ for (const snapshot of valid) {
522
+ const outcome = batched.get(snapshot.path) ?? { error: new Error(`layout overlay batch omitted ${snapshot.path}`) }
523
+ if ('error' in outcome) outcomes.set(snapshot.path, outcome)
524
+ else {
525
+ const key = `${interpretation}\0${mainSha}\0${snapshot.head}\0${snapshot.sig}`
526
+ deltaCache.set(snapshot.path, { key, ops: outcome.ops })
527
+ outcomes.set(snapshot.path, { ops: outcome.ops })
528
+ }
529
+ }
530
+
531
+ await Promise.all(misses.filter(({ head }) => !head || !mainSha).map(async (snapshot) => {
532
+ if (!existsSync(snapshot.path)) {
533
+ outcomes.set(snapshot.path, { error: new Error(`worktree ${snapshot.path} is absent`) })
534
+ return
535
+ }
536
+ if (!layoutHeadWarned) {
537
+ layoutHeadWarned = true
538
+ console.warn('spec-cli: layout overlay cache bypassed (unreadable HEAD/main tip), recomputing every read')
539
+ }
540
+ try { outcomes.set(snapshot.path, { ops: await worktreeSpecDelta(snapshot.path, mainRef) }) }
541
+ catch (error) { outcomes.set(snapshot.path, { error }) }
542
+ }))
543
+ return outcomes
544
+ }).finally(() => {
545
+ entry.settled = true
546
+ if (layoutDeltaFlights.get(flightKey) === entry) layoutDeltaFlights.delete(flightKey)
547
+ })
548
+ layoutDeltaFlights.set(flightKey, entry)
549
+ flight = entry
550
+ }
551
+
552
+ const token = Symbol(flightKey)
553
+ const callerSignal = currentGitBuildAbortSignal()
554
+ flight.waiters.add(token)
555
+ let onAbort: (() => void) | null = null
556
+ try {
557
+ if (!callerSignal) return await flight.promise
558
+ if (callerSignal.aborted) throw gitAbortError()
559
+ return await Promise.race([
560
+ flight.promise,
561
+ new Promise<Map<string, LayoutDeltaOutcome>>((_, reject) => {
562
+ onAbort = () => reject(gitAbortError())
563
+ callerSignal.addEventListener('abort', onAbort, { once: true })
564
+ }),
565
+ ])
566
+ } finally {
567
+ if (onAbort) callerSignal?.removeEventListener('abort', onAbort)
568
+ flight.waiters.delete(token)
569
+ if (!flight.settled && flight.waiters.size === 0) flight.controller.abort()
570
+ }
571
+ }
572
+
573
+ export async function resolveLayout(options: { activeSessionIds?: readonly string[] } = {}): Promise<Layout> {
574
+ const root = repoRoot()
575
+ const main = dirname(gitCommonDir()) // the main checkout — same answer from main OR any linked worktree
576
+ const cfg = readConfig(main)
577
+ const base = mainBranch()
578
+ const convention: Convention = {
579
+ main: cfg.main || '',
580
+ mainBranch: base,
581
+ branchPrefix: cfg.branchPrefix ?? 'node/',
582
+ }
583
+ const mainRef = base
584
+ // the board enumerates the GLOBAL per-session store (NOT `git worktree list`): every GOVERNED record this
585
+ // project owns, each carrying the worktree_path its spec-delta is computed from. Non-governed (user-self-
586
+ // launched) records are excluded — board state is a managed-session concern ([[state]]). Each delta is
587
+ // independent → compute (or cache-hit) in parallel, keyed by worktree path as before. guardWorktree wraps
588
+ // each: a worktree whose dir was genuinely removed mid-read (a worker self-merged + retired it) is OMITTED;
589
+ // one that still exists but hit a transient detail failure is kept as a DEGRADED row from the last cached delta.
590
+ const publicEntries = listSessionIds().map((id) => readPublicRecordEntry(id))
591
+ .filter((entry) => entry.kind === 'corrupt' ? entry.governed !== false : entry.kind === 'ok' && entry.raw.governed)
592
+ const records = publicEntries.flatMap((entry) => entry.kind === 'ok' ? [entry] : [])
593
+ const projectedActive = options.activeSessionIds ? new Set(options.activeSessionIds) : null
594
+ const isActive = (record: RawRecord): boolean => projectedActive
595
+ ? projectedActive.has(record.session_id)
596
+ : !record.archived
597
+ // main's tip, resolved ONCE per board read — a component of every worktree's overlay cache key
598
+ // ([[worktree-linker]]: landed content must dissolve the ops it made moot).
599
+ const mainSha = await (async () => {
600
+ try { return (await gitA(['-C', main, 'rev-parse', '--verify', `${mainRef}^{commit}`])).trim() } catch { return '' }
601
+ })()
602
+ const activePaths = records.filter(({ raw }) => isActive(raw)).map(({ raw }) => raw.worktree_path)
603
+ const deltas = await layoutDeltas(activePaths, main, mainRef, mainSha)
604
+ const rows = await Promise.all(records.map(({ raw: r, liveness }) => {
605
+ const node = r.node ?? (r.branch && r.branch.startsWith(convention.branchPrefix) ? r.branch.slice(convention.branchPrefix.length) : null)
606
+ const base: Worktree = { path: r.worktree_path, branch: r.branch, node, session: r.session_id, status: r.status, isMain: false, ...(liveness ? { liveness } : {}), ops: [] }
607
+ // @@@ projected shelves cost nothing - a cold archived session ([[archive]]) keeps its record but leaves
608
+ // the working-set projection and skips the per-worktree spec delta. An archived runtime hazard is still
609
+ // projected active by listSessions, so it deliberately retains the same ops in both full and splice builds.
610
+ if (!isActive(r)) return Promise.resolve(base)
611
+ return guardWorktree<Worktree>(r.worktree_path,
612
+ (): Worktree => {
613
+ const outcome = deltas.get(r.worktree_path)
614
+ if (!outcome) throw new Error(`layout overlay missing ${r.worktree_path}`)
615
+ if ('error' in outcome) throw outcome.error
616
+ return { ...base, ops: outcome.ops }
617
+ },
618
+ (): Worktree => {
619
+ const cached = deltaCache.get(r.worktree_path)
620
+ if (!cached) throw new Error(`layout overlay failed before ${r.worktree_path} had a last-known result`)
621
+ return { ...base, ops: cached.ops }
622
+ })
623
+ }))
624
+ const corruptRows: Worktree[] = publicEntries.flatMap((entry) => entry.kind === 'corrupt'
625
+ ? [{ path: '', branch: null, node: null, session: entry.sessionId, status: 'corrupt', liveness: 'unknown', isMain: false, ops: [] }]
626
+ : [])
627
+ const sessionWorktrees = [...rows.filter((w): w is Worktree => w !== null), ...corruptRows]
628
+ // the main checkout row (isMain) — always present, carries no overlay; it anchors the merged tree the board draws.
629
+ const mainRow: Worktree = { path: main, branch: base, node: null, session: null, status: null, isMain: true, ops: [] }
630
+ const worktrees = [mainRow, ...sessionWorktrees]
631
+ // drop cache entries for worktrees that may no longer hold one — closed sessions (gone from the store) AND
632
+ // newly-cold archived ones (which no longer compute a delta), so archiving SELF-EVICTS its cached ops instead of
633
+ // stranding them in a map nothing prunes.
634
+ const live = new Set(records.filter(({ raw }) => isActive(raw)).map(({ raw }) => raw.worktree_path))
635
+ for (const k of [...deltaCache.keys()]) if (!live.has(k)) deltaCache.delete(k)
636
+ return { main: convention.main || main || root, convention, worktrees }
637
+ }