spexcode 0.5.1 → 0.5.3

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 (155) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +116 -66
  8. package/spec-cli/src/cli.ts +221 -72
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +13 -3
  17. package/spec-cli/src/harness.ts +965 -70
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +706 -0
  21. package/spec-cli/src/host.ts +1 -0
  22. package/spec-cli/src/index.ts +95 -21
  23. package/spec-cli/src/init.ts +47 -8
  24. package/spec-cli/src/issues.ts +36 -3
  25. package/spec-cli/src/layout.ts +193 -37
  26. package/spec-cli/src/lint.ts +152 -27
  27. package/spec-cli/src/localIssues.ts +5 -1
  28. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  29. package/spec-cli/src/materialize.ts +6 -7
  30. package/spec-cli/src/opencode-headless.ts +117 -7
  31. package/spec-cli/src/opencode.ts +8 -4
  32. package/spec-cli/src/pi-headless.ts +18 -60
  33. package/spec-cli/src/process-identity.ts +49 -0
  34. package/spec-cli/src/project-store.ts +17 -0
  35. package/spec-cli/src/pty-bridge.ts +37 -7
  36. package/spec-cli/src/pty-helper.mjs +16 -6
  37. package/spec-cli/src/pty-native-helper.mjs +22 -0
  38. package/spec-cli/src/reviews.ts +17 -13
  39. package/spec-cli/src/runtime-ownership.ts +99 -0
  40. package/spec-cli/src/session-maintenance.ts +750 -0
  41. package/spec-cli/src/session-timeline.ts +29 -23
  42. package/spec-cli/src/sessions.ts +1262 -200
  43. package/spec-cli/src/sh.ts +3 -0
  44. package/spec-cli/src/source-files.ts +27 -2
  45. package/spec-cli/src/specs.ts +58 -25
  46. package/spec-cli/src/supervise.ts +31 -11
  47. package/spec-cli/src/uninstall.ts +7 -6
  48. package/spec-cli/templates/hooks/commit-msg +14 -0
  49. package/spec-cli/templates/hooks/pre-commit +26 -6
  50. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  51. package/spec-cli/templates/hooks/reference-transaction +105 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  54. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  55. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  56. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  57. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  58. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  59. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  60. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  61. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  116. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  117. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  118. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  119. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  120. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  121. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  122. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
  123. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  124. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  125. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  126. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  127. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  128. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  129. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  130. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  131. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  132. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  133. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  134. package/spec-dashboard/dist/index.html +5 -4
  135. package/spec-dashboard/src/reviewFilters.js +46 -8
  136. package/spec-dashboard/src/session.js +64 -9
  137. package/spec-eval/src/cli.ts +144 -32
  138. package/spec-eval/src/evaltab.ts +16 -9
  139. package/spec-eval/src/freshness.ts +336 -54
  140. package/spec-eval/src/scenariofresh.ts +51 -1
  141. package/spec-eval/src/scenarios.ts +324 -25
  142. package/spec-eval/src/sessioneval.ts +969 -173
  143. package/spec-cli/src/message-stream.ts +0 -147
  144. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
  145. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
  146. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
  147. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
  148. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
  149. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
  150. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
  151. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
  152. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
  153. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
  154. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
  155. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
@@ -20,7 +20,8 @@ const cmd = process.argv[2]
20
20
  // (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
21
21
  // paths route through the same printer.
22
22
  function fatal(e: unknown): never {
23
- if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError'].includes(e.name)) console.error(`spex: ${e.message}`)
23
+ if (e instanceof Error && e.name === 'SessionMaintenanceError') console.error(`spex: ${(e as Error & { code?: string }).code ?? 'maintenance_error'}: ${e.message}`)
24
+ else if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError'].includes(e.name)) console.error(`spex: ${e.message}`)
24
25
  else console.error(e)
25
26
  process.exit(1)
26
27
  }
@@ -49,7 +50,7 @@ function flushExit(code = 0): Promise<never> {
49
50
  }
50
51
  const has = (name: string) => process.argv.includes(`--${name}`)
51
52
  // bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
