spexcode 0.5.2 → 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 (151) 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 +13 -52
  8. package/spec-cli/src/cli.ts +209 -71
  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 +946 -71
  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/index.ts +95 -21
  22. package/spec-cli/src/init.ts +47 -8
  23. package/spec-cli/src/issues.ts +36 -3
  24. package/spec-cli/src/layout.ts +193 -37
  25. package/spec-cli/src/lint.ts +152 -27
  26. package/spec-cli/src/localIssues.ts +5 -1
  27. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  28. package/spec-cli/src/materialize.ts +6 -7
  29. package/spec-cli/src/opencode-headless.ts +4 -8
  30. package/spec-cli/src/pi-headless.ts +17 -60
  31. package/spec-cli/src/process-identity.ts +49 -0
  32. package/spec-cli/src/project-store.ts +17 -0
  33. package/spec-cli/src/pty-bridge.ts +6 -3
  34. package/spec-cli/src/reviews.ts +17 -13
  35. package/spec-cli/src/runtime-ownership.ts +99 -0
  36. package/spec-cli/src/session-maintenance.ts +750 -0
  37. package/spec-cli/src/session-timeline.ts +6 -6
  38. package/spec-cli/src/sessions.ts +1249 -208
  39. package/spec-cli/src/sh.ts +3 -0
  40. package/spec-cli/src/source-files.ts +27 -2
  41. package/spec-cli/src/specs.ts +58 -25
  42. package/spec-cli/src/supervise.ts +31 -11
  43. package/spec-cli/src/uninstall.ts +7 -6
  44. package/spec-cli/templates/hooks/commit-msg +14 -0
  45. package/spec-cli/templates/hooks/pre-commit +26 -6
  46. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  47. package/spec-cli/templates/hooks/reference-transaction +105 -0
  48. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  49. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  50. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  51. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  53. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  54. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  56. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  57. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  58. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  59. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  60. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  61. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  116. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  117. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  118. package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
  119. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  120. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  121. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  122. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  123. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  124. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  125. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  126. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  127. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  128. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  129. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  130. package/spec-dashboard/dist/index.html +5 -4
  131. package/spec-dashboard/src/reviewFilters.js +46 -8
  132. package/spec-dashboard/src/session.js +64 -9
  133. package/spec-eval/src/cli.ts +144 -32
  134. package/spec-eval/src/evaltab.ts +16 -9
  135. package/spec-eval/src/freshness.ts +336 -54
  136. package/spec-eval/src/scenariofresh.ts +51 -1
  137. package/spec-eval/src/scenarios.ts +324 -25
  138. package/spec-eval/src/sessioneval.ts +969 -173
  139. package/spec-cli/src/message-stream.ts +0 -147
  140. package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
  141. package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
  142. package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
  143. package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
  144. package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
  145. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
  146. package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
  147. package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
  148. package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
  149. package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
  150. package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
  151. package/spec-dashboard/dist/assets/index-DFdlYy4H.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 {
@@ -218,7 +200,7 @@ async function evalExport(id: string): Promise<never> {
218
200
 
219
201
  // appended to a done/ask/block declaration: the note is durable conversation history even though the
220
202
  // CURRENT board projection correctly flips back to active on the next tool call.
221
- 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 board state back to active (the mark-active hook, by design).'
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).'
222
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.
223
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.'
224
206
 
@@ -250,9 +232,16 @@ async function stateKit() {
250
232
  }
251
233
  // a state writer from a non-repo cwd throws git's not-a-repo before it can return false — map exactly
252
234
  // that throw to the no-record path (noRecord re-probes and names the cwd); anything else stays loud.
253
- const mark = (fn: () => boolean): boolean => {
254
- try { return fn() }
255
- 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
+ }
256
245
  }
257
246
  // truncation transparency ([[state]]): the session table shows only the first NOTE_BOARD_LIMIT chars of a
258
247
  // note. When a declared note overflows that cap, the confirmation says so — length, what the board shows,
