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
@@ -13,11 +13,10 @@ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
13
13
  import { summarize } from './mentions.js'
14
14
  import { resolveLayout, mainBranch } from './layout.js'
15
15
  import { getBoardJson } from './graphCache.js'
16
- import { boardStream, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
16
+ import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
17
17
  import { gitA, gitTry, repoRoot } from './git.js'
18
- import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, TMUX_SOCK } from './sessions.js'
18
+ import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
19
19
  import { superviseTimeline, readTimeline } from './session-timeline.js'
20
- import { readSessionMessages, sessionMessageStream } from './message-stream.js'
21
20
  import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
22
21
  import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
23
22
  import { putBlob } from '../../spec-eval/src/cache.js'
@@ -29,6 +28,9 @@ import { attachViewer, detachViewer, resizeBridge, hideViewer, forwardInput, sup
29
28
  import { installProcessGuards } from './resilience.js'
30
29
  import { resolveProjectIdentity } from './project-identity.js'
31
30
  import { evalDetailReview, evalsReview, issuesReview } from './reviews.js'
31
+ import { collectResourceReport, ResourceConflict } from './host-resources.js'
32
+ import { exactProcessIdentity, maintenanceErrorPayload, sessionMaintenance, SessionMaintenanceError, type Authorization } from './session-maintenance.js'
33
+ import { readBackendInstanceRecords } from './runtime-ownership.js'
32
34
 
33
35
  // last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
34
36
  // self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
@@ -38,12 +40,18 @@ const app = new Hono()
38
40
  app.use('/api/*', cors())
39
41
  app.onError((error, c) => {
40
42
  if (error instanceof SessionEvalUnavailableError) return c.json({ error: error.message }, 503)
43
+ // a record that cannot carry state is a CONFLICT with the caller's request, not a server fault: the refusal
44
+ // is deliberate and already carries its own diagnosis + repair ([[sessions-core]]). Answering 500 with a
45
+ // stack would hide exactly the sentence the human needs.
46
+ if (error instanceof SessionRecordUnusable) return c.json({ error: error.message, code: error.code }, 409)
47
+ if (error instanceof ResourceConflict) return c.json({ error: error.message, code: error.code }, 409)
48
+ if (error instanceof SessionMaintenanceError) return c.json(maintenanceErrorPayload(error)!, 409)
41
49
  console.error(error)
42
50
  return c.text('Internal Server Error', 500)
43
51
  })
44
52
  const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
45
53
 
46
- app.get('/', (c) => c.text('spec-cli — GET /api/graph · /api/specs · /api/specs/:id/history · /api/settings · /api/sessions · /api/slash-commands'))
54
+ app.get('/', (c) => c.text('spec-cli — GET /api/graph · /api/specs · /api/specs/:id/history · /api/settings · /api/sessions · /api/resources · /api/slash-commands'))
47
55
  // the supervisor's readiness gate (supervise.ts): a bare git-free 200 so a booting child reports ready the
48
56
  // instant Hono is listening. Not under /api/* — loopback-only (supervisor→child), no CORS needed.
49
57
  app.get('/health', (c) => c.text('ok'))
@@ -77,11 +85,21 @@ app.get('/api/instance', (c) => {
77
85
  // is bounded by [[graph-cache]]'s own build watchdog, so the next poll retries a fresh build.
78
86
  const BOARD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_TIMEOUT_MS || 20000)
79
87
  app.get('/api/graph', etag(), async (c) => {
80
- ensureBoardFileWatchers()
88
+ await ensureBoardFileWatchers()
81
89
  const timeout = Symbol('timeout')
82
- const json = await Promise.race([getBoardJson(), new Promise<typeof timeout>((r) => setTimeout(() => r(timeout), BOARD_TIMEOUT_MS))])
83
- if (json === timeout) return c.json({ error: 'graph build timed out' }, 503)
84
- return c.body(json as string, 200, { 'content-type': 'application/json; charset=UTF-8' })
90
+ let timer: ReturnType<typeof setTimeout> | undefined
91
+ const result = await Promise.race([
92
+ getBoardJson('stale-ok'),
93
+ new Promise<typeof timeout>((resolve) => {
94
+ timer = setTimeout(() => resolve(timeout), BOARD_TIMEOUT_MS)
95
+ timer.unref?.()
96
+ }),
97
+ ])
98
+ clearTimeout(timer)
99
+ if (result === timeout) return c.json({ error: 'graph build timed out' }, 503)
100
+ const freshness = result.refreshing ? `${result.freshness}, refreshing` : result.freshness
101
+ c.header('x-spexcode-graph', freshness)
102
+ return c.body(result.json, 200, { 'content-type': 'application/json; charset=UTF-8' })
85
103
  })
86
104
  // the graph's push channel: an SSE that fires `board-changed` on any session-store write, so the dashboard
87
105
  // reloads the instant status moves instead of waiting for its slow fallback poll ([[graph-stream]]).
@@ -206,14 +224,15 @@ app.get('/api/issues', etag(), async (c) => c.json(await issuesReview(c.req.quer
206
224
  // Evals uses the identical paged-review response. `scope:` inside q selects the worktree source; without
207
225
  // it the source is the current cached board. Filtering/counts always precede the one 25-row slice.
208
226
  app.get('/api/evals', etag(), async (c) => {
209
- ensureBoardFileWatchers()
227
+ const scope = c.req.query('q')?.match(/(?:^|\s)scope:([^\s]+)/)?.[1]
228
+ await ensureBoardFileWatchers(scope)
210
229
  const page = await evalsReview(c.req.query('q'), c.req.query('page'), { view: c.req.query('view') })
211
230
  return page ? c.json(page) : c.json({ error: 'no such review source' }, 404)
212
231
  })
213
232
  // ONE bounded detail response for both source roots: the selected scenario's complete A/B history and at
214
233
  // most five lightweight neighbors. It never serializes another scenario's history or the scoped model.
215
234
  app.get('/api/evals/detail', etag(), async (c) => {
216
- ensureBoardFileWatchers()
235
+ await ensureBoardFileWatchers(c.req.query('scope')?.trim() || undefined)
217
236
  const node = c.req.query('node')?.trim()
218
237
  const scenario = c.req.query('scenario')?.trim()
219
238
  if (!node || !scenario) return c.json({ error: 'node and scenario are required' }, 400)
@@ -385,7 +404,48 @@ app.post('/api/uploads', async (c) => {
385
404
 
386
405
  // sessions: real tmux-backed Claude Code sessions. List + spawn, stream the live pane (WebSocket),
387
406
  // forward keystrokes, and close.
388
- app.get('/api/sessions', async (c) => c.json(await listSessions()))
407
+ app.get('/api/sessions', async (c) => c.json(await listSessions(c.req.query('all') === '1' || c.req.query('all') === 'true')))
408
+ app.get('/api/resources', async (c) => c.json(await collectResourceReport()))
409
+ const maintenance = sessionMaintenance()
410
+ const maintenanceToken = (header: (name: string) => string | undefined) => header('x-spexcode-session-maintenance')?.trim() || ''
411
+ const maintenanceLeaseOwner = () => {
412
+ const instanceId = process.env.SPEXCODE_INSTANCE_ID?.trim()
413
+ const record = instanceId ? readBackendInstanceRecords(repoRoot()).find((candidate) => candidate.instanceId === instanceId) : null
414
+ const exact = record ? exactProcessIdentity(record.pid) : null
415
+ if (!instanceId || !record || !exact || exact.startToken !== record.startToken)
416
+ throw new SessionMaintenanceError('maintenance_identity_unknown', 'cannot prove the exact live backend supervisor generation')
417
+ return { instanceId, pid: record.pid, startToken: record.startToken }
418
+ }
419
+ const operationAuthorization = async (
420
+ header: (name: string) => string | undefined,
421
+ operation: { op: 'stop'; sessionId: string } | { op: 'resume'; sessionId: string; force: boolean },
422
+ ): Promise<Authorization | undefined> => maintenance.authorizeHttpOperation({
423
+ authenticated: true,
424
+ projectMatches: true,
425
+ headers: { [maintenance.headerName]: maintenanceToken(header) },
426
+ operation,
427
+ })
428
+ app.get('/api/session-maintenance', (c) => c.json(maintenance.readState()))
429
+ app.post('/api/session-maintenance/acquire', async (c) => {
430
+ const body = await c.req.json().catch(() => ({}))
431
+ const lease = await maintenance.acquireLease({
432
+ capabilities: Array.isArray(body?.capabilities) ? body.capabilities : body?.capabilities,
433
+ owner: maintenanceLeaseOwner(),
434
+ ttlMs: Number(body?.ttlMs),
435
+ waitMs: Number(body?.waitMs),
436
+ })
437
+ return c.json(lease, lease.state === 'active' ? 201 : 202)
438
+ })
439
+ app.post('/api/session-maintenance/heartbeat', async (c) => {
440
+ const body = await c.req.json().catch(() => ({}))
441
+ await maintenance.heartbeatLease({ token: maintenanceToken(c.req.header.bind(c.req)), epoch: Number(body?.epoch), ttlMs: Number(body?.ttlMs) })
442
+ return c.json({ ok: true, ...maintenance.readState() })
443
+ })
444
+ app.post('/api/session-maintenance/release', async (c) => {
445
+ const body = await c.req.json().catch(() => ({}))
446
+ await maintenance.releaseLease({ token: maintenanceToken(c.req.header.bind(c.req)), epoch: Number(body?.epoch) })
447
+ return c.json({ ok: true })
448
+ })
389
449
  // edges derived live from `spex session watch` monitors (A→B = agent A is watching B), not a stored subscription;
390
450
  // watch/unwatch register + heartbeat. A literal `edges` segment so it never collides with the `:id` routes.
391
451
  app.get('/api/sessions/edges', async (c) => c.json(await sessionGraph()))
@@ -437,19 +497,12 @@ app.get('/api/sessions/:id/timeline', (c) => {
437
497
  const r = readTimeline(c.req.param('id'), Number.isFinite(limit) && limit > 0 ? limit : undefined)
438
498
  return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
439
499
  })
440
- // An adapter may expose its native event log as the console's optional full-process drill-down. REST establishes
441
- // the complete ordered snapshot + byte cursor; SSE follows appends from that cursor (or Last-Event-ID on reconnect).
442
- app.get('/api/sessions/:id/messages', (c) => {
443
- const r = readSessionMessages(c.req.param('id'))
444
- return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
445
- })
446
- app.get('/api/sessions/:id/messages/stream', (c) => sessionMessageStream(c))
447
500
  // the session RECORD detail (`spex session show`): the board row (status · node · branch · launcher · …)
448
501
  // plus the full originating prompt (the row itself carries only the preview). One id-addressed read backs
449
502
  // the CLI's show; 404 for an unknown id.
450
503
  app.get('/api/sessions/:id', async (c) => {
451
504
  const id = c.req.param('id')
452
- const row = (await listSessions()).find((s) => s.id === id)
505
+ const row = (await listSessions(true)).find((s) => s.id === id)
453
506
  if (!row) return c.json({ error: 'no such session' }, 404)
454
507
  return c.json({ ...row, prompt: await sessionPrompt(id) })
455
508
  })
@@ -460,7 +513,9 @@ app.get('/api/sessions/:id', async (c) => {
460
513
  app.post('/api/sessions/:id/resume', async (c) => {
461
514
  const body = await c.req.json().catch(() => ({} as { force?: boolean }))
462
515
  const force = body?.force === true || c.req.query('force') === '1'
463
- const r = await resumeSession(c.req.param('id'), { force })
516
+ const sessionId = c.req.param('id')
517
+ const authorization = await operationAuthorization(c.req.header.bind(c.req), { op: 'resume', sessionId, force })
518
+ const r = await resumeSession(sessionId, { force, authorization })
464
519
  return c.json(r, r.ok ? 200 : (r.refused ? 409 : 404))
465
520
  })
466
521
  // a dispatch to the session's own agent (it runs the merge), never a server merge — the server never touches
@@ -574,12 +629,22 @@ app.post('/api/sessions/:id/input', async (c) => {
574
629
  })
575
630
  // soft stop: kill the agent's tmux + socket but KEEP the worktree (resumable). Distinct from close, which
576
631
  // removes the worktree. {ok:false} = no such session.
577
- app.post('/api/sessions/:id/stop', async (c) => c.json({ ok: await stopSession(c.req.param('id')) }))
632
+ app.post('/api/sessions/:id/stop', async (c) => {
633
+ const sessionId = c.req.param('id')
634
+ const authorization = await operationAuthorization(c.req.header.bind(c.req), { op: 'stop', sessionId })
635
+ return c.json({ ok: await stopSession(sessionId, { authorization }) })
636
+ })
578
637
  app.post('/api/sessions/:id/interrupt', async (c) => {
579
638
  const result = await interruptSession(c.req.param('id'))
580
639
  return c.json(result, result.ok ? 200 : 502)
581
640
  })
582
641
  app.post('/api/sessions/:id/close', async (c) => c.json({ ok: await closeSession(c.req.param('id')) }))
642
+ // archive / legacy unarchive signpost ([[archive]]) — archive proves exact cold/offline ownership before filing;
643
+ // `{on:false}` enters the same resume transition and recreates the preserved conversation. {ok:false}=no such session.
644
+ app.post('/api/sessions/:id/archive', async (c) => {
645
+ const body = await c.req.json().catch(() => ({}))
646
+ return c.json({ ok: await archiveSession(c.req.param('id'), body?.on !== false) })
647
+ })
583
648
  // set (or clear, with a blank) a session's display-name override; persists to the session's global record
584
649
  // (`session.json`) so it survives a restart. Unknown id → 404. That record sits INSIDE the watched store, but
585
650
  // the store watch is best-effort (it can fail to attach), so the route still nudges the stream explicitly
@@ -622,10 +687,19 @@ superviseQueue() // launch queued sessions as slots free (catches agent-auth
622
687
  superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
623
688
  console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
624
689
 
690
+ let graphWatchersClosed = false
691
+ const closeGraphWatchers = (): void => {
692
+ if (graphWatchersClosed) return
693
+ graphWatchersClosed = true
694
+ closeBoardFileWatchers()
695
+ }
696
+ process.once('exit', closeGraphWatchers)
697
+
625
698
  // graceful drain (the other half of zero-downtime reload, supervise.ts): on SIGTERM stop accepting new
626
699
  // connections, let in-flight requests finish, and sweep now-idle keep-alive sockets so close() fires the
627
700
  // instant the last request drains. A hard cap still forces exit if a connection won't close.
628
701
  process.on('SIGTERM', () => {
702
+ closeGraphWatchers()
629
703
  const srv = server as unknown as { close(cb?: () => void): void; closeIdleConnections?(): void }
630
704
  const sweep = setInterval(() => srv.closeIdleConnections?.(), 200)
631
705
  srv.close(() => { clearInterval(sweep); process.exit(0) })
@@ -1,4 +1,5 @@
1
- import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, chmodSync, writeFileSync } from 'node:fs'
1
+ import { existsSync, mkdirSync, copyFileSync, readFileSync, readdirSync, renameSync, rmSync, statSync, chmodSync, writeFileSync } from 'node:fs'
2
+ import { createHash } from 'node:crypto'
2
3
  import { join, resolve, relative } from 'node:path'
3
4
  import { fileURLToPath } from 'node:url'
4
5
  import { execFileSync } from 'node:child_process'
@@ -9,6 +10,32 @@ import { resolveHarnessTargets, parseHarnessFlag, NATIVE_HARNESS_IDS } from './h
9
10
  // launch paths use, never a hardcoded repo path (so a relocated/installed package still finds its data).
10
11
  const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
11
12
  const TEMPLATES = join(pkgRoot, 'templates')
13
+ const MANAGED_HOOK_HEADER = '# spexcode-managed-hook-v1'
14
+ const LEGACY_MANAGED_HOOKS: Record<string, Set<string>> = {
15
+ 'pre-commit': new Set(['fc33a6ff2b444fa47210a2594b6a714613d9c62fd8ec77a9a13aec81143de8a7']),
16
+ 'prepare-commit-msg': new Set(['08b26ad8ffb305a64b7cddc868231854c84d539b8e5e9324702bd2c293044183']),
17
+ }
18
+
19
+ function hookDigest(path: string): string {
20
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
21
+ }
22
+
23
+ function isManagedHook(path: string, name: string): boolean {
24
+ const source = readFileSync(path, 'utf8')
25
+ return source.split('\n').slice(0, 4).includes(MANAGED_HOOK_HEADER)
26
+ || LEGACY_MANAGED_HOOKS[name]?.has(createHash('sha256').update(source).digest('hex')) === true
27
+ }
28
+
29
+ function replaceHook(source: string, dest: string): void {
30
+ const temp = `${dest}.spexcode-${process.pid}`
31
+ try {
32
+ copyFileSync(source, temp)
33
+ chmodSync(temp, 0o755)
34
+ renameSync(temp, dest)
35
+ } finally {
36
+ rmSync(temp, { force: true })
37
+ }
38
+ }
12
39
 
13
40
  // the cumulative preset chain, lean → cautious (see [[init-preset]]). `default` is the live `.plugins`
14
41
  // instance set (planted from templates/spec); a higher tier would be a SEPARATE package under
@@ -149,7 +176,8 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
149
176
  console.log(`✓ planted spexcode.json — harnesses ${JSON.stringify(chosenHarnesses)}, launchers ${JSON.stringify(Object.keys(cfg.sessions?.launchers ?? {}))}; lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)`)
150
177
  }
151
178
 
152
- // 2. install the git hooks: templates/hooks/* -> <repo>/<common-git-dir>/hooks/* (skip any that exist).
179
+ // 2. install the git hooks. Unknown existing hooks are user-owned and stay byte-identical. An exact
180
+ // SpexCode snapshot is refreshed because a multi-hook protocol cannot mix old and new generations.
153
181
  const hooksDir = resolveHooksDir(targetDir)
154
182
  if (!hooksDir) {
155
183
  console.warn(`• ${targetDir} is not a git repository — skipped hook install. Run \`git init\` there, then \`spex init\` again (or \`npm run hooks\`).`)
@@ -157,18 +185,27 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
157
185
  mkdirSync(hooksDir, { recursive: true })
158
186
  const hooksSrc = join(TEMPLATES, 'hooks')
159
187
  const installed: string[] = []
188
+ const refreshed: string[] = []
160
189
  for (const e of readdirSync(hooksSrc, { withFileTypes: true })) {
161
190
  if (!e.isFile()) continue
191
+ const source = join(hooksSrc, e.name)
162
192
  const dest = join(hooksDir, e.name)
163
193
  if (existsSync(dest)) {
164
- console.warn(`• hook ${e.name} already exists in ${hooksDir} — left untouched.`)
194
+ if (hookDigest(dest) === hookDigest(source)) continue
195
+ if (isManagedHook(dest, e.name)) {
196
+ replaceHook(source, dest)
197
+ refreshed.push(e.name)
198
+ } else {
199
+ console.warn(`• user hook ${e.name} already exists in ${hooksDir} — left untouched.`)
200
+ }
165
201
  continue
166
202
  }
167
- copyFileSync(join(hooksSrc, e.name), dest)
203
+ copyFileSync(source, dest)
168
204
  chmodSync(dest, 0o755)
169
205
  installed.push(e.name)
170
206
  }
171
207
  if (installed.length) console.log(`✓ installed git hooks (${installed.join(', ')}) → ${hooksDir}`)
208
+ if (refreshed.length) console.log(`✓ refreshed SpexCode hook snapshots (${refreshed.join(', ')}) → ${hooksDir}`)
172
209
  }
173
210
 
174
211
  // 2c. MATERIALIZE the harness-discovered artifacts so a USER-self-launched harness works with zero further
@@ -196,13 +233,15 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
196
233
  const rootsNow = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
197
234
  console.log(`
198
235
  Next steps:
199
- 1. Edit .spec/project/spec.md to describe YOUR project, then grow child nodes beneath it.
200
- 2. lint.governedRoots in spexcode.json (currently ${rootsNow}) names what \`spex spec lint\` governs —
236
+ 1. The seeded .spec/ tree and spexcode.json are project source of truth: add and commit them. Generated
237
+ harness artifacts are machine-local and can stay untracked.
238
+ 2. Edit .spec/project/spec.md to describe YOUR project, then grow child nodes beneath it.
239
+ 3. lint.governedRoots in spexcode.json (currently ${rootsNow}) names what \`spex spec lint\` governs —
201
240
  ["."] governs the whole git-tracked tree (tests excluded); narrow it to explicit source roots
202
241
  when you want a curated graph.
203
- 3. Start the backend and open the dashboard:
242
+ 4. Start the backend and open the dashboard:
204
243
  spex serve # http://localhost:8787
205
- 4. \`spex spec lint\` should report 0 errors. Coverage warnings are your adoption TODO (source files no
244
+ 5. \`spex spec lint\` should report 0 errors. Coverage warnings are your adoption TODO (source files no
206
245
  spec node claims yet). You're adopting SpexCode — the spec tree is now ground truth.
207
246
  (On a fresh CLONE, re-run \`spex init\` — git never clones .git/hooks/, and the harness shims are
208
247
  gitignored machine-local files that regenerate per-machine.)`)
@@ -140,10 +140,43 @@ export function fromForge(slice: ForgeSlice, nodeIds: string[]): Issue[] {
140
140
  // board issue badge, the `spex issue ls` drain — is free of them by construction (they reach the EVAL side
141
141
  // through loadEvalRemarkTracks / the reading overlay instead).
142
142
  export function mergedIssues(forge: ForgeSlice | null, nodeIds: string[]): Issue[] {
143
+ return allThreads(forge, nodeIds).filter((i) => !isEvalConcern(i.concern))
144
+ }
145
+
146
+ // the same one merged read BEFORE the read-time split: every thread in the store, both halves, one walk.
147
+ // Deliberately NOT exported — the split above is what every SURFACE read owes ([[eval-issue-split]]), and
148
+ // an unsplit set escaping to a surface would put eval remarks back in the issue drain. The one consumer
149
+ // whose question is about the STORE ITSELF reaches it through boardThreads below.
150
+ function allThreads(forge: ForgeSlice | null, nodeIds: string[]): Issue[] {
143
151
  const remote = forge ? fromForge(forge, nodeIds) : []
144
- return [...loadLocalIssues(), ...remote]
145
- .filter((i) => !isEvalConcern(i.concern))
146
- .sort((a, b) => b.created.localeCompare(a.created))
152
+ return [...loadLocalIssues(), ...remote].sort((a, b) => b.created.localeCompare(a.created))
153
+ }
154
+
155
+ // @@@ boardThreads - the board's ONE store read ([[graph-lean]]'s fold and [[remark-substrate]]'s carrier
156
+ // from one walk). It hands back the SPLIT issue population every issue surface consumes and the freshness
157
+ // stamp folded over the WHOLE store, together, because the two answer different questions and must not be
158
+ // derived from each other: the split is a SURFACE question (which page renders this thread), the stamp is a
159
+ // STORE question (was anything written at all). Folding the stamp from whatever the surface read returned
160
+ // is exactly the confusion that left an open reading blind to every remark on it — a scenario-hosted remark
161
+ // moved no board byte, so no push ever fired. Pairing them here means no caller can pick the wrong set.
162
+ export function boardThreads(forge: ForgeSlice | null, nodeIds: string[]): { issues: Issue[]; stamp: string } {
163
+ const threads = allThreads(forge, nodeIds)
164
+ return { issues: threads.filter((i) => !isEvalConcern(i.concern)), stamp: threadStamp(threads) }
165
+ }
166
+
167
+ // @@@ threadStamp - the board's freshness carrier over a thread set ([[remark-substrate]] write-visibility):
168
+ // open-count : thread-count : reply-count : latest-activity. Every thread write — open, reply, remark,
169
+ // resolve, retract, close — moves at least one component, so a store write ALWAYS moves board bytes and
170
+ // [[graph-delta]]'s no-change suppression can never swallow the push. Pure over the set it is handed, which
171
+ // is what makes "does THIS write move it" a test rather than a claim; the caller owes it the WHOLE store
172
+ // (allThreads), because a set missing a half is a carrier blind to that half.
173
+ export function threadStamp(threads: Issue[]): string {
174
+ return [
175
+ threads.filter((i) => i.status === 'open').length,
176
+ threads.length,
177
+ threads.reduce((n, i) => n + i.replies.length, 0),
178
+ threads.flatMap((i) => [i.created, ...i.replies.flatMap((r) => [r.at, r.resolvedAt ?? ''])]).reduce((a, b) => (b > a ? b : a), ''),
179
+ ].join(':')
147
180
  }
148
181
 
149
182
  // @@@ createIssue - the ONE creation port, store-routed ([[issues]]): the dashboard's New form