spexcode 0.5.2 → 0.5.4

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 +15 -4
  17. package/spec-cli/src/harness.ts +1133 -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 +698 -0
  21. package/spec-cli/src/index.ts +95 -21
  22. package/spec-cli/src/init.ts +73 -15
  23. package/spec-cli/src/issues.ts +36 -3
  24. package/spec-cli/src/layout.ts +189 -41
  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 +174 -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 +88 -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 +1251 -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 +29 -15
  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,5 +1,6 @@
1
- import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, chmodSync, writeFileSync } from 'node:fs'
2
- import { join, resolve, relative } from 'node:path'
1
+ import { existsSync, mkdirSync, copyFileSync, readFileSync, readdirSync, renameSync, rmSync, statSync, chmodSync, writeFileSync } from 'node:fs'
2
+ import { createHash } from 'node:crypto'
3
+ import { join, resolve, relative, dirname } from 'node:path'
3
4
  import { fileURLToPath } from 'node:url'
4
5
  import { execFileSync } from 'node:child_process'
5
6
  import { readConfig, readJsonConfig } from './layout.js'
@@ -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
@@ -54,6 +81,20 @@ function resolveHooksDir(dir: string): string | null {
54
81
  }
55
82
  }
56
83
 
84
+ // Detect once, at adoption, while the root checkout still names the branch being adopted. Steady-state
85
+ // layout never re-derives trunk identity from a mutable checkout (an ordinary `git switch node/x` must not
86
+ // turn that feature branch into trunk).
87
+ function adoptionMainBranch(dir: string): string {
88
+ try {
89
+ const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8' }).trim()
90
+ const branch = execFileSync('git', ['-C', dirname(common), 'symbolic-ref', '--short', 'HEAD'], { encoding: 'utf8' }).trim()
91
+ if (branch) return branch
92
+ } catch { /* render the one product-level repair below */ }
93
+ const error = new Error('cannot determine the source-of-truth branch at adoption — check out the trunk in the root checkout, or set "mainBranch" in spexcode.json before re-running `spex init`')
94
+ error.name = 'ConfigError'
95
+ throw error
96
+ }
97
+
57
98
  export async function specInit(targetArg: string | undefined, presetArg?: string, harnessArg?: string): Promise<void> {
58
99
  const targetDir = resolve(targetArg ?? process.cwd())
59
100
 
@@ -126,18 +167,23 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
126
167
  const cfgDest = join(targetDir, 'spexcode.json')
127
168
  const nativeChosen = (chosenHarnesses as unknown[]).filter((m): m is string => typeof m === 'string')
128
169
  if (existsSync(cfgDest)) {
129
- if (flagRaw) {
130
- // an explicit --harness on a re-init is a deliberate command: restamp THAT field, touch nothing else.
131
- const cfg = (readJsonConfig(cfgDest) ?? {}) as Record<string, unknown>
132
- cfg.harnesses = flagRaw
170
+ const cfg = (readJsonConfig(cfgDest) ?? {}) as Record<string, unknown>
171
+ const stampedBranch = typeof cfg.mainBranch !== 'string' || !cfg.mainBranch.trim()
172
+ if (stampedBranch) cfg.mainBranch = adoptionMainBranch(targetDir)
173
+ if (flagRaw) cfg.harnesses = flagRaw
174
+ if (flagRaw || stampedBranch) {
133
175
  writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
134
- console.log(`✓ stamped "harnesses": ${JSON.stringify(flagRaw)} into the existing spexcode.json (other fields untouched)`)
176
+ console.log(`✓ stamped ${[
177
+ flagRaw ? `"harnesses": ${JSON.stringify(flagRaw)}` : '',
178
+ stampedBranch ? `"mainBranch": ${JSON.stringify(cfg.mainBranch)}` : '',
179
+ ].filter(Boolean).join(' and ')} into the existing spexcode.json (other fields untouched)`)
135
180
  } else {
136
181
  console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched (harnesses: ${JSON.stringify(chosenHarnesses)}).`)
137
182
  }
138
183
  } else {
139
184
  const cfg = (readJsonConfig(join(TEMPLATES, 'spexcode.json')) ?? {}) as Record<string, any>
140
185
  cfg.harnesses = chosenHarnesses
186
+ cfg.mainBranch = adoptionMainBranch(targetDir)
141
187
  if (nativeChosen.length && cfg.sessions?.launchers) {
142
188
  cfg.sessions.launchers = Object.fromEntries(
143
189
  Object.entries(cfg.sessions.launchers as Record<string, { harness?: string }>).filter(([, l]) => nativeChosen.includes(l.harness ?? 'claude')))
@@ -146,10 +192,11 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
146
192
  }
147
193
  writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
148
194
  const roots = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
149
- 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)`)
195
+ console.log(`✓ planted spexcode.json — mainBranch ${JSON.stringify(cfg.mainBranch)}, 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
196
  }
151
197
 
152
- // 2. install the git hooks: templates/hooks/* -> <repo>/<common-git-dir>/hooks/* (skip any that exist).
198
+ // 2. install the git hooks. Unknown existing hooks are user-owned and stay byte-identical. An exact
199
+ // SpexCode snapshot is refreshed because a multi-hook protocol cannot mix old and new generations.
153
200
  const hooksDir = resolveHooksDir(targetDir)
154
201
  if (!hooksDir) {
155
202
  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 +204,27 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
157
204
  mkdirSync(hooksDir, { recursive: true })
158
205
  const hooksSrc = join(TEMPLATES, 'hooks')
159
206
  const installed: string[] = []
207
+ const refreshed: string[] = []
160
208
  for (const e of readdirSync(hooksSrc, { withFileTypes: true })) {
161
209
  if (!e.isFile()) continue
210
+ const source = join(hooksSrc, e.name)
162
211
  const dest = join(hooksDir, e.name)
163
212
  if (existsSync(dest)) {
164
- console.warn(`• hook ${e.name} already exists in ${hooksDir} — left untouched.`)
213
+ if (hookDigest(dest) === hookDigest(source)) continue
214
+ if (isManagedHook(dest, e.name)) {
215
+ replaceHook(source, dest)
216
+ refreshed.push(e.name)
217
+ } else {
218
+ console.warn(`• user hook ${e.name} already exists in ${hooksDir} — left untouched.`)
219
+ }
165
220
  continue
166
221
  }
167
- copyFileSync(join(hooksSrc, e.name), dest)
222
+ copyFileSync(source, dest)
168
223
  chmodSync(dest, 0o755)
169
224
  installed.push(e.name)
170
225
  }
171
226
  if (installed.length) console.log(`✓ installed git hooks (${installed.join(', ')}) → ${hooksDir}`)
227
+ if (refreshed.length) console.log(`✓ refreshed SpexCode hook snapshots (${refreshed.join(', ')}) → ${hooksDir}`)
172
228
  }
173
229
 
174
230
  // 2c. MATERIALIZE the harness-discovered artifacts so a USER-self-launched harness works with zero further
@@ -196,13 +252,15 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
196
252
  const rootsNow = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
197
253
  console.log(`
198
254
  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 —
255
+ 1. The seeded .spec/ tree and spexcode.json are project source of truth: add and commit them. Generated
256
+ harness artifacts are machine-local and can stay untracked.
257
+ 2. Edit .spec/project/spec.md to describe YOUR project, then grow child nodes beneath it.
258
+ 3. lint.governedRoots in spexcode.json (currently ${rootsNow}) names what \`spex spec lint\` governs —
201
259
  ["."] governs the whole git-tracked tree (tests excluded); narrow it to explicit source roots
202
260
  when you want a curated graph.
203
- 3. Start the backend and open the dashboard:
261
+ 4. Start the backend and open the dashboard:
204
262
  spex serve # http://localhost:8787
205
- 4. \`spex spec lint\` should report 0 errors. Coverage warnings are your adoption TODO (source files no
263
+ 5. \`spex spec lint\` should report 0 errors. Coverage warnings are your adoption TODO (source files no
206
264
  spec node claims yet). You're adopting SpexCode — the spec tree is now ground truth.
207
265
  (On a fresh CLONE, re-run \`spex init\` — git never clones .git/hooks/, and the harness shims are
208
266
  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