@@ -278,12 +267,12 @@ async function stateKit() {
278
267
 
279
268
  // a trailing --help/-h prints help and exits BEFORE any verb runs, so a help probe never fires a
280
269
  // streaming/mutating command. It prints THAT command's usage when an entry exists (the second layer
281
- // of the help journey — see help.ts): a drawer sub's probe (`spex session send --help`) answers with the
282
- // drawer's entry. Unknown tokens fall back to the map. (Removed spellings never reach here — the signpost
283
- // 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.)
284
273
  if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
285
274
  const { commandHelp, overviewHelp } = await import('./help.js')
286
- console.log(commandHelp(cmd) ?? overviewHelp())
275
+ console.log(commandHelp(cmd, cmd === 'session' ? process.argv[3] : undefined) ?? overviewHelp())
287
276
  process.exit(0)
288
277
  }
289
278
 
@@ -455,14 +444,21 @@ if (cmd === 'serve') {
455
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}`)
456
445
  }
457
446
  } else if (sub === 'lint') {
458
- const { specLint, DRIFT_GUIDANCE } = await import('./lint.js')
459
- 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
+ })
460
456
  const errors = findings.filter((f) => f.level === 'error')
461
457
  for (const f of findings) console.error(` ${f.level === 'error' ? '✗' : '•'} ${f.rule}: ${f.msg}`)
462
458
  console.error(`spex spec lint: ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
463
459
  // drift teaches from the ONE `spex spec lint` (no flag). Unanchored drift stays advisory forever; the
464
- // blocking tier is anchor-drift ([[code-anchor]]) — an ERROR like any other, so the pre-commit shim
465
- // (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.
466
462
  if (findings.some((f) => f.rule === 'drift' || f.rule === 'anchor-drift')) console.error(`\n${DRIFT_GUIDANCE}`)
467
463
  process.exit(errors.length ? 1 : 0)
468
464
  } else if (sub === 'ack') {
@@ -513,9 +509,9 @@ if (cmd === 'serve') {
513
509
  } else if (sub === 'ls' && flag('session') !== undefined) {
514
510
  // the session EVAL read ([[session-eval]]'s interactive face as a CLI verb): the dashboard Eval tab's
515
511
  // text twin. Renders the session's changed nodes with each DECLARED scenario at its CURRENT score
516
- // (latest reading per scenario, worktree-rooted) — blind spots lead, the session's OWN measurements
517
- // ✦-marked ahead of the inherited baseline under its divider. --export writes the self-contained HTML
518
- // 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.
519
515
  const id = await resolveSelectorOrExit(flag('session')!)
520
516
  if (has('export')) await evalExport(id)
521
517
  const { clientEvals } = await import('./client.js')
@@ -523,35 +519,31 @@ if (cmd === 'serve') {
523
519
  if (!r.ok) { console.error(`no evals for ${id} (status ${r.status})`); process.exit(1) }
524
520
  if (has('json')) { console.log(JSON.stringify(r.model, null, 2)); await flushExit(0) }
525
521
  const m = r.model
526
- const byNode = new Map<string, any[]>()
527
- for (const item of m.items) {
528
- const rows = byNode.get(item.node) ?? []
529
- rows.push(item)
530
- byNode.set(item.node, rows)
531
- }
532
- const groups = [...byNode].map(([node, rows]) => ({ node, rows }))
533
522
  const own = m.items.filter((item) => item.inSession).length
534
523
  console.log(`eval session [${m.id}]`)
535
524
  console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
536
- 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)`)
537
526
  if (!m.items.length) console.log('\n no affected scenarios to evaluate yet')
538
- for (const { node, rows } of groups) {
539
- console.log(`\n${node}`)
540
- for (const item of rows.filter((row) => row.filterKind === 'blind')) console.log(` ∅ unmeasured ${item.scenario} — declared, never measured (blind spot)`)
541
- let divided = false
542
- for (const e of rows.filter((row) => row.filterKind === 'result')) {
543
- if (!e.inSession && !divided && rows.some((x) => x.filterKind === 'result' && x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
544
- const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
545
- const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
546
- 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
532
+ }
533
+ if (e.filterKind === 'blind') {
534
+ console.log(` unmeasured ${e.scenario} — declared, never measured (blind spot)`)
535
+ continue
547
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}` : ''}`)
548
540
  }
549
541
  } else if (['add', 'ls', 'scenario', 'matrix', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
550
542
  // node-scoped verbs — thin route; the logic lives in spec-eval.
551
543
  const { runEval } = await import('../../spec-eval/src/cli.js')
552
544
  await flushExit(await runEval(process.argv.slice(3)))
553
545
  } else {
554
- 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)`)
555
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)
556
548
  process.exit(2)
