spexcode 0.5.9 → 0.6.1

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.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/cli.ts +69 -70
  3. package/spec-cli/src/client.ts +1 -1
  4. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  5. package/spec-cli/src/commit-surgery.ts +2 -1
  6. package/spec-cli/src/contract-filter.ts +58 -42
  7. package/spec-cli/src/delivery-queue.ts +107 -0
  8. package/spec-cli/src/doctor.ts +2 -1
  9. package/spec-cli/src/file-write.ts +22 -0
  10. package/spec-cli/src/git.ts +30 -8
  11. package/spec-cli/src/graphStream.ts +79 -11
  12. package/spec-cli/src/harness.ts +12 -10
  13. package/spec-cli/src/help.ts +9 -14
  14. package/spec-cli/src/index.ts +10 -2
  15. package/spec-cli/src/init.ts +8 -17
  16. package/spec-cli/src/layout.ts +35 -7
  17. package/spec-cli/src/materialize.ts +150 -107
  18. package/spec-cli/src/plugin-harness.ts +19 -8
  19. package/spec-cli/src/process-identity.ts +13 -0
  20. package/spec-cli/src/reviews.ts +55 -11
  21. package/spec-cli/src/session-cursors.ts +11 -17
  22. package/spec-cli/src/session-follow.ts +6 -6
  23. package/spec-cli/src/sessions.ts +224 -38
  24. package/spec-cli/src/uninstall.ts +2 -1
  25. package/spec-cli/templates/hooks/post-checkout +3 -0
  26. package/spec-cli/templates/hooks/post-merge +1 -0
  27. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  28. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +5 -41
  29. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +1 -1
  30. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  31. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  32. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
  33. package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
  34. package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
  35. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
  36. package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
  37. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
  38. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
  39. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
  40. package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
  41. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
  42. package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
  43. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
  44. package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
  45. package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
  46. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
  47. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
  48. package/spec-dashboard/dist/index.html +2 -2
  49. package/spec-dashboard/src/reviewFilters.js +5 -0
  50. package/spec-dashboard/src/session.js +5 -1
  51. package/spec-eval/src/cli.ts +7 -8
  52. package/spec-eval/src/evaltab.ts +25 -5
  53. package/spec-eval/src/freshness.ts +52 -8
  54. package/spec-eval/src/scenariofresh.ts +58 -8
  55. package/spec-eval/src/scenarios.ts +60 -12
  56. package/spec-eval/src/sessioneval.ts +122 -30
  57. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  58. package/spec-eval/src/matrix.ts +0 -693
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.5.9",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -161,28 +161,13 @@ async function followKit(selectors: string[], verb: string): Promise<{
161
161
  return { watcher, follow: (emit, opts) => followSessions(emit, { ...opts, targets, self: watcher, row }) }
162
162
  }
163
163
 