52
- const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys'])
53
+ const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms'])
53
54
  function positionals(from: number): string[] {
54
55
  const out: string[] = []
55
56
  for (let i = from; i < process.argv.length; i++) {
@@ -126,25 +127,6 @@ function signpost(oldSpelling: string, newSpelling: string): never {
126
127
  }
127
128
  if (cmd !== undefined && SIGNPOSTS[cmd]) signpost(`spex ${cmd}`, SIGNPOSTS[cmd])
128
129
 
129
- // After a successful launch, nudge the caller to actually MONITOR the session — launch-then-forget is a real
130
- // gap (a supervisor or human launches and then never watches, so a review/failure goes unnoticed). Goes to
131
- // STDERR so the JSON on stdout (which callers parse) stays clean; keyed to whoever's calling — a supervising
132
- // agent has an own-session id, a human at a terminal does not. The hint also names the COMM channel
133
- // (`spex session send`) — field-tested gap: callers who couldn't find it reached for raw tmux keystrokes instead.
134
- async function launchMonitorReminder(id: string): Promise<void> {
135
- const { ownSessionId } = await import('./sessions.js')
136
- const agent = ownSessionId()
137
- console.error(`\nspex: launched session ${id} — now MONITOR it, or its review/failure goes unnoticed:`)
138
- if (agent) {
139
- // a supervising agent: the per-worker monitor is a backgrounded `spex session wait`, which is edge-triggered.
140
- console.error(` supervising agent → background \`spex session wait ${id}\` (edge-triggered: exits when it OBSERVES the session transition into an actionable status — also how you await a dispatched merge actually landing; its exit is your wake-up. Already actionable and you just want to read it? \`spex session ls\`)`)
141
- console.error(` or watch the whole stream: \`spex session watch\``)
142
- } else {
143
- console.error(` \`spex session watch\` — the live stream of actionable session transitions (or \`spex session wait ${id}\` to sleep until this one's next transition into actionable)`)
144
- }
145
- console.error(` talk to it: \`spex session send ${id} "<msg>"\` — plain text; \`send --keys\` is a LAST RESORT (unstable raw TUI keys — only when a text send provably can't land)`)
146
- }
147
-
148
130
  const greeted = new Set<string>()
149
131
  async function greetWatchTargets(watcher: string, selectors: string[]): Promise<void> {
150
132
  try {
@@ -216,8 +198,9 @@ async function evalExport(id: string): Promise<never> {
216
198
  process.exit(0)
217
199
  }
218
200
 
219
- // appended to a done/ask/block declaration: states (not commands) that the next tool call's mark-active hook re-flips the global record to active, so a re-read won't show this.
220
- const DECLARED = ' recorded; the human sees it in the dashboard. This state lives in your session\'s global record; your next tool call flips that record back to active (the mark-active hook, by design), so it is normal for this declaration not to persist.'
201
+ // appended to a done/ask/block declaration: the note is durable conversation history even though the
202
+ // CURRENT board projection correctly flips back to active on the next tool call.
203
+ const DECLARED = ' — recorded; the human sees it in the dashboard. This declaration remains in the session timeline; your next tool call flips only the current graph state back to active (the mark-active hook, by design).'
221
204
  // appended ONLY to a propose-close declaration: a worktree about to be discarded may still own ephemeral things the agent started to test this change; nudge (not gate) it to reclaim them before the worktree goes, keyed on whether the thing should outlive the task — never on who started it (a deliberately long-running service / a production build is started-by-you yet must be left alone). Project-agnostic on purpose.
222
205
  const CLOSE_CLEANUP = '\n\nBefore this worktree closes, check whether you left anything running that you started to test this change — a background process, a dev or preview server, a bound port, a scratch session. If nothing depends on it anymore, shut it down, or it keeps running as an orphan. Leave anything meant to keep running: a service you deliberately stood up, a production build, anything other work relies on. What matters is whether it still needs to exist after this task, not whether you started it. If unsure, leave it. This is a reminder to check, not a required step.'
223
206
 
@@ -249,9 +232,16 @@ async function stateKit() {
249
232
  }
250
233
  // a state writer from a non-repo cwd throws git's not-a-repo before it can return false — map exactly
251
234
  // that throw to the no-record path (noRecord re-probes and names the cwd); anything else stays loud.
252
- const mark = (fn: () => boolean): boolean => {
253
- try { return fn() }
254
- catch (e) { if (/not a git repository/i.test(String((e as any)?.stderr ?? e))) return false; throw e }
235
+ // A record that exists but CANNOT carry state (unreadable bytes, or a worktree that is gone) is its own
236
+ // answer: the writer refused on purpose and already knows why, so that reason is what the caller prints —
237
+ // never the no-record diagnosis, which would send the author chasing a wrong cwd ([[sessions-core]]).
238
+ const mark = (fn: () => boolean): { ok: boolean; reason?: string } => {
239
+ try { return { ok: fn() } }
240
+ catch (e) {
241
+ if (e instanceof s.SessionRecordUnusable) return { ok: false, reason: e.message }
242
+ if (/not a git repository/i.test(String((e as any)?.stderr ?? e))) return { ok: false }
243
+ throw e
244
+ }
255
245
  }
256
246
  // truncation transparency ([[state]]): the session table shows only the first NOTE_BOARD_LIMIT chars of a
257
247
  // note. When a declared note overflows that cap, the confirmation says so — length, what the board shows,
@@ -277,12 +267,12 @@ async function stateKit() {
277
267
 
278
268
  // a trailing --help/-h prints help and exits BEFORE any verb runs, so a help probe never fires a
279
269
  // streaming/mutating command. It prints THAT command's usage when an entry exists (the second layer
280
- // of the help journey — see help.ts): a drawer sub's probe (`spex session send --help`) answers with the
281
- // drawer's entry. Unknown tokens fall back to the map. (Removed spellings never reach here — the signpost
282
- // table above already exited.)
270
+ // of the help journey — see help.ts); session noun-verb probes project the exact verb from the shared
271
+ // drawer definition. Unknown tokens preserve the existing drawer/map fallback. (Removed spellings never
272
+ // reach here — the signpost table above already exited.)
283
273
  if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
284
274
  const { commandHelp, overviewHelp } = await import('./help.js')
285
- console.log(commandHelp(cmd) ?? overviewHelp())
275
+ console.log(commandHelp(cmd, cmd === 'session' ? process.argv[3] : undefined) ?? overviewHelp())
286
276
  process.exit(0)
287
277
  }
288
278
 
@@ -454,14 +444,21 @@ if (cmd === 'serve') {
454
444
  console.log(`${rel} is governed whole-file by ${whole.length} specs (all claims: ${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).${relLine}`)
455
445
  }
456
446
  } else if (sub === 'lint') {
457
- const { specLint, DRIFT_GUIDANCE } = await import('./lint.js')
458
- const findings = await specLint()
447
+ const { specLint, pendingTouchesGoverned, DRIFT_GUIDANCE } = await import('./lint.js')
448
+ const pending = flag('pending')
449
+ if (pending && process.env.SPEXCODE_GATE_SCOPE_ONLY === '1') {
450
+ const touches = await pendingTouchesGoverned(process.cwd(), pending)
451
+ if (!touches) process.exit(76)
452
+ }
453
+ const findings = await specLint(undefined, undefined, {
454
+ tip: pending || 'HEAD',
455
+ })
459
456
  const errors = findings.filter((f) => f.level === 'error')
460
457
  for (const f of findings) console.error(` ${f.level === 'error' ? '✗' : '•'} ${f.rule}: ${f.msg}`)
461
458
  console.error(`spex spec lint: ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
462
459
  // drift teaches from the ONE `spex spec lint` (no flag). Unanchored drift stays advisory forever; the
463
- // blocking tier is anchor-drift ([[code-anchor]]) — an ERROR like any other, so the pre-commit shim
464
- // (and CI) gates on it with no separate staged-index machinery.
460
+ // Blocking tier is anchor-drift ([[code-anchor]]) — CI/default lint judges HEAD; the local armed hook
461
+ // supplies --pending <real commit oid>; canonical pre-commit leaves the whole lint to that one run.
465
462
  if (findings.some((f) => f.rule === 'drift' || f.rule === 'anchor-drift')) console.error(`\n${DRIFT_GUIDANCE}`)
466
463
  process.exit(errors.length ? 1 : 0)
467
464
  } else if (sub === 'ack') {
@@ -512,9 +509,9 @@ if (cmd === 'serve') {
512
509
  } else if (sub === 'ls' && flag('session') !== undefined) {
513
510
  // the session EVAL read ([[session-eval]]'s interactive face as a CLI verb): the dashboard Eval tab's
514
511
  // text twin. Renders the session's changed nodes with each DECLARED scenario at its CURRENT score
515
- // (latest reading per scenario, worktree-rooted) — blind spots lead, the session's OWN measurements
516
- // ✦-marked ahead of the inherited baseline under its divider. --export writes the self-contained HTML
517
- // artifact instead.
512
+ // (latest reading per scenario, worktree-rooted) — filed results keep the backend's newest-first order
513
+ // across ownership, the session's OWN measurements are ✦-marked, and blind spots follow. --export writes
514
+ // the self-contained HTML artifact instead.
518
515
  const id = await resolveSelectorOrExit(flag('session')!)
519
516
  if (has('export')) await evalExport(id)
520
517
  const { clientEvals } = await import('./client.js')
@@ -522,35 +519,31 @@ if (cmd === 'serve') {
522
519
  if (!r.ok) { console.error(`no evals for ${id} (status ${r.status})`); process.exit(1) }
523
520
  if (has('json')) { console.log(JSON.stringify(r.model, null, 2)); await flushExit(0) }
524
521
  const m = r.model
525
- const byNode = new Map<string, any[]>()
526
- for (const item of m.items) {
527
- const rows = byNode.get(item.node) ?? []
528
- rows.push(item)
529
- byNode.set(item.node, rows)
530
- }
531
- const groups = [...byNode].map(([node, rows]) => ({ node, rows }))
532
522
  const own = m.items.filter((item) => item.inSession).length
533
523
  console.log(`eval session [${m.id}]`)
534
524
  console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
535
- if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = inherited baseline)`)
525
+ if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = evals filed by other sessions)`)
536
526
  if (!m.items.length) console.log('\n no affected scenarios to evaluate yet')
537
- for (const { node, rows } of groups) {
538
- console.log(`\n${node}`)
539
- for (const item of rows.filter((row) => row.filterKind === 'blind')) console.log(` ∅ unmeasured ${item.scenario} — declared, never measured (blind spot)`)
540
- let divided = false
541
- for (const e of rows.filter((row) => row.filterKind === 'result')) {
542
- if (!e.inSession && !divided && rows.some((x) => x.filterKind === 'result' && x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
543
- const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
544
- const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
545
- console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
527
+ let lastNode: string | null = null
528
+ for (const e of m.items) {
529
+ if (e.node !== lastNode) {
530
+ console.log(`\n${e.node}`)
531
+ lastNode = e.node
546
532
  }
533
+ if (e.filterKind === 'blind') {
534
+ console.log(` ∅ unmeasured ${e.scenario} — declared, never measured (blind spot)`)
535
+ continue
536
+ }
537
+ const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
538
+ const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
539
+ console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
547
540
  }
548
541
  } else if (['add', 'ls', 'scenario', 'matrix', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
549
542
  // node-scoped verbs — thin route; the logic lives in spec-eval.
550
543
  const { runEval } = await import('../../spec-eval/src/cli.js')
551
544
  await flushExit(await runEval(process.argv.slice(3)))
552
545
  } else {
553
- console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | matrix | lint | ok | retract | clean (spex help eval)`)
546
+ console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls/write | matrix | lint | ok | retract | clean (spex help eval)`)
554
547
  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
548
  process.exit(2)
556
549
  }
@@ -623,6 +616,51 @@ if (cmd === 'serve') {
623
616
  console.log((await import('./help.js')).commandHelp('session'))
624
617
  } else if (SESSION_SIGNPOSTS[sub]) {
625
618
  signpost(`spex session ${sub}`, SESSION_SIGNPOSTS[sub])
619
+ } else if (sub === 'maintain') {
620
+ const args = process.argv.slice(4)
621
+ if (args.length === 1 && args[0] === '--status') {
622
+ const state = await (await import('./client.js')).clientMaintenanceStatus()
623
+ console.log(JSON.stringify(state, null, 2))
624
+ } else {
625
+ const separator = args.indexOf('--')
626
+ if (separator < 0 || separator === args.length - 1) {
627
+ console.error('usage: spex session maintain [--allow-stop SEL] [--allow-resume SEL[:force]] [--ttl-ms MS] [--wait-ms MS] -- <command> [args...]')
628
+ process.exit(2)
629
+ }
630
+ const options = args.slice(0, separator)
631
+ const command = args.slice(separator + 1)
632
+ const capabilities: import('./session-maintenance.js').Capability[] = []
633
+ let ttlMs = 30_000
634
+ let waitMs = 10_000
635
+ for (let i = 0; i < options.length; i++) {
636
+ const option = options[i]
637
+ if (option === '--api' || option === '--port') { i++; continue }
638
+ if (option === '--ttl-ms' || option === '--wait-ms') {
639
+ const value = Number(options[++i])
640
+ if (!Number.isSafeInteger(value)) { console.error(`spex session maintain: ${option} requires an integer millisecond value`); process.exit(2) }
641
+ if (option === '--ttl-ms') ttlMs = value
642
+ else waitMs = value
643
+ continue
644
+ }
645
+ if (option === '--allow-stop' || option === '--allow-resume') {
646
+ const raw = options[++i]
647
+ if (!raw) { console.error(`spex session maintain: ${option} requires a session selector`); process.exit(2) }
648
+ if (option === '--allow-stop') {
649
+ capabilities.push({ op: 'stop', sessionId: await resolveSelectorOrExit(raw) })
650
+ } else {
651
+ const force = raw.endsWith(':force')
652
+ const selector = force ? raw.slice(0, -':force'.length) : raw
653
+ capabilities.push({ op: 'resume', sessionId: await resolveSelectorOrExit(selector), force })
654
+ }
655
+ continue
656
+ }
657
+ console.error(`spex session maintain: unknown option ${option}`)
658
+ process.exit(2)
659
+ }
660
+ if (capabilities.length === 0) { console.error('spex session maintain: at least one exact stop/resume capability is required'); process.exit(2) }
661
+ const { runMaintenanceWrapper } = await import('./maintenance-wrapper.js')
662
+ process.exit(await runMaintenanceWrapper({ capabilities, ttlMs, waitMs, command }))
663
+ }
626
664
  } else if (sub === 'new') {
627
665
  // spex session new "<prompt>" (prompt = first positional or --prompt, or --prompt-file
628
666
  // <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
@@ -647,22 +685,40 @@ if (cmd === 'serve') {
647
685
  }
648
686
  const created = await createSession(prompt, flag('launcher') ?? undefined)
649
687
  console.log(JSON.stringify(created, null, 2))
650
- await launchMonitorReminder(created.id)
688
+ console.error((await import('./help.js')).sessionLaunchReceipt(created.id))
651
689
  } else if (sub === 'ls') {
652
690
  // pretty list of living sessions + states. `spex session ls [SEL...] [--status a,b] [--json]`
653
691
  // the board comes from the backend (so it shows the sessions of whatever SPEXCODE_API_URL points at,
654
692
  // incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
655
693
  const { selectSessions, formatTable } = await import('./sessions.js')
656
694
  const { clientListSessions } = await import('./client.js')
657
- const picked = selectSessions(await clientListSessions(), positionals(4), flag('status')?.split(','))
695
+ // The backend's default projection excludes cold archives. --all and an explicit selector request the
696
+ // history projection so an operator can still inspect or unarchive one deliberately.
697
+ const selectors = positionals(4)
698
+ const all = await clientListSessions(has('all') || selectors.length > 0)
699
+ const visible = all
700
+ const picked = selectSessions(visible, selectors, flag('status')?.split(','))
658
701
  console.log(has('json') ? JSON.stringify(picked, null, 2) : formatTable(picked))
702
+ } else if (sub === 'resources') {
703
+ rejectUnknownFlags('spex session resources', 4, ['json', 'api', 'port'])
704
+ const { clientResources } = await import('./client.js')
705
+ const report = await clientResources()
706
+ if (has('json')) console.log(JSON.stringify(report, null, 2))
707
+ else console.log((await import('./host-resources.js')).formatResourceReport(report))
659
708
  } else if (sub === 'watch') {
660
709
  const { watchSessions } = await import('./sessions.js')
661
710
  const { clientListSessions } = await import('./client.js')
662
711
  const selectors = positionals(4)
663
712
  const intervalMs = (Number(flag('interval')) || 5) * 1000
713
+ // Broad watch keeps the default active-only event population, so the archive shelf is not replayed into
714
+ // ordinary monitoring. Presence is a separate all-record read: hiding a cold row must not turn it into
715
+ // "gone"/closed. An explicit selector opts into the history population so its archive/offline transition
716
+ // remains observable.
717
+ const history = () => clientListSessions(true)
718
+ const events = selectors.length ? history : () => clientListSessions(false)
664
719
  await withWatchEdge(selectors, intervalMs, () => watchSessions((line) => console.log(line), {
665
- source: clientListSessions, // poll the backend, so watch streams the (possibly remote) backend's board
720
+ source: events,
721
+ presenceSource: history,
666
722
  selectors,
667
723
  statuses: flag('status')?.split(','),
668
724
  includeIdle: has('idle'),
@@ -682,8 +738,12 @@ if (cmd === 'serve') {
682
738
  if (own) console.error(`spex session wait: heads-up (managed agent ${own.slice(0, 8)}) — this command BLOCKS until it OBSERVES ${id} transition from non-actionable into an actionable status (edge-triggered: an already-actionable current state does NOT return it — to just read the state now, use \`spex session ls\`/\`review\`); run it in the BACKGROUND or it freezes your whole turn (its exit is your wake-up). Proceeding.`)
683
739
  const intervalMs = (Number(flag('interval')) || 2) * 1000
684
740
  const timeoutSec = Number(flag('timeout')) || 1200
741
+ // `wait` addresses one explicit record. Read its history row for both events and presence so archive is an
742
+ // offline transition, not a vanished session; only a missing all-record row is a genuine gone/closed result.
743
+ const history = () => clientListSessions(true)
685
744
  const r = await withWatchEdge([id], intervalMs, () => watchSessions(() => {}, {
686
- source: clientListSessions,
745
+ source: history,
746
+ presenceSource: history,
687
747
  selectors: [id],
688
748
  includeIdle: has('idle'),
689
749
  intervalMs,
@@ -731,6 +791,11 @@ if (cmd === 'serve') {
731
791
  console.log(' gates:')
732
792
  console.log(` conflicts w/ main : ${g.conflictsWithMain ? 'YES' : 'no'}`)
733
793
  console.log(` lint : ${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)`)
794
+ // measured loss is REPORTED, never graded — and a projection that isn't ready says so instead of
795
+ // printing four zeros that would read as a clean session.
796
+ console.log(` evals : ${g.evals.phase === 'ready'
797
+ ? `${g.evals.freshPass} fresh pass, ${g.evals.freshFail} fresh fail, ${g.evals.needReview} need review, ${g.evals.blind} blind`
798
+ : `not measured yet (${g.evals.phase})`}`)
734
799
  console.log(` diff (merge-base, ${r.diff.length} file(s)):`)
735
800
  for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
736
801
  }
@@ -770,30 +835,44 @@ if (cmd === 'serve') {
770
835
  try { closeNote += (await import('./localIssues.js')).closeoutNudge(sess ?? s.ownSessionId()) }
771
836
  catch (e) { console.error(`issue closeout check failed (declaration unaffected): ${e instanceof Error ? e.message : e}`) }
772
837
  }
773
- console.log(mark(() => s.markDone(p, sess, flag('note'))) ? `done (${p})${DECLARED}${noteEcho(flag('note'))}${closeNote}` : noRecord())
838
+ const done = mark(() => s.markDone(p, sess, flag('note')))
839
+ console.log(done.ok ? `done (${p})${DECLARED}${noteEcho(flag('note'))}${closeNote}` : done.reason ?? noRecord())
774
840
  } else if (sub === 'park') {
775
841
  // sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
776
842
  const { s, sess, mark, noRecord, noteEcho } = await stateKit()
777
- console.log(mark(() => s.markState('parked', { note: flag('note'), sessionId: sess })) ? `parked${DECLARED}${noteEcho(flag('note'))}` : noRecord())
843
+ const parked = mark(() => s.markState('parked', { note: flag('note'), sessionId: sess }))
844
+ console.log(parked.ok ? `parked${DECLARED}${noteEcho(flag('note'))}` : parked.reason ?? noRecord())
778
845
  } else if (sub === 'ask') {
779
846
  // the agent DELIBERATELY declares it is pausing to ask the human a question (like `done`/`park`, an
780
847
  // authored state — NOT guarded active-only). The --note carries the question. Distinct from `park`
781
848
  // (waiting on a background task, self-resumes): an asking agent resumes only when the human replies.
782
849
  const { s, sess, mark, noRecord, noteEcho } = await stateKit()
783
- console.log(mark(() => s.markState('asking', { note: flag('note'), sessionId: sess })) ? `asking${DECLARED}${noteEcho(flag('note'))}` : noRecord())
850
+ const asked = mark(() => s.markState('asking', { note: flag('note'), sessionId: sess }))
851
+ console.log(asked.ok ? `asking${DECLARED}${noteEcho(flag('note'))}` : asked.reason ?? noRecord())
784
852
  } else if (sub === 'stop') {
785
853
  // the SOFT stop: kill the agent's tmux + socket but KEEP the worktree, so the session goes offline and
786
854
  // can be resumed (`session resume`). Distinct from `close`, which removes the worktree.
787
855
  const full = await resolveSelectorOrExit(id)
788
- console.log(await c.clientStop(full) ? `stopped ${full} (worktree kept — resumable)` : `no such session ${full}`)
856
+ const stopped = await c.clientStop(full)
857
+ if (!stopped) { console.error(`spex session stop: no such session ${full} (no stop transition was committed)`); process.exit(1) }
858
+ console.log(`stopped ${full} (worktree kept — resumable)`)
789
859
  } else if (sub === 'interrupt') {
790
860
  const full = await resolveSelectorOrExit(id)
791
861
  const r = await c.clientInterrupt(full)
792
862
  console.log(r.ok ? `interrupted ${full}` : `interrupt failed: ${r.error}`)
793
863
  process.exit(r.ok ? 0 : 1)
864
+ } else if (sub === 'archive' || sub === 'unarchive') {
865
+ // ARCHIVING/legacy unarchive ([[archive]]) — archive exact-stops before filing; unarchive signposts to
866
+ // resume and recreates the same conversation.
867
+ const on = sub === 'archive'
868
+ const full = await resolveSelectorOrExit(id)
869
+ const ok = await c.clientArchive(full, on)
870
+ console.log(!ok ? `no such session ${full}` : `${on ? 'archived' : 'resumed'} ${full}`)
794
871
  } else if (sub === 'close') {
795
872
  const full = await resolveSelectorOrExit(id)
796
- console.log(await c.clientClose(full) ? `closed ${full}` : `no such session ${full}`)
873
+ const closed = await c.clientClose(full)
874
+ if (!closed) { console.error(`spex session close: no such session ${full} (record remains; no close was committed)`); process.exit(1) }
875
+ console.log(`closed ${full}`)
797
876
  } else if (sub === 'send') {
798
877
  const full = await resolveSelectorOrExit(id)
799
878
  if (has('keys')) {
@@ -871,7 +950,7 @@ if (cmd === 'serve') {
871
950
  // machine's) and terminal-only (an agent must never block its turn on it — capture/send).
872
951
  const { assertLocalBackend, attachSession } = await import('./attach.js')
873
952
  await assertLocalBackend()
874
- await attachSession(await resolveSelectorOrExit(id))
953
+ process.exit(await attachSession(await resolveSelectorOrExit(id)))
875
954
  } else {
876
955
  console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | interrupt | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
877
956
  process.exit(2)
@@ -900,6 +979,58 @@ if (cmd === 'serve') {
900
979
  if (!path) throw new Error(`governor '${owner.id}' has no live spec path`)
901
980
  console.log(`${owner.id}\t${path}`)
902
981
  }
982
+ } else if (sub === 'maintenance-begin') {
983
+ const ownerPid = Number(process.argv[4]), sessionId = process.argv[5]
984
+ if (!Number.isInteger(ownerPid) || ownerPid <= 0 || !sessionId) {
985
+ console.error('usage: spex internal maintenance-begin <owner-pid> <session-id>')
986
+ process.exit(2)
987
+ }
988
+ const { exactProcessIdentity, maintenanceErrorPayload, sessionMaintenance } = await import('./session-maintenance.js')
989
+ const owner = exactProcessIdentity(ownerPid)
990
+ if (!owner) { console.error('maintenance_identity_unknown: hook dispatcher owner identity is not exact'); process.exit(2) }
991
+ try {
992
+ console.log(sessionMaintenance().beginExternalOperation({ op: 'hook-state', sessionId }, owner))
993
+ } catch (error) {
994
+ const payload = maintenanceErrorPayload(error)
995
+ console.error(payload ? `${payload.code}: ${payload.error}` : String(error))
996
+ process.exit(2)
997
+ }
998
+ } else if (sub === 'maintenance-end') {
999
+ const ticket = process.argv[4], ownerPid = Number(process.argv[5])
1000
+ if (!ticket || !Number.isInteger(ownerPid) || ownerPid <= 0) {
1001
+ console.error('usage: spex internal maintenance-end <ticket-id> <owner-pid>')
1002
+ process.exit(2)
1003
+ }
1004
+ const { exactProcessIdentity, sessionMaintenance } = await import('./session-maintenance.js')
1005
+ const owner = exactProcessIdentity(ownerPid)
1006
+ if (!owner) { console.error('maintenance_identity_unknown: hook dispatcher owner identity is not exact'); process.exit(2) }
1007
+ sessionMaintenance().finishExternalOperation(ticket, owner)
1008
+ } else if (sub === 'shared-runtime-spawn') {
1009
+ const [cwd, logFile, pidFile, isolationFile, command] = process.argv.slice(4, 9)
1010
+ const args = process.argv.slice(9)
1011
+ if (!cwd || !logFile || !pidFile || !isolationFile || !command) {
1012
+ console.error('usage: spex internal shared-runtime-spawn <cwd> <log> <pid-file> <isolation-file> <command> [args...]')
1013
+ process.exit(2)
1014
+ }
1015
+ const { readFileSync } = await import('node:fs')
1016
+ const { runSessionOperation } = await import('./session-maintenance.js')
1017
+ const { spawnDetachedRuntime } = await import('./runtime-ownership.js')
1018
+ const delegateFdValue = process.env.SPEXCODE_MAINTENANCE_DELEGATE_FD
1019
+ const delegateChannelPresent = delegateFdValue !== undefined
1020
+ const delegateFd = Number(delegateFdValue)
1021
+ let delegate = ''
1022
+ if (delegateChannelPresent && Number.isInteger(delegateFd) && delegateFd >= 3) {
1023
+ try { delegate = readFileSync(delegateFd, 'utf8').trim() } catch { delegate = '' }
1024
+ }
1025
+ const sessionId = process.env.SPEXCODE_MAINTENANCE_SESSION_ID?.trim()
1026
+ || process.env.SPEXCODE_SESSION_ID?.trim() || 'shared-runtime'
1027
+ const env = { ...process.env }
1028
+ delete env.SPEXCODE_MAINTENANCE_DELEGATE_FD
1029
+ delete env.SPEXCODE_MAINTENANCE_SESSION_ID
1030
+ delete env.SPEXCODE_SESSION_ID
1031
+ const runtime = await runSessionOperation({ op: 'shared-spawn', sessionId, ...(delegateChannelPresent ? { delegate } : {}) }, () =>
1032
+ spawnDetachedRuntime({ cwd, logFile, pidFile, isolationFile, command, args, env }))
1033
+ console.log(runtime.pid)
903
1034
  } else if (sub === 'codex-launch') {
904
1035
  // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
905
1036
  // (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
@@ -914,7 +1045,11 @@ if (cmd === 'serve') {
914
1045
  // the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
915
1046
  // the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
916
1047
  const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex'))
917
- const r = await codexStartThread(sock, cwd, bypassHookTrust)
1048
+ // The governed record id rides into the thread's own shell environment (shell_environment_policy.set), so
1049
+ // every command this thread spawns knows which session it is — the codex equivalent of the launch-injected
1050
+ // id claude gets. codex-launch is exactly where both ids are known ([[harness-adapter]]).
1051
+ const ownId = process.env.SPEXCODE_SESSION_ID?.trim()
1052
+ const r = await codexStartThread(sock, cwd, bypassHookTrust, ownId ? { SPEXCODE_SESSION_ID: ownId } : undefined)
918
1053
  if (!r.ok) { console.error(r.error); process.exit(1) }
919
1054
  if (prompt) {
920
1055
  const t = await codexTurn(sock, r.threadId, prompt, cwd)
@@ -993,24 +1128,38 @@ if (cmd === 'serve') {
993
1128
  const { s, sess, mark, noRecord, noteEcho } = await stateKit()
994
1129
  const st = process.argv[4] as any
995
1130
  const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
996
- console.log(ok ? `state -> ${st}${noteEcho(flag('note'))}` : noRecord())
1131
+ console.log(ok.ok ? `state -> ${st}${noteEcho(flag('note'))}` : ok.reason ?? noRecord())
997
1132
  } else if (sub === 'session-fail') {
998
1133
  // the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
999
1134
  const { s, sess, mark, noRecord } = await stateKit()
1000
- console.log(mark(() => s.markError(sess)) ? 'marked error' : noRecord())
1135
+ const failed = mark(() => s.markError(sess))
1136
+ console.log(failed.ok ? 'marked error' : failed.reason ?? noRecord())
1137
+ } else if (sub === 'session-turn-fail') {
1138
+ // Headless adapters report an ephemeral turn's non-zero exit through this one shared CAS. A declaration
1139
+ // that landed before teardown wins, so a late child close can never erase an agent-authored state.
1140
+ const sessionId = process.argv[4], harness = process.argv[5], exitCode = process.argv[6]
1141
+ if (!sessionId || !harness || !exitCode) {
1142
+ console.error('usage: spex internal session-turn-fail <session-id> <harness> <exit-code|signal>')
1143
+ process.exit(2)
1144
+ }
1145
+ const { markHeadlessTurnFailure } = await import('./sessions.js')
1146
+ console.log(markHeadlessTurnFailure(sessionId, harness, exitCode) ? `marked error (${harness} ${exitCode})` : 'noop (no active session record)')
1001
1147
  } else if (sub === 'session-idle') {
1002
1148
  // the Notification(idle_prompt) hook marks its session (--session from the payload) idle when claude waits
1003
1149
  // at its prompt. INFERRED, so guarded active-only: it no-ops unless the current status is exactly `active`,
1004
1150
  // never clobbering a deliberate awaiting/asking/parked/error declaration. Distinct from `session ask`
1005
1151
  // (the agent deliberately asking the human) — idle is the undeclared stop the Stop gate missed.
1006
- const { s, sess } = await stateKit()
1007
- console.log(s.markIdle(sess) ? 'idle' : 'noop (no session record, or not active)')
1152
+ const { s, sess, mark } = await stateKit()
1153
+ const idled = mark(() => s.markIdle(sess))
1154
+ console.log(idled.ok ? 'idle' : idled.reason ?? 'noop (no session record, or not active)')
1008
1155
  } else if (sub === 'commit-gate') {
1009
1156
  // the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
1010
- // ready to declare done/merge (work committed + ahead of main), else print the reason and exit 1. Uses
1157
+ // ready to declare done, else print the reason and exit 1. Takes the PROPOSAL being judged — `merge`
1158
+ // additionally requires commits ahead of main, `nothing` only a clean tree (see mergeReadiness). Uses
1011
1159
  // git() so the hook's exported GIT_DIR/GIT_INDEX_FILE don't misdirect repo discovery (see git.ts).
1012
1160
  const { s } = await stateKit()
1013
- const r = s.mergeReadiness()
1161
+ const kind = positionals(4)[0] === 'nothing' ? 'nothing' : 'merge'
1162
+ const r = s.mergeReadiness(kind)
1014
1163
  if (r.ready) { console.log('ready'); process.exit(0) }
1015
1164
  console.log(r.reason)
1016
1165
  process.exit(1)