557
549
  }
@@ -624,6 +616,51 @@ if (cmd === 'serve') {
624
616
  console.log((await import('./help.js')).commandHelp('session'))
625
617
  } else if (SESSION_SIGNPOSTS[sub]) {
626
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
+ }
627
664
  } else if (sub === 'new') {
628
665
  // spex session new "<prompt>" (prompt = first positional or --prompt, or --prompt-file
629
666
  // <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
@@ -648,22 +685,40 @@ if (cmd === 'serve') {
648
685
  }
649
686
  const created = await createSession(prompt, flag('launcher') ?? undefined)
650
687
  console.log(JSON.stringify(created, null, 2))
651
- await launchMonitorReminder(created.id)
688
+ console.error((await import('./help.js')).sessionLaunchReceipt(created.id))
652
689
  } else if (sub === 'ls') {
653
690
  // pretty list of living sessions + states. `spex session ls [SEL...] [--status a,b] [--json]`
654
691
  // the board comes from the backend (so it shows the sessions of whatever SPEXCODE_API_URL points at,
655
692
  // incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
656
693
  const { selectSessions, formatTable } = await import('./sessions.js')
657
694
  const { clientListSessions } = await import('./client.js')
658
- 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(','))
659
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))
660
708
  } else if (sub === 'watch') {
661
709
  const { watchSessions } = await import('./sessions.js')
662
710
  const { clientListSessions } = await import('./client.js')
663
711
  const selectors = positionals(4)
664
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)
665
719
  await withWatchEdge(selectors, intervalMs, () => watchSessions((line) => console.log(line), {
666
- source: clientListSessions, // poll the backend, so watch streams the (possibly remote) backend's board
720
+ source: events,
721
+ presenceSource: history,
667
722
  selectors,
668
723
  statuses: flag('status')?.split(','),
669
724
  includeIdle: has('idle'),
@@ -683,8 +738,12 @@ if (cmd === 'serve') {
683
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.`)
684
739
  const intervalMs = (Number(flag('interval')) || 2) * 1000
685
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)
686
744
  const r = await withWatchEdge([id], intervalMs, () => watchSessions(() => {}, {
687
- source: clientListSessions,
745
+ source: history,
746
+ presenceSource: history,
688
747
  selectors: [id],
689
748
  includeIdle: has('idle'),
690
749
  intervalMs,
@@ -732,6 +791,11 @@ if (cmd === 'serve') {
732
791
  console.log(' gates:')
733
792
  console.log(` conflicts w/ main : ${g.conflictsWithMain ? 'YES' : 'no'}`)
734
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})`}`)
735
799
  console.log(` diff (merge-base, ${r.diff.length} file(s)):`)
736
800
  for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
737
801
  }
@@ -771,30 +835,44 @@ if (cmd === 'serve') {
771
835
  try { closeNote += (await import('./localIssues.js')).closeoutNudge(sess ?? s.ownSessionId()) }
772
836
  catch (e) { console.error(`issue closeout check failed (declaration unaffected): ${e instanceof Error ? e.message : e}`) }
773
837
  }
774
- 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())
775
840
  } else if (sub === 'park') {
776
841
  // sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
777
842
  const { s, sess, mark, noRecord, noteEcho } = await stateKit()
778
- 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())
779
845
  } else if (sub === 'ask') {
780
846
  // the agent DELIBERATELY declares it is pausing to ask the human a question (like `done`/`park`, an
781
847
  // authored state — NOT guarded active-only). The --note carries the question. Distinct from `park`
782
848
  // (waiting on a background task, self-resumes): an asking agent resumes only when the human replies.
783
849
  const { s, sess, mark, noRecord, noteEcho } = await stateKit()
784
- 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())
785
852
  } else if (sub === 'stop') {
786
853
  // the SOFT stop: kill the agent's tmux + socket but KEEP the worktree, so the session goes offline and
787
854
  // can be resumed (`session resume`). Distinct from `close`, which removes the worktree.
788
855
  const full = await resolveSelectorOrExit(id)
789
- 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)`)
790
859
  } else if (sub === 'interrupt') {
791
860
  const full = await resolveSelectorOrExit(id)
792
861
  const r = await c.clientInterrupt(full)
793
862
  console.log(r.ok ? `interrupted ${full}` : `interrupt failed: ${r.error}`)
794
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}`)
795
871
  } else if (sub === 'close') {
796
872
  const full = await resolveSelectorOrExit(id)
797
- 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}`)
798
876
  } else if (sub === 'send') {
799
877
  const full = await resolveSelectorOrExit(id)
800
878
  if (has('keys')) {
@@ -872,7 +950,7 @@ if (cmd === 'serve') {
872
950
  // machine's) and terminal-only (an agent must never block its turn on it — capture/send).
873
951
  const { assertLocalBackend, attachSession } = await import('./attach.js')
874
952
  await assertLocalBackend()
875
- await attachSession(await resolveSelectorOrExit(id))
953
+ process.exit(await attachSession(await resolveSelectorOrExit(id)))
876
954
  } else {
877
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)`)
878
956
  process.exit(2)
@@ -901,6 +979,58 @@ if (cmd === 'serve') {
901
979
  if (!path) throw new Error(`governor '${owner.id}' has no live spec path`)
902
980
  console.log(`${owner.id}\t${path}`)
903
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)
904
1034
  } else if (sub === 'codex-launch') {
905
1035
  // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
906
1036
  // (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
@@ -915,7 +1045,11 @@ if (cmd === 'serve') {
915
1045
  // the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
916
1046
  // the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
917
1047
  const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex'))
918
- 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)
919
1053
  if (!r.ok) { console.error(r.error); process.exit(1) }
920
1054
  if (prompt) {
921
1055
  const t = await codexTurn(sock, r.threadId, prompt, cwd)
@@ -994,11 +1128,12 @@ if (cmd === 'serve') {
994
1128
  const { s, sess, mark, noRecord, noteEcho } = await stateKit()
995
1129
  const st = process.argv[4] as any
996
1130
  const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
997
- console.log(ok ? `state -> ${st}${noteEcho(flag('note'))}` : noRecord())
1131
+ console.log(ok.ok ? `state -> ${st}${noteEcho(flag('note'))}` : ok.reason ?? noRecord())
998
1132
  } else if (sub === 'session-fail') {
999
1133
  // the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
1000
1134
  const { s, sess, mark, noRecord } = await stateKit()
1001
- 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())
1002
1137
  } else if (sub === 'session-turn-fail') {
1003
1138
  // Headless adapters report an ephemeral turn's non-zero exit through this one shared CAS. A declaration
1004
1139
  // that landed before teardown wins, so a late child close can never erase an agent-authored state.
@@ -1014,14 +1149,17 @@ if (cmd === 'serve') {
1014
1149
  // at its prompt. INFERRED, so guarded active-only: it no-ops unless the current status is exactly `active`,
1015
1150
  // never clobbering a deliberate awaiting/asking/parked/error declaration. Distinct from `session ask`
1016
1151
  // (the agent deliberately asking the human) — idle is the undeclared stop the Stop gate missed.
1017
- const { s, sess } = await stateKit()
1018
- 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)')
1019
1155
  } else if (sub === 'commit-gate') {
1020
1156
  // the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
1021
- // 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
1022
1159
  // git() so the hook's exported GIT_DIR/GIT_INDEX_FILE don't misdirect repo discovery (see git.ts).
1023
1160
  const { s } = await stateKit()
1024
- const r = s.mergeReadiness()
1161
+ const kind = positionals(4)[0] === 'nothing' ? 'nothing' : 'merge'
1162
+ const r = s.mergeReadiness(kind)
1025
1163
  if (r.ready) { console.log('ready'); process.exit(0) }
1026
1164
  console.log(r.reason)
1027
1165
  process.exit(1)