164
- const greeted = new Set<string>()
165
- // Starting a STREAM follow on named live sessions announces itself ([[session-follow]]): one appended line per
166
- // target per process, so a stream never re-nags and a one-shot `wait` never announces at all. The append is the
167
- // delivery, taken locally, so the announce needs no backend either.
168
- async function greetWatchTargets(watcher: string, selectors: string[]): Promise<void> {
169
- try {
170
- const real = selectors.filter((sel) => sel && sel !== '@all')
171
- if (!real.length) return
172
- const { localCachedSessions } = await import('./client.js')
173
- const { selectSessions, sendText, sessionHeadline } = await import('./sessions.js')
174
- const board = localCachedSessions(true)
175
- // name the watcher by its board HEADLINE (same as the reply-channel footer), delimited as a session title.
176
- const mine = board.find((s) => s.id === watcher)
177
- const me = mine ? sessionHeadline(mine) : watcher
178
- const meWho = me && me !== watcher ? `session "${me}" (${watcher})` : `session ${watcher}`
179
- for (const target of selectSessions(board, real).map((s) => s.id)) {
180
- if (target === watcher || greeted.has(target)) continue
181
- greeted.add(target)
182
- const text = `🔭 ${meWho} is now supervising you — they started \`spex session watch\` over this session. To reach them directly, run: spex session send ${watcher} "<your message>". (One-time heads-up; reply only if you need to.)`
183
- void sendText(target, text) // no sender id → the connection notice is not double-counted as comms
184
- }
185
- } catch { /* greeting is best-effort — it must never disturb the watch */ }
164
+ async function localWatchTargetsOrExit(selectors: string[], verb: string): Promise<string[]> {
165
+ if (!selectors.length) { console.error(`usage: ${verb} <SEL...>`); process.exit(2) }
166
+ const { localCachedSessions } = await import('./client.js')
167
+ const { selectSessions } = await import('./sessions.js')
168
+ const targets = selectSessions(localCachedSessions(true), selectors).map((session) => session.id)
169
+ if (!targets.length) { console.error(`${verb}: no such local session: ${selectors.join(' ')}`); process.exit(2) }
170
+ return targets
186
171
  }
187
172
 
188
173
  async function resolveSelectorOrExit(selector: string): Promise<string> {
@@ -545,12 +530,12 @@ if (cmd === 'serve') {
545
530
  const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
546
531
  console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
547
532
  }
548
- } else if (['add', 'ls', 'scenario', 'matrix', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
533
+ } else if (['add', 'ls', 'scenario', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
549
534
  // node-scoped verbs — thin route; the logic lives in spec-eval.
550
535
  const { runEval } = await import('../../spec-eval/src/cli.js')
551
536
  await flushExit(await runEval(process.argv.slice(3)))
552
537
  } else {
553
- console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls/write | matrix | lint | ok | retract | clean (spex help eval)`)
538
+ console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls/write | lint | ok | retract | clean (spex help eval)`)
554
539
  if (!sub.startsWith('--')) console.error(` (the old \`spex eval <SEL>\` session read is now \`spex eval ls --session <SEL>\` [--export])`) // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
555
540
  process.exit(2)
556
541
  }
@@ -626,8 +611,18 @@ if (cmd === 'serve') {
626
611
  if (!prompt.trim()) { console.error(`spex session new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
627
612
  }
628
613
  const created = await createSession(prompt, flag('launcher') ?? undefined)
614
+ const { ownSessionId, subscribeSessionWatch } = await import('./sessions.js')
615
+ let watchEstablished = false
616
+ if (created.parent && created.parent === ownSessionId()) {
617
+ try {
618
+ await subscribeSessionWatch(created.parent, [created.id])
619
+ watchEstablished = true
620
+ } catch (error) {
621
+ console.error(`spex session new: child ${created.id} was created, but its managed watch was not established: ${error instanceof Error ? error.message : String(error)}`)
622
+ }
623
+ }
629
624
  console.log(JSON.stringify(created, null, 2))
630
- console.error((await import('./help.js')).sessionLaunchReceipt(created.id))
625
+ console.error((await import('./help.js')).sessionLaunchReceipt(created.id, watchEstablished))
631
626
  } else if (sub === 'ls') {
632
627
  // pretty list of living sessions + states. `spex session ls [SEL...] [--status a,b] [--json]`
633
628
  // the board comes from the backend (so it shows the sessions of whatever SPEXCODE_API_URL points at,
@@ -648,19 +643,39 @@ if (cmd === 'serve') {
648
643
  if (has('json')) console.log(JSON.stringify(report, null, 2))
649
644
  else console.log((await import('./host-resources.js')).formatResourceReport(report))
650
645
  } else if (sub === 'watch') {
651
- const selectors = positionals(4)
652
- const kit = await followKit(selectors, 'spex session watch')
653
- if (kit.watcher) void greetWatchTargets(kit.watcher, selectors)
654
- await kit.follow((line) => console.log(line), {
655
- statuses: flag('status')?.split(','),
656
- includeIdle: has('idle'),
657
- as: flag('as'),
658
- intervalMs: (Number(flag('interval')) || 1) * 1000,
659
- })
646
+ const [verb, ...rest] = positionals(4)
647
+ const { ownSessionId, subscribeSessionWatch, listSessionWatches, cancelSessionWatch } = await import('./sessions.js')
648
+ const watcher = ownSessionId()
649
+ if (verb === 'stream') {
650
+ const kit = await followKit(rest, 'spex session watch stream')
651
+ await kit.follow((line) => console.log(line), {
652
+ statuses: flag('status')?.split(','),
653
+ includeIdle: has('idle'),
654
+ as: flag('as'),
655
+ intervalMs: (Number(flag('interval')) || 1) * 1000,
656
+ })
657
+ } else if (verb === 'list') {
658
+ if (!watcher) { console.error('spex session watch list: no governed caller session — this caller has no durable watch relation'); process.exit(2) }
659
+ for (const watch of listSessionWatches(watcher)) console.log(`${watch.target}\t${watch.createdAt}`)
660
+ } else if (verb === 'cancel') {
661
+ if (!watcher) { console.error(`spex session watch cancel: no governed caller session — run \`spex session wait ${rest.join(' ')}\` in the background instead`); process.exit(2) }
662
+ const targets = await localWatchTargetsOrExit(rest, 'spex session watch cancel')
663
+ console.log(`cancelled ${cancelSessionWatch(watcher, targets)} watch${targets.length === 1 ? '' : 'es'}`)
664
+ } else {
665
+ const selectors = [verb, ...rest].filter(Boolean)
666
+ if (!watcher) {
667
+ if (!selectors.length) { console.error('usage: spex session watch <SEL...>'); process.exit(2) }
668
+ console.error(`spex session watch: no governed caller session — run \`spex session wait ${selectors.join(' ')}\` in the BACKGROUND to receive the next actionable transition`)
669
+ } else {
670
+ const targets = await localWatchTargetsOrExit(selectors, 'spex session watch')
671
+ const result = await subscribeSessionWatch(watcher, targets)
672
+ console.log(`watching ${result.watched.join(' ')}`)
673
+ }
674
+ }
660
675
  } else if (sub === 'wait') {
661
676
  const selectors = positionals(4)
662
677
  const kit = await followKit(selectors, 'spex session wait')
663
- const named = selectors.join(' ') || 'your inbox'
678
+ const named = selectors.join(' ') || 'your own log'
664
679
  // point-of-use turn-freeze warning ([[session-follow]]): a managed agent that runs this wait in the FOREGROUND
665
680
  // freezes its whole turn until the target produces an edge — a warning that used to live only in help
666
681
  // prose, now said where it matters. Foreground vs background is invisible from here, so the hint prints
@@ -923,25 +938,31 @@ if (cmd === 'serve') {
923
938
  delete env.SPEXCODE_SESSION_ID
924
939
  const runtime = await spawnDetachedRuntime({ cwd, logFile, pidFile, receiptFile, command, args, env })
925
940
  console.log(runtime.pid)
926
- } else if (sub === 'codex-generation-current') {
927
- const [root, command] = process.argv.slice(4, 6)
928
- if (!root || !command) { console.error('usage: spex internal codex-generation-current <runtime-root> <codex-command>'); process.exit(2) }
929
- const { ensureCodexCurrentGeneration } = await import('./codex-runtime-generations.js')
941
+ } else if (sub === 'codex-generation-current' || sub === 'codex-generation-session') {
942
+ // Both launch spellings resolve ONE endpoint and print it as shell assignments. Starting a root is the same
943
+ // detached spawn either way a session identity must never ride into the shared, project-scoped daemon.
944
+ const resume = sub === 'codex-generation-session'
945
+ const [root, ...rest] = process.argv.slice(4)
946
+ const [sessionId, threadId, sessionCommand] = rest
947
+ const command = resume ? sessionCommand : rest[0]
948
+ if (!root || !command || (resume && (!sessionId || !threadId))) {
949
+ console.error(resume
950
+ ? 'usage: spex internal codex-generation-session <runtime-root> <session-id> <thread-id> <codex-command>'
951
+ : 'usage: spex internal codex-generation-current <runtime-root> <codex-command>')
952
+ process.exit(2)
953
+ }
954
+ const { ensureCodexCurrentGeneration, resolveCodexGenerationForResume } = await import('./codex-runtime-generations.js')
930
955
  const { spawnDetachedRuntime } = await import('./runtime-ownership.js')
931
956
  const { sessionIdentityEnvVars } = await import('./harness.js')
932
957
  const env = { ...process.env }
933
958
  for (const key of sessionIdentityEnvVars()) delete env[key]
934
- const endpoint = await ensureCodexCurrentGeneration(root, async (candidate) => {
959
+ const start = async (candidate: { logFile: string; pidFile: string; receiptFile: string; socketPath: string }) => {
935
960
  await spawnDetachedRuntime({ cwd: root, logFile: candidate.logFile, pidFile: candidate.pidFile, receiptFile: candidate.receiptFile,
936
961
  command, args: ['app-server', '--listen', `unix://${candidate.socketPath}`], env })
937
- })
938
- const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
939
- console.log(`sock=${quote(endpoint.socketPath)}; pid=${quote(endpoint.pidFile)}; receipt=${quote(endpoint.receiptFile)}; log=${quote(endpoint.logFile)}; export SPEXCODE_CODEX_GENERATION=${quote(endpoint.id)}`)
940
- } else if (sub === 'codex-generation-session') {
941
- const [root, sessionId, threadId] = process.argv.slice(4, 7)
942
- if (!root || !sessionId || !threadId) { console.error('usage: spex internal codex-generation-session <runtime-root> <session-id> <thread-id>'); process.exit(2) }
943
- const { resolveCodexGenerationForSession } = await import('./codex-runtime-generations.js')
944
- const endpoint = resolveCodexGenerationForSession(root, sessionId, threadId)
962
+ }
963
+ const endpoint = resume
964
+ ? await resolveCodexGenerationForResume(root, sessionId!, threadId!, start)
965
+ : await ensureCodexCurrentGeneration(root, start)
945
966
  if (!endpoint) { console.error(`no exact Codex generation binding for session ${sessionId} thread ${threadId}`); process.exit(1) }
946
967
  const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
947
968
  console.log(`sock=${quote(endpoint.socketPath)}; pid=${quote(endpoint.pidFile)}; receipt=${quote(endpoint.receiptFile)}; log=${quote(endpoint.logFile)}; export SPEXCODE_CODEX_GENERATION=${quote(endpoint.id)}`)
@@ -1043,28 +1064,6 @@ if (cmd === 'serve') {
1043
1064
  const st = process.argv[4] as any
1044
1065
  const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
1045
1066
  console.log(ok.ok ? `state -> ${st}${noteEcho(flag('note'))}` : ok.reason ?? noRecord())
1046
- } else if (sub === 'session-cursor') {
1047
- // the turn-boundary mail reader advances its own inbox cursor here ([[session-cursors]]) — the same
1048
- // one-writer discipline as session-state: shell reads the file, it never rewrites it, so a follower's
1049
- // entries in the same file cannot be clobbered by a partial shell write.
1050
- const to = Number(flag('to'))
1051
- const sess = flag('session')
1052
- if (process.argv[4] !== 'inbox' || !sess || !Number.isFinite(to)) {
1053
- console.error('usage: spex internal session-cursor inbox --session <id> --to <event-index>')
1054
- process.exit(2)
1055
- }
1056
- const { advanceInbox, inboxCursor } = await import('./session-cursors.js')
1057
- const { readAliasedRawRecord } = await import('./layout.js')
1058
- // the hook may address a codex THREAD id; the cursor file is keyed by the record id, so resolve the alias
1059
- // through the one seam that owns that rule.
1060
- const record = readAliasedRawRecord(sess)
1061
- if (!record) console.log('noop (no session record)')
1062
- else {
1063
- advanceInbox(record.session_id, to)
1064
- // report where the cursor ACTUALLY is: advancing is monotonic, so a lower offer is ignored, and
1065
- // echoing the request back would confirm a move that did not happen.
1066
- console.log(`inbox -> ${inboxCursor(record.session_id)}`)
1067
- }
1068
1067
  } else if (sub === 'session-fail') {
1069
1068
  // StopFailure is one native source for the shared active-only turn-failure CAS. A declaration or explicit
1070
1069
  // stop that landed first is authoritative, just as it is for Codex notifications and headless exits.
@@ -41,7 +41,7 @@ function cachedStatus(rec: ReturnType<typeof fromRaw>): DisplayStatus {
41
41
  function corruptCachedSession(id: string, reason: string): Session {
42
42
  const label = `${id.slice(0, 8)} (unreadable record)`
43
43
  return {
44
- id, node: null, branch: null, path: '', label, headline: label, raw: { name: null, title: null },
44
+ id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
45
45
  parent: null, harness: 'unknown', capabilities: { headless: false }, launcher: null,
46
46
  lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
47
47
  note: `session record is unreadable: ${reason}`, archived: false, archiveHazard: null,
@@ -3,7 +3,7 @@ import { createConnection, type Socket } from 'node:net'
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
- import { processStartToken, verifyDetachedRuntime } from './process-identity.js'
6
+ import { detachedRuntimeIsGone, processStartToken, verifyDetachedRuntime } from './process-identity.js'
7
7
 
8
8
  export type CodexGenerationEndpoint = Readonly<{
9
9
  id: string
@@ -271,6 +271,32 @@ function hasLegacyResidue(root: string): boolean {
271
271
  return [endpoint.pidFile, endpoint.receiptFile, endpoint.socketPath].some(existsSync)
272
272
  }
273
273
 
274
+ // A generation whose recorded process is provably gone — the host restarted, the OOM killer fired, a /tmp sweep
275
+ // took the socket with it. Death is a POSITIVE fact, not the absence of proof: such a root holds no threads, no
276
+ // peers, and no protective references, so nothing it once carried can be lost by retiring it. Everything else
277
+ // that fails `endpointIdentity` is AMBIGUOUS — a live process we can no longer address — and keeps its root.
278
+ function goneGeneration(endpoint: CodexGenerationEndpoint): boolean {
279
+ if (endpointIdentity(endpoint)) return false
280
+ let pid: number
281
+ try { pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim()) }
282
+ catch { return false }
283
+ return Number.isInteger(pid) && pid > 0 && detachedRuntimeIsGone(pid, endpoint.receiptFile)
284
+ }
285
+
286
+ // Retire a gone generation inside the caller's ledger lock, returning the published ledger (null when the
287
+ // generation is still there to serve or merely unaddressable). Its bindings deliberately survive: a session's
288
+ // conversation lives in Codex's on-disk rollout, so those rows are stale pointers to re-pin, not lost threads.
289
+ function retireGoneGenerationLocked(root: string, previous: CodexGenerationLedger, id: string): CodexGenerationLedger | null {
290
+ const generation = previous.generations[id]
291
+ if (!generation || generation.state === 'reclaimed' || !goneGeneration(generation.endpoint)) return null
292
+ return writeLedger(root, previous, {
293
+ current: previous.current === id ? null : previous.current,
294
+ pending: previous.pending === id ? null : previous.pending,
295
+ generations: { ...previous.generations, [id]: { state: 'reclaimed', endpoint: generation.endpoint } },
296
+ bindings: previous.bindings,
297
+ })
298
+ }
299
+
274
300
  function bootstrapBindings(root: string, generationId: string): Record<string, CodexGenerationBinding> {
275
301
  const result: Record<string, CodexGenerationBinding> = {}
276
302
  const sessions = join(root, 'sessions')
@@ -305,13 +331,18 @@ function bindingProtectsGeneration(root: string, sessionId: string, binding: Cod
305
331
  function bootstrapLedger(root: string): CodexGenerationLedger {
306
332
  const legacy = legacyCodexGenerationEndpoint(root)
307
333
  if (!hasLegacyResidue(root)) return emptyLedger()
308
- if (!endpointIdentity(legacy)) throw new Error('legacy Codex root is present but its exact detached PID/start/receipt/socket identity is unproven')
334
+ // Residue left by a legacy root that has since died enters the ledger already reclaimed rather than blocking
335
+ // the bootstrap: it has nothing to drain. Its governed bindings are still recorded so those sessions resolve
336
+ // to a stale pointer they can re-pin, instead of losing the thread they own.
337
+ const gone = goneGeneration(legacy)
338
+ if (!gone && !endpointIdentity(legacy))
339
+ throw new Error('legacy Codex root is present but its exact detached PID/start/receipt/socket identity is unproven')
309
340
  return {
310
341
  version: 3,
311
342
  revision: 0,
312
343
  current: null,
313
344
  pending: null,
314
- generations: { [legacy.id]: { state: 'draining', endpoint: legacy } },
345
+ generations: { [legacy.id]: { state: gone ? 'reclaimed' : 'draining', endpoint: legacy } },
315
346
  bindings: bootstrapBindings(root, legacy.id),
316
347
  }
317
348
  }
@@ -373,9 +404,13 @@ export async function ensureCodexCurrentGeneration(root: string, start: (endpoin
373
404
  }
374
405
  if (previous.current) {
375
406
  const generation = previous.generations[previous.current]
376
- if (!generation || generation.state !== 'current' || !endpointIdentity(generation.endpoint))
377
- throw new Error('canonical Codex generation is missing, replaced, or unproven; refusing to route new traffic')
378
- return { kind: 'current', endpoint: generation.endpoint }
407
+ if (generation && generation.state === 'current' && endpointIdentity(generation.endpoint))
408
+ return { kind: 'current', endpoint: generation.endpoint }
409
+ // A canonical root that is provably gone is retired here and replaced by the rest of this same call:
410
+ // refusing forever would make one host restart permanently un-routable. Anything else stays a refusal.
411
+ const healed = retireGoneGenerationLocked(root, previous, previous.current)
412
+ if (!healed) throw new Error('canonical Codex generation is missing, replaced, or unproven; refusing to route new traffic')
413
+ previous = healed
379
414
  }
380
415
  if (previous.pending) {
381
416
  const pending = previous.generations[previous.pending]
@@ -491,12 +526,67 @@ export function prepareCodexGenerationClose(root: string, sessionId: string, thr
491
526
  if (!binding || binding.threadId !== threadId) throw new Error(`Codex session ${sessionId} has no exact generation binding to close`)
492
527
  if (binding.phase === 'record-removing') return
493
528
  const generation = previous.generations[binding.generationId]
494
- if (!generation || generation.state === 'reclaimed') throw new Error(`Codex session ${sessionId} binding names an absent or reclaimed generation`)
529
+ // A retired root can neither route this thread nor be pinned by it, so there is nothing for the removal
530
+ // marker to protect: drop the binding outright and let close proceed. An ABSENT generation is corruption.
531
+ if (!generation) throw new Error(`Codex session ${sessionId} binding names an absent generation`)
532
+ if (generation.state === 'reclaimed') {
533
+ const remaining = { ...previous.bindings }
534
+ delete remaining[sessionId]
535
+ writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings: remaining })
536
+ return
537
+ }
495
538
  const bindings = { ...previous.bindings, [sessionId]: { ...binding, phase: 'record-removing' as const } }
496
539
  writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
497
540
  })
498
541
  }
499
542
 
543
+ // Re-pin an existing exact binding onto another live generation. The transaction phase is carried over, so a
544
+ // registration or close that is mid-flight keeps its crash boundary while its route is corrected.
545
+ export function repinCodexGeneration(root: string, sessionId: string, threadId: string, generationId: string): void {
546
+ withLedgerLockSync(root, () => {
547
+ const previous = readCodexGenerationLedger(root)
548
+ const binding = previous.bindings[sessionId]
549
+ if (!binding || binding.threadId !== threadId) throw new Error(`Codex session ${sessionId} has no exact binding to re-pin`)
550
+ const generation = previous.generations[generationId]
551
+ if (!generation || generation.state === 'reclaimed') throw new Error(`Codex generation ${generationId} is absent or reclaimed`)
552
+ if (binding.generationId === generationId) return
553
+ const bindings = { ...previous.bindings, [sessionId]: { ...binding, generationId } }
554
+ writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
555
+ })
556
+ }
557
+
558
+ // The resume boundary. A binding to a LIVE generation still routes only there — an existing conversation is
559
+ // never moved out from under the client that holds it. But a binding whose root is gone names a process, not a
560
+ // conversation: the thread's durable home is its on-disk rollout, which any generation can load. So the gone
561
+ // root is retired and the session is re-pinned to the canonical one, which loads that same rollout. Missing,
562
+ // mismatched, ambiguous, or unprotected bindings remain refusals — resume never invents a route.
563
+ export async function resolveCodexGenerationForResume(
564
+ root: string,
565
+ sessionId: string,
566
+ threadId: string,
567
+ start: (endpoint: CodexGenerationEndpoint) => Promise<void>,
568
+ ): Promise<CodexGenerationEndpoint | null> {
569
+ const ledger = readCodexGenerationLedger(root)
570
+ const binding = ledger.bindings[sessionId]
571
+ if (!binding || binding.threadId !== threadId || !bindingProtectsGeneration(root, sessionId, binding)) return null
572
+ const generation = ledger.generations[binding.generationId]
573
+ if (!generation) return null
574
+ if (generation.state !== 'reclaimed') {
575
+ if (endpointIdentity(generation.endpoint)) return generation.endpoint
576
+ const retired = await withLedgerLock(root, async () => {
577
+ const previous = readCodexGenerationLedger(root)
578
+ if (previous.bindings[sessionId]?.generationId !== binding.generationId) return false
579
+ const observed = previous.generations[binding.generationId]
580
+ if (!observed) return false
581
+ return observed.state === 'reclaimed' || !!retireGoneGenerationLocked(root, previous, binding.generationId)
582
+ })
583
+ if (!retired) return null
584
+ }
585
+ const current = await ensureCodexCurrentGeneration(root, start)
586
+ repinCodexGeneration(root, sessionId, threadId, current.id)
587
+ return current
588
+ }
589
+
500
590
  export function resolveCodexGenerationForSession(root: string, sessionId: string, threadId: string): CodexGenerationEndpoint | null {
501
591
  const ledger = readCodexGenerationLedger(root)
502
592
  const binding = ledger.bindings[sessionId]
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'
2
2
  import { relative } from 'node:path'
3
3
  import { materialize, stripSpexcodeBlock, GENERATED_MARK } from './materialize.js'
4
4
  import { HARNESSES } from './harness.js'
5
+ import { gitBinary } from './git.js'
5
6
 
6
7
  // GIT ENV, deliberately INVERTED from git.ts's git(): every call here PRESERVES the hook's environment —
7
8
  // GIT_INDEX_FILE must be honored so the surgery reads/writes the EXACT index this commit is being built
@@ -9,7 +10,7 @@ import { HARNESSES } from './harness.js'
9
10
  // operating on the real one would silently miss them). git.ts strips that env for repo DISCOVERY reasons;
10
11
  // index surgery is the one place the env is the point.
11
12
  const raw = (args: string[], input?: string): string =>
12
- execFileSync('git', args, { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
13
+ execFileSync(gitBinary(process.env), args, { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
13
14
 
14
15
  function inHead(p: string): boolean {
15
16
  try { raw(['cat-file', '-e', `HEAD:${p}`]); return true } catch { return false }
@@ -1,9 +1,10 @@
1
- import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, rmSync, chmodSync, existsSync, readdirSync, statSync } from 'node:fs'
2
2
  import { join, relative } from 'node:path'
3
3
  import { execFileSync } from 'node:child_process'
4
- import { git } from './git.js'
4
+ import { git, gitBinary } from './git.js'
5
5
  import { writeManagedBlock, removeManagedBlock } from './harness.js'
6
6
  import { encodeProject, runtimeRoot, treeSlotDir } from './layout.js'
7
+ import { writeFileIfChanged } from './file-write.js'
7
8
 
8
9
  // the three field-sharpened edges this module owes ([[content-filter]]):
9
10
  // ① the configured command points at a STABLE shim path and degrades to `cat` (identity) when the shim is
@@ -20,20 +21,13 @@ function commonDirOf(proj: string): string {
20
21
  }
21
22
  const filterDir = (common: string) => join(common, 'spexcode')
22
23
  const shimPath = (common: string) => join(filterDir(common), 'contract-filter.sh')
23
- const blockPath = (common: string) => join(filterDir(common), 'contract-block.md')
24
24
  const rootPath = (common: string) => join(filterDir(common), 'contract-filter-root')
25
25
  const bindingsPath = (common: string) => join(filterDir(common), 'contract-filter-bindings')
26
26
  const treeFilterDir = (proj: string) => join(treeSlotDir(proj), 'contract-filter')
27
27
  const attributesPath = (common: string) => join(common, 'info', 'attributes')
28
28
 
29
- const registeredSlots = (proj: string) => git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
30
- .filter((row) => row.startsWith('worktree ')).map((row) =>
31
- join(runtimeRoot(proj), 'trees', encodeProject(row.slice('worktree '.length))))
32
- const legacyFilterTree = (proj: string) => registeredSlots(proj)
33
- .some((slot) => existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, 'contract-filter-v2')))
34
-
35
29
  export type ContractFilterPayload = { file: string; content: string }
36
- export type ContractFilterBinding = { file: string; start: string; end: string; legacy?: boolean }
30
+ export type ContractFilterBinding = { file: string; start: string; end: string }
37
31
 
38
32
  // The common shim both filter directions run through. Pure shell/awk (no node boot on git's hot path), it
39
33
  // resolves the invoking checkout to that tree's payload before mirroring managed-block normalization:
@@ -48,19 +42,15 @@ set -u
48
42
  mode="\${1:?usage: contract-filter.sh smudge|clean <path>}"
49
43
  path="\${2:?usage: contract-filter.sh smudge|clean <path>}"
50
44
  here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
51
- binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3 "\t" $4; exit }' "$here/contract-filter-bindings" 2>/dev/null)"
45
+ binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3; exit }' "$here/contract-filter-bindings" 2>/dev/null)"
52
46
  [ -n "$binding" ] || { cat; exit 0; }
53
47
  start="\${binding%%$'\t'*}"; rest="\${binding#*$'\t'}"
54
- end="\${rest%%$'\t'*}"; legacy="\${rest#*$'\t'}"
48
+ end="$rest"
55
49
  top="$(git rev-parse --show-toplevel 2>/dev/null || true)"
56
50
  root="$(cat "$here/contract-filter-root" 2>/dev/null || true)"
57
51
  key="$(printf '%s' "$top" | sed 's#[/.]#-#g')"
58
52
  manifest="$root/trees/$key/contract-filter/manifest"
59
53
  payload="$(awk -F '\t' -v p="$path" '$1 == p { print $2; exit }' "$manifest" 2>/dev/null)"
60
- # A pre-v2 tree has only the old common payload. Once that tree materializes, its marker makes a missing
61
- # per-file payload mean identity (for example, AGENTS.md in a Claude-only tree), never global fallback.
62
- marker="$root/trees/$key/contract-filter-v2"
63
- if [ ! -r "$payload" ] && [ ! -f "$marker" ] && [ "$legacy" = 1 ] && [ -r "$here/contract-block.md" ]; then payload="$here/contract-block.md"; fi
64
54
  strip() {
65
55
  awk -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
66
56
  { lines[n++] = $0 }
@@ -105,33 +95,54 @@ const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
105
95
 
106
96
  // plant (or refresh) the filter for the given contract files (tracked, or untracked-with-host-content —
107
97
  // pre-armed): the shim + the block content it smudges, the per-clone git config, and the attribute lines
108
- // binding each file to the filter. Idempotent — every write is a full replace. `contract` is the assembled
109
- // block body (guide + surface:system). settleIndexStat skips untracked entries (no index blob) by design.
110
- export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[]): void {
98
+ // binding each file to the filter. `contract` is the assembled block body (guide + surface:system).
99
+ // settleIndexStat skips untracked entries (no index blob) by design and only runs after a real filter change.
100
+ export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[], changedFiles: readonly string[] = []): boolean {
111
101
  const common = commonDirOf(proj)
112
102
  mkdirSync(filterDir(common), { recursive: true })
113
- writeFileSync(shimPath(common), SHIM)
114
- chmodSync(shimPath(common), 0o755)
115
- git(['-C', proj, 'config', 'filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')])
116
- git(['-C', proj, 'config', 'filter.spexcode.clean', filterCmd(shimPath(common), 'clean')])
117
- writeFileSync(bindingsPath(common), bindings.map((b) => `${b.file}\t${b.start}\t${b.end}\t${b.legacy ? 1 : 0}`).join('\n') + '\n')
103
+ let changed = writeFileIfChanged(shimPath(common), SHIM)
104
+ if (changed || (statSync(shimPath(common)).mode & 0o777) !== 0o755) chmodSync(shimPath(common), 0o755)
105
+ const commands = new Map([
106
+ ['filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')],
107
+ ['filter.spexcode.clean', filterCmd(shimPath(common), 'clean')],
108
+ ])
109
+ const configured = new Map<string, string>()
110
+ try {
111
+ for (const row of git(['-C', proj, 'config', '--get-regexp', '^filter\\.spexcode\\.(smudge|clean)$']).trimEnd().split('\n')) {
112
+ const at = row.indexOf(' ')
113
+ if (at > 0) configured.set(row.slice(0, at), row.slice(at + 1))
114
+ }
115
+ } catch {}
116
+ for (const [key, command] of commands) {
117
+ if (configured.get(key) === command) continue
118
+ git(['-C', proj, 'config', key, command])
119
+ changed = true
120
+ }
121
+ changed = writeFileIfChanged(bindingsPath(common), bindings.map((b) => `${b.file}\t${b.start}\t${b.end}`).join('\n') + '\n') || changed
118
122
  const dir = treeFilterDir(proj)
119
- rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true })
123
+ mkdirSync(dir, { recursive: true })
120
124
  const manifest: string[] = []
125
+ const wanted = new Set(['manifest'])
121
126
  for (const [i, payload] of payloads.entries()) {
122
127
  const target = join(dir, String(i))
123
- writeFileSync(target, payload.content.endsWith('\n') ? payload.content : `${payload.content}\n`)
128
+ wanted.add(String(i))
129
+ changed = writeFileIfChanged(target, payload.content.endsWith('\n') ? payload.content : `${payload.content}\n`) || changed
124
130
  manifest.push(`${payload.file}\t${target}`)
125
131
  }
132
+ for (const entry of readdirSync(dir)) {
133
+ if (!wanted.has(entry)) { rmSync(join(dir, entry), { recursive: true, force: true }); changed = true }
134
+ }
126
135
  const manifestPath = join(dir, 'manifest')
127
- writeFileSync(manifestPath, manifest.join('\n') + (manifest.length ? '\n' : ''))
128
- writeFileSync(rootPath(common), `${runtimeRoot(proj)}\n`)
136
+ changed = writeFileIfChanged(manifestPath, manifest.join('\n') + (manifest.length ? '\n' : '')) || changed
137
+ changed = writeFileIfChanged(rootPath(common), `${runtimeRoot(proj)}\n`) || changed
129
138
  // Attribute patterns are checkout-relative; the stable binding set is safe in the common git dir because
130
139
  // the driver selects a payload from the invoking checkout's tree slot.
131
140
  const entries = bindings.map((b) => `/${b.file} filter=spexcode`).sort().join('\n')
132
141
  mkdirSync(join(common, 'info'), { recursive: true })
133
- writeManagedBlock(attributesPath(common), entries, ['# ', ''])
134
- settleIndexStat(proj, payloads.map((p) => join(proj, p.file)))
142
+ changed = writeManagedBlock(attributesPath(common), entries, ['# ', '']) || changed
143
+ const settle = changed ? payloads.map((p) => join(proj, p.file)) : changedFiles
144
+ if (settle.length) settleIndexStat(proj, [...new Set(settle)])
145
+ return changed
135
146
  }
136
147
 
137
148
  // settle the index STAT for each file — the famous filtered-path phantom-`M`: git cannot verify a
@@ -150,7 +161,7 @@ export function settleIndexStat(proj: string, files: string[]): void {
150
161
  const rel = relative(proj, f)
151
162
  try {
152
163
  const indexBlob = git(['-C', proj, 'rev-parse', `:${rel}`]).trim()
153
- const filtered = execFileSync('git', ['-C', proj, 'hash-object', '--path', rel, '--stdin'],
164
+ const filtered = execFileSync(gitBinary(env), ['-C', proj, 'hash-object', '--path', rel, '--stdin'],
154
165
  { input: readFileSync(f), env, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim()
155
166
  if (indexBlob === filtered) git(['-C', proj, 'add', '--renormalize', '--', rel])
156
167
  } catch { /* best-effort */ }
@@ -160,24 +171,29 @@ export function settleIndexStat(proj: string, files: string[]): void {
160
171
  // the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
161
172
  // config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
162
173
  // (evidence blobs), so only OUR two files go, never the dir.
174
+ export function clearContractFilterPayload(proj: string, files: string[] = []): void {
175
+ try { rmSync(treeFilterDir(proj), { recursive: true, force: true }) } catch { /* inaccessible tree */ }
176
+ settleIndexStat(proj, files)
177
+ }
178
+
163
179
  export function removeContractFilter(proj: string, files: string[] = [], final = false): void {
164
180
  let common: string
165
181
  try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
166
- try { rmSync(treeFilterDir(proj), { recursive: true, force: true }) } catch { /* inaccessible tree */ }
167
- settleIndexStat(proj, files)
168
- const anotherPayload = registeredSlots(proj).some((slot) => existsSync(join(slot, 'contract-filter', 'manifest')))
169
- const legacyTree = legacyFilterTree(proj)
170
- if (!legacyTree) rmSync(blockPath(common), { force: true })
171
- if (!final && (anotherPayload || legacyTree)) return
182
+ clearContractFilterPayload(proj, files)
183
+ const anotherPayload = final ? false : (() => {
184
+ const rows = git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
185
+ const root = runtimeRoot(proj)
186
+ return rows
187
+ .filter((row) => row.startsWith('worktree '))
188
+ .map((row) => row.slice('worktree '.length))
189
+ .some((tree) => existsSync(join(root, 'trees', encodeProject(tree), 'contract-filter', 'manifest')))
190
+ })()
191
+ if (!final && anotherPayload) return
172
192
  removeManagedBlock(attributesPath(common), ['# ', ''], true)
173
193
  for (const key of ['filter.spexcode.smudge', 'filter.spexcode.clean']) {
174
194
  try { git(['-C', proj, 'config', '--unset-all', key]) } catch { /* not set — already clean */ }
175
195
  }
176
- for (const path of [shimPath(common), blockPath(common), rootPath(common), bindingsPath(common)]) rmSync(path, { force: true })
177
- }
178
-
179
- export function retireLegacyContractBlock(proj: string): void {
180
- if (!legacyFilterTree(proj)) rmSync(blockPath(commonDirOf(proj)), { force: true })
196
+ for (const path of [shimPath(common), rootPath(common), bindingsPath(common)]) rmSync(path, { force: true })
181
197
  }
182
198
 
183
199
  // is the filter currently planted? (the assert-side probe tests use; cheap: one config read)