spexcode 0.6.7 → 0.6.8

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 (205) hide show
  1. package/README.md +12 -7
  2. package/node_modules/@spexcode/session-application/dist/index.d.ts +17 -0
  3. package/node_modules/@spexcode/session-application/dist/index.js +35 -0
  4. package/node_modules/@spexcode/session-application/dist/migration.d.ts +51 -0
  5. package/node_modules/@spexcode/session-application/dist/migration.js +694 -0
  6. package/node_modules/@spexcode/session-application/dist/production.d.ts +109 -0
  7. package/node_modules/@spexcode/session-application/dist/production.js +438 -0
  8. package/node_modules/@spexcode/session-application/dist/schema.d.ts +2 -0
  9. package/node_modules/@spexcode/session-application/dist/schema.js +39 -0
  10. package/node_modules/@spexcode/session-application/package.json +29 -0
  11. package/node_modules/@spexcode/session-events/dist/errors.d.ts +6 -0
  12. package/node_modules/@spexcode/session-events/dist/errors.js +11 -0
  13. package/node_modules/@spexcode/session-events/dist/index.d.ts +46 -0
  14. package/node_modules/@spexcode/session-events/dist/index.js +245 -0
  15. package/node_modules/@spexcode/session-events/dist/schema.d.ts +3 -0
  16. package/node_modules/@spexcode/session-events/dist/schema.js +44 -0
  17. package/node_modules/@spexcode/session-events/package.json +24 -0
  18. package/node_modules/@spexcode/session-protocol/dist/canonical.d.ts +36 -0
  19. package/node_modules/@spexcode/session-protocol/dist/canonical.js +139 -0
  20. package/node_modules/@spexcode/session-protocol/dist/engine.d.ts +22 -0
  21. package/node_modules/@spexcode/session-protocol/dist/engine.js +478 -0
  22. package/node_modules/@spexcode/session-protocol/dist/errors.d.ts +8 -0
  23. package/node_modules/@spexcode/session-protocol/dist/errors.js +39 -0
  24. package/node_modules/@spexcode/session-protocol/dist/index.d.ts +60 -0
  25. package/node_modules/@spexcode/session-protocol/dist/index.js +4 -0
  26. package/node_modules/@spexcode/session-protocol/dist/schema.d.ts +36 -0
  27. package/node_modules/@spexcode/session-protocol/dist/schema.js +192 -0
  28. package/node_modules/@spexcode/{session-core → session-protocol}/package.json +4 -8
  29. package/node_modules/@spexcode/session-runtime/dist/errors.d.ts +6 -0
  30. package/node_modules/@spexcode/session-runtime/dist/errors.js +11 -0
  31. package/node_modules/@spexcode/session-runtime/dist/index.d.ts +33 -0
  32. package/node_modules/@spexcode/session-runtime/dist/index.js +193 -0
  33. package/node_modules/@spexcode/session-runtime/dist/schema.d.ts +2 -0
  34. package/node_modules/@spexcode/session-runtime/dist/schema.js +37 -0
  35. package/node_modules/@spexcode/session-runtime/package.json +24 -0
  36. package/node_modules/@spexcode/session-selflaunch/bin/spex-session.mjs +4 -0
  37. package/node_modules/@spexcode/session-selflaunch/dist/cli.d.ts +25 -0
  38. package/node_modules/@spexcode/session-selflaunch/dist/cli.js +156 -0
  39. package/node_modules/@spexcode/session-selflaunch/dist/index.d.ts +28 -0
  40. package/node_modules/@spexcode/session-selflaunch/dist/index.js +25 -0
  41. package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +20 -0
  42. package/node_modules/@spexcode/session-selflaunch/dist/locality.js +83 -0
  43. package/node_modules/@spexcode/session-selflaunch/dist/path.d.ts +13 -0
  44. package/node_modules/@spexcode/session-selflaunch/dist/path.js +45 -0
  45. package/node_modules/@spexcode/session-selflaunch/package.json +35 -0
  46. package/node_modules/@spexcode/session-topology/dist/errors.d.ts +6 -0
  47. package/node_modules/@spexcode/session-topology/dist/errors.js +11 -0
  48. package/node_modules/@spexcode/session-topology/dist/index.d.ts +24 -0
  49. package/node_modules/@spexcode/session-topology/dist/index.js +173 -0
  50. package/node_modules/@spexcode/session-topology/dist/schema.d.ts +3 -0
  51. package/node_modules/@spexcode/session-topology/dist/schema.js +27 -0
  52. package/node_modules/@spexcode/session-topology/package.json +24 -0
  53. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +56 -19
  54. package/node_modules/@spexcode/spec-cli/dist/cli.js +102 -59
  55. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +1 -3
  56. package/node_modules/@spexcode/spec-cli/dist/client.js +49 -30
  57. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +11 -0
  58. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +46 -9
  59. package/node_modules/@spexcode/spec-cli/dist/delivery-lock.d.ts +2 -0
  60. package/node_modules/@spexcode/spec-cli/dist/delivery-lock.js +58 -0
  61. package/node_modules/@spexcode/spec-cli/dist/doctor.js +53 -11
  62. package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +1 -0
  63. package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +2 -2
  64. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +2 -1
  65. package/node_modules/@spexcode/spec-cli/dist/gateway.js +6 -3
  66. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +32 -2
  67. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +57 -2
  68. package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +2 -0
  69. package/node_modules/@spexcode/spec-cli/dist/graphStream.js +83 -3
  70. package/node_modules/@spexcode/spec-cli/dist/guide.js +20 -7
  71. package/node_modules/@spexcode/spec-cli/dist/harness-select.js +16 -3
  72. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +15 -3
  73. package/node_modules/@spexcode/spec-cli/dist/harness.js +331 -50
  74. package/node_modules/@spexcode/spec-cli/dist/help.js +11 -8
  75. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +8 -0
  76. package/node_modules/@spexcode/spec-cli/dist/host-resources.js +29 -8
  77. package/node_modules/@spexcode/spec-cli/dist/host.d.ts +7 -0
  78. package/node_modules/@spexcode/spec-cli/dist/host.js +93 -0
  79. package/node_modules/@spexcode/spec-cli/dist/index.js +324 -22
  80. package/node_modules/@spexcode/spec-cli/dist/init.js +1 -1
  81. package/node_modules/@spexcode/spec-cli/dist/lint.js +70 -35
  82. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +3 -2
  83. package/node_modules/@spexcode/spec-cli/dist/listen.js +14 -2
  84. package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +1 -1
  85. package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +2 -2
  86. package/node_modules/@spexcode/spec-cli/dist/materialize.js +176 -35
  87. package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +14 -14
  88. package/node_modules/@spexcode/spec-cli/dist/reviews.js +12 -7
  89. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +11 -0
  90. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +79 -1
  91. package/node_modules/@spexcode/spec-cli/dist/session-application.d.ts +23 -0
  92. package/node_modules/@spexcode/spec-cli/dist/session-application.js +189 -0
  93. package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +13 -1
  94. package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +6 -0
  95. package/node_modules/@spexcode/spec-cli/dist/session-files.js +13 -1
  96. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +39 -22
  97. package/node_modules/@spexcode/{session-core/dist/record-lock.d.ts → spec-cli/dist/session-record-lock.d.ts} +0 -4
  98. package/node_modules/@spexcode/spec-cli/dist/session-record-lock.js +94 -0
  99. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.d.ts +44 -0
  100. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.js +37 -0
  101. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +25 -2
  102. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +68 -11
  103. package/node_modules/@spexcode/spec-cli/dist/session-web.js +4 -4
  104. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +108 -15
  105. package/node_modules/@spexcode/spec-cli/dist/sessions.js +1465 -744
  106. package/node_modules/@spexcode/spec-cli/dist/source-list.d.ts +13 -0
  107. package/node_modules/@spexcode/spec-cli/dist/source-list.js +99 -0
  108. package/node_modules/@spexcode/spec-cli/dist/source-read.d.ts +16 -0
  109. package/node_modules/@spexcode/spec-cli/dist/source-read.js +84 -0
  110. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.d.ts +7 -0
  111. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.js +89 -0
  112. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.d.ts +23 -0
  113. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.js +138 -0
  114. package/node_modules/@spexcode/spec-cli/dist/supervise.js +15 -6
  115. package/node_modules/@spexcode/spec-cli/dist/transcript-reader.d.ts +36 -0
  116. package/node_modules/@spexcode/spec-cli/dist/transcript-reader.js +251 -0
  117. package/node_modules/@spexcode/spec-cli/hooks/dispatch.sh +19 -31
  118. package/node_modules/@spexcode/spec-cli/hooks/harness.sh +6 -6
  119. package/node_modules/@spexcode/spec-cli/package.json +6 -6
  120. package/node_modules/@spexcode/spec-cli/templates/hooks/post-checkout +4 -2
  121. package/node_modules/@spexcode/spec-cli/templates/hooks/post-merge +2 -1
  122. package/node_modules/@spexcode/spec-cli/templates/hooks/pre-commit +5 -3
  123. package/node_modules/@spexcode/spec-cli/templates/hooks/reference-transaction +5 -3
  124. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/spec.md +2 -7
  125. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
  126. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  127. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
  128. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
  129. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
  130. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  131. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
  132. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
  133. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -0
  134. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  135. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
  136. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
  137. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/spec.md +2 -6
  138. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/spec.md +7 -0
  139. package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +13 -2
  140. package/node_modules/@spexcode/spec-core/dist/anchors.js +311 -15
  141. package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
  142. package/node_modules/@spexcode/spec-core/dist/git.js +9 -9
  143. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +12 -0
  144. package/node_modules/@spexcode/spec-core/dist/layout.js +4 -2
  145. package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +1 -0
  146. package/node_modules/@spexcode/spec-core/dist/resilience.js +8 -0
  147. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +1 -0
  148. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +7 -3
  149. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +1 -0
  150. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +4 -0
  151. package/node_modules/@spexcode/spec-core/dist/specs.d.ts +2 -0
  152. package/node_modules/@spexcode/spec-core/dist/specs.js +30 -0
  153. package/node_modules/@spexcode/spec-core/package.json +4 -1
  154. package/node_modules/@spexcode/spec-eval/dist/cli.js +1 -1
  155. package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +1 -0
  156. package/node_modules/@spexcode/spec-eval/dist/evaltab.js +6 -1
  157. package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +1 -1
  158. package/node_modules/@spexcode/spec-eval/dist/freshness.js +12 -12
  159. package/node_modules/@spexcode/spec-eval/dist/host.d.ts +2 -1
  160. package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +2 -1
  161. package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +20 -3
  162. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  163. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  164. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  165. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  166. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  167. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  168. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  169. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  170. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  171. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  172. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  173. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  174. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  175. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  176. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  177. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  178. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  179. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  180. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  181. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  182. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  183. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  184. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  185. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  186. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  187. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  188. package/package.json +9 -3
  189. package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +0 -24
  190. package/node_modules/@spexcode/session-core/dist/delivery-queue.js +0 -206
  191. package/node_modules/@spexcode/session-core/dist/index.d.ts +0 -5
  192. package/node_modules/@spexcode/session-core/dist/index.js +0 -5
  193. package/node_modules/@spexcode/session-core/dist/internal.d.ts +0 -3
  194. package/node_modules/@spexcode/session-core/dist/internal.js +0 -3
  195. package/node_modules/@spexcode/session-core/dist/message.d.ts +0 -22
  196. package/node_modules/@spexcode/session-core/dist/message.js +0 -53
  197. package/node_modules/@spexcode/session-core/dist/record-lock.js +0 -152
  198. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +0 -62
  199. package/node_modules/@spexcode/session-core/dist/runtime-session.js +0 -326
  200. package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +0 -14
  201. package/node_modules/@spexcode/session-core/dist/session-cursors.js +0 -82
  202. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +0 -47
  203. package/node_modules/@spexcode/session-core/dist/session-timeline.js +0 -216
  204. package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
  205. package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-sed-v0.fixture +0 -46
@@ -82,6 +82,16 @@ export function readCodexGenerationLedger(root) {
82
82
  return emptyLedger();
83
83
  return parseLedger(JSON.parse(readFileSync(ledgerPath(root), 'utf8')));
84
84
  }
85
+ // The backend startup sweep must see legacy roots too. Bootstrap only when the legacy residue is present; an
86
+ // empty project remains an empty ledger and no runtime is created by inspection.
87
+ export async function ensureCodexGenerationLedger(root) {
88
+ return withLedgerLock(root, async () => {
89
+ const current = readCodexGenerationLedger(root);
90
+ if (existsSync(ledgerPath(root)) || current.revision !== 0)
91
+ return current;
92
+ return writeLedger(root, current, bootstrapLedger(root));
93
+ });
94
+ }
85
95
  function writeLedger(root, previous, next) {
86
96
  const observed = readCodexGenerationLedger(root);
87
97
  if (observed.revision !== previous.revision)
@@ -244,6 +254,12 @@ async function withLedgerLock(root, body) {
244
254
  releaseLedgerLock(root, owner);
245
255
  }
246
256
  }
257
+ // Codex accepts only a bounded amount of concurrent thread/start work. The generation ledger lock is already
258
+ // the cross-process project mutex with exact stale-owner recovery, so use it for native thread admission too.
259
+ // This keeps launch serialization in the runtime owner rather than making callers invent their own queue.
260
+ export async function withCodexGenerationMutex(root, body) {
261
+ return withLedgerLock(root, body);
262
+ }
247
263
  function withLedgerLockSync(root, body) {
248
264
  const owner = acquireLedgerLockSync(root);
249
265
  try {
@@ -317,7 +333,7 @@ function bootstrapBindings(root, generationId) {
317
333
  }
318
334
  for (const sessionId of ids) {
319
335
  try {
320
- const record = JSON.parse(readFileSync(join(sessions, sessionId, 'session.json'), 'utf8'));
336
+ const record = JSON.parse(readFileSync(join(sessions, sessionId, 'runtime.json'), 'utf8'));
321
337
  const harness = record.harness;
322
338
  const threadId = record.harness_session_id;
323
339
  if (record.governed === true && (harness === 'codex' || harness === 'codex-headless') && typeof threadId === 'string' && threadId) {
@@ -328,20 +344,28 @@ function bootstrapBindings(root, generationId) {
328
344
  }
329
345
  return result;
330
346
  }
331
- function pendingBindingHasRecord(root, sessionId, binding) {
347
+ function readBindingRecord(root, sessionId) {
332
348
  if (!sessionId || sessionId.includes('/') || sessionId.includes('\\'))
333
- return false;
349
+ return null;
334
350
  try {
335
- const record = JSON.parse(readFileSync(join(root, 'sessions', sessionId, 'session.json'), 'utf8'));
336
- const harness = record.harness;
337
- return record.governed === true && (harness === 'codex' || harness === 'codex-headless') && record.harness_session_id === binding.threadId;
351
+ return JSON.parse(readFileSync(join(root, 'sessions', sessionId, 'runtime.json'), 'utf8'));
338
352
  }
339
353
  catch {
340
- return false;
354
+ return null;
341
355
  }
342
356
  }
357
+ function bindingRecordMatches(record, binding) {
358
+ const harness = record?.harness;
359
+ return !!record && record.governed === true && (harness === 'codex' || harness === 'codex-headless') && record.harness_session_id === binding.threadId;
360
+ }
343
361
  function bindingProtectsGeneration(root, sessionId, binding) {
344
- return binding.phase === undefined || pendingBindingHasRecord(root, sessionId, binding);
362
+ // A close transaction remains protective until its record is physically removed. Once a historical
363
+ // record is archived, its durable rollout is still recoverable but the record no longer routes or pins
364
+ // a live generation; the native census remains the independent guard for any still-loaded thread.
365
+ const record = readBindingRecord(root, sessionId);
366
+ if (!record)
367
+ return binding.phase !== 'record-pending' && binding.phase !== 'record-removing';
368
+ return bindingRecordMatches(record, binding) && (binding.phase === 'record-removing' || record.archived !== true);
345
369
  }
346
370
  function bootstrapLedger(root) {
347
371
  const legacy = legacyCodexGenerationEndpoint(root);
@@ -626,7 +650,7 @@ export function bindCodexGeneration(root, sessionId, threadId, generationId) {
626
650
  writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings });
627
651
  });
628
652
  }
629
- // Native thread creation and session.json persistence are separate writes. A pending registration can be
653
+ // Native thread creation and runtime envelope persistence are separate writes. A pending registration can be
630
654
  // recovered only when the durable record names the same exact thread; otherwise it cannot pin a root forever.
631
655
  export function prepareCodexGenerationRegistration(root, sessionId, threadId, generationId) {
632
656
  withLedgerLockSync(root, () => {
@@ -796,3 +820,16 @@ export async function reclaimDrainingCodexGeneration(root, generationId, census)
796
820
  return { reclaimed: true, reason: 'exact drained generation reclaimed after zero-reference census' };
797
821
  });
798
822
  }
823
+ // Startup/rotation callers use this sweep to make stale generation cleanup a property of the shared runtime
824
+ // owner. The census remains adapter-supplied and fail-closed; a missing or unhealthy descriptor is retained.
825
+ export async function reclaimDrainingCodexGenerations(root, census) {
826
+ const generations = readCodexGenerationLedger(root).generations;
827
+ const results = [];
828
+ for (const [generationId, generation] of Object.entries(generations)) {
829
+ if (generation.state !== 'draining')
830
+ continue;
831
+ const result = await reclaimDrainingCodexGeneration(root, generationId, census);
832
+ results.push({ generationId, ...result });
833
+ }
834
+ return results;
835
+ }
@@ -0,0 +1,2 @@
1
+ /** Serialize delivery claims without making the transport itself part of SQLite. */
2
+ export declare function withDeliveryLocks<T>(rawIds: string[], body: () => Promise<T>, index?: number, ids?: string[]): Promise<T>;
@@ -0,0 +1,58 @@
1
+ import { mkdirSync, openSync, readFileSync, unlinkSync, writeSync, closeSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runtimeRoot } from '@spexcode/spec-core';
4
+ const lockRoot = () => join(runtimeRoot(), '.delivery-locks');
5
+ const lockPath = (id) => join(lockRoot(), `${id}.lock`);
6
+ const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
7
+ async function acquire(id, timeoutMs) {
8
+ mkdirSync(lockRoot(), { recursive: true });
9
+ const path = lockPath(id), deadline = Date.now() + timeoutMs;
10
+ for (;;) {
11
+ try {
12
+ const fd = openSync(path, 'wx');
13
+ writeSync(fd, String(process.pid));
14
+ closeSync(fd);
15
+ return () => { try {
16
+ unlinkSync(path);
17
+ }
18
+ catch { /* a dead owner may have reclaimed it */ } };
19
+ }
20
+ catch (error) {
21
+ if (error.code !== 'EEXIST')
22
+ throw error;
23
+ let owner = 0;
24
+ try {
25
+ owner = Number(readFileSync(path, 'utf8').trim()) || 0;
26
+ }
27
+ catch { /* creator/releaser race */ }
28
+ if (owner && owner !== process.pid) {
29
+ try {
30
+ process.kill(owner, 0);
31
+ }
32
+ catch {
33
+ try {
34
+ unlinkSync(path);
35
+ }
36
+ catch { /* race */ }
37
+ ;
38
+ continue;
39
+ }
40
+ }
41
+ if (Date.now() >= deadline)
42
+ throw new Error(`delivery queue ${id}: timed out waiting for transaction lock`);
43
+ await pause(25);
44
+ }
45
+ }
46
+ }
47
+ /** Serialize delivery claims without making the transport itself part of SQLite. */
48
+ export async function withDeliveryLocks(rawIds, body, index = 0, ids = [...new Set(rawIds)].sort()) {
49
+ if (index >= ids.length)
50
+ return body();
51
+ const release = await acquire(ids[index], 30_000);
52
+ try {
53
+ return await withDeliveryLocks(ids, body, index + 1, ids);
54
+ }
55
+ finally {
56
+ release();
57
+ }
58
+ }
@@ -3,9 +3,10 @@ import { join, dirname, basename } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { homedir } from 'node:os';
6
- import { loadSystemConfig, loadSkillConfig, loadSpecs } from '@spexcode/spec-core';
7
- import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout, readJsonConfig } from '@spexcode/spec-core';
6
+ import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadSpecs } from '@spexcode/spec-core';
7
+ import { treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout, readJsonConfig } from '@spexcode/spec-core';
8
8
  import { loadConfig } from './lint.js';
9
+ import { GENERATED_MARK } from './harness.js';
9
10
  import { trackedSourceFiles } from './source-files.js';
10
11
  import { gitBinary } from '@spexcode/spec-core';
11
12
  // this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
@@ -211,6 +212,25 @@ function manifestScripts(text) {
211
212
  }
212
213
  return [...out];
213
214
  }
215
+ // which EVENTS the manifest actually binds. A tree can carry handlers and still be ungoverned in the way
216
+ // that matters, because a handler answers an event and the lifecycle rides on specific ones.
217
+ function manifestEvents(text) {
218
+ const out = new Set();
219
+ for (const line of text.split('\n')) {
220
+ const f = line.split('\t');
221
+ if (f.length >= 4 && f[0])
222
+ out.add(f[0]);
223
+ }
224
+ return out;
225
+ }
226
+ // THE LIFECYCLE EVENTS, and why they are named here rather than derived from an adapter's full event list.
227
+ // A tree that binds no turn-end event has a shim, a manifest, and readable handlers — every check above it
228
+ // passes — and still cannot report that its agent stopped: the record keeps whatever state it last held and
229
+ // a dead turn still shows as running. That is not hypothetical; a deployment whose `.config` predated
230
+ // the stop-gate node hit exactly this, and nothing in the diagnosis said so. Stop is the gate; StopFailure
231
+ // is the failed-turn writer; PostToolUse is the freshness mark. Missing any of them is a distinct break
232
+ // from "no manifest", and it is the one a passing installation can hide.
233
+ const LIFECYCLE_EVENTS = ['Stop', 'StopFailure', 'PostToolUse'];
214
234
  // the bundle's declared name, from plugin.json (root or the .claude-plugin/ convention), or null when none.
215
235
  function pluginName(dir) {
216
236
  for (const p of [join(dir, 'plugin.json'), join(dir, '.claude-plugin', 'plugin.json')]) {
@@ -263,6 +283,12 @@ async function doubleDeliveryReport(base) {
263
283
  catch {
264
284
  return [];
265
285
  } })();
286
+ const ourAgents = (() => { try {
287
+ return loadAgentConfig().map((c) => c.name);
288
+ }
289
+ catch {
290
+ return [];
291
+ } })();
266
292
  const L = [];
267
293
  const line = (k, v) => L.push(` ${k.padEnd(16)}: ${v}`);
268
294
  let conflict = false;
@@ -282,6 +308,13 @@ async function doubleDeliveryReport(base) {
282
308
  ...(looseDispatch ? [`loose ${rel(shimFile)}`] : []),
283
309
  ...bundles.filter((b) => b.hooksToDispatch).map((b) => `plugin "${b.name}" (${b.scope})`),
284
310
  ];
311
+ // a DIFFERENT collision from the double-delivery channels below: a skill/agent path a live spec node
312
+ // names, already occupied by a file the user wrote (no GENERATED_MARK). materialize skips those, so the
313
+ // node silently never reaches this harness — worth naming here, where somebody asks "do we clash?".
314
+ const userOwned = [
315
+ ...(looseSkillDir ? ourSkills.map((s) => join(looseSkillDir, s, 'SKILL.md')) : []),
316
+ ...((dir) => dir ? ourAgents.map((a) => join(dir, `${a}.md`)) : [])(h.agentDir(base)),
317
+ ].filter((f) => existsSync(f) && !read(f).includes(GENERATED_MARK));
285
318
  // channel 2 — same-named skill in loose skillDir AND a bundle's skills dir
286
319
  const skillHits = [];
287
320
  for (const s of ourSkills) {
@@ -302,6 +335,7 @@ async function doubleDeliveryReport(base) {
302
335
  line(' plugin', `"${b.name}" (${b.scope}) — ${b.dir}`);
303
336
  line('hooks→dispatch', `${hookSrc.length}${hookSrc.length > 1 ? ' (>1 → CONFLICT): ' + hookSrc.join(', ') : hookSrc.length === 1 ? ' (single — ok)' : ' (none wired)'}`);
304
337
  line('skill shadowing', skillHits.length ? `CONFLICT: ${skillHits.join(', ')}` : 'none');
338
+ line('your files kept', userOwned.length ? `${userOwned.map(rel).join(', ')} — yours (no spexcode stamp), so the same-named spec node is NOT delivered here` : 'none');
305
339
  L.push('');
306
340
  }
307
341
  if (conflict) {
@@ -423,31 +457,39 @@ async function doctor() {
423
457
  const shim = read(h.shimFile(base));
424
458
  line(`${h.id} shim`, /dispatch\.sh/.test(shim) ? `wired (${h.shimFile(base).replace(base + '/', '')})` : 'NOT wired (no dispatch shim)');
425
459
  }
426
- // manifest resolution mirrors dispatch.sh: this tree's materialize slot first, then the legacy global file
427
- // (a pre-slot tree's migration-window fallback) — so the doctor reads exactly what a dispatch would.
460
+ // Manifest resolution mirrors dispatch.sh: only the current tree slot is executable.
428
461
  let manifestText = '';
429
462
  let manifestHome = 'tree slot';
430
463
  try {
431
464
  manifestText = read(join(treeSlotDir(base), 'hooks-manifest'));
432
465
  }
433
466
  catch { /* non-git / no store */ }
434
- if (!manifestText) {
435
- try {
436
- manifestText = read(join(runtimeRoot(base), 'hooks-manifest'));
437
- manifestHome = 'legacy global file (pre-slot materialize — re-run `spex materialize`)';
438
- }
439
- catch { /* neither */ }
467
+ const manifestFile = (() => { try {
468
+ return join(treeSlotDir(base), 'hooks-manifest');
440
469
  }
470
+ catch {
471
+ return null;
472
+ } })();
473
+ const manifestPresent = !!manifestFile && existsSync(manifestFile);
441
474
  if (!manifestText) {
442
- line('manifest', 'MISSING from the global store materialize never ran (hooks fire but find no manifest)');
475
+ // ABSENT and EMPTY are different breaks and want different repairs, and the dispatcher cannot tell them
476
+ // apart at all: a missing file is a loud exit 78, an empty one dispatches nothing and exits 0 in silence.
477
+ line('manifest', manifestPresent
478
+ ? 'EMPTY in the current tree slot — materialize ran and bound ZERO handlers: this tree has no hook nodes, so every event dispatches nothing and exits 0 in silence'
479
+ : 'MISSING from the current tree slot — materialize must run before hooks can execute');
443
480
  }
444
481
  else {
445
482
  const scripts = manifestScripts(manifestText);
446
483
  const missing = scripts.filter((s) => !existsSync(join(base, s)));
484
+ const events = manifestEvents(manifestText);
447
485
  line('manifest', `${scripts.length} handler(s) in the ${manifestHome}`);
448
486
  line('handlers', missing.length === 0 ? 'all readable in the worktree' : `${missing.length} MISSING in the worktree → those hooks SILENTLY NO-OP:`);
449
487
  for (const m of missing)
450
488
  L.push(` ✗ ${m}`);
489
+ const unbound = LIFECYCLE_EVENTS.filter((event) => !events.has(event));
490
+ line('lifecycle events', unbound.length === 0
491
+ ? `${LIFECYCLE_EVENTS.join(', ')} all bound`
492
+ : `${unbound.join(', ')} NOT bound → this tree cannot report its own turn ending; the record keeps its last state and a stopped agent still shows as running`);
451
493
  }
452
494
  // codex trust
453
495
  const trustPresent = codexCfg.includes(`# spexcode:trust:${base}`);
@@ -19,6 +19,7 @@ export type ExecutionTrace = Readonly<{
19
19
  type ExportLoader = (threadId: string) => string;
20
20
  export declare function codexRolloutPath(threadId: string, root?: string): string | null;
21
21
  export declare function readCodexExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string): ExecutionTrace;
22
+ export declare function claudeTranscriptPath(threadId: string, root?: string): string | null;
22
23
  export declare function readProjectJsonlExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string): ExecutionTrace;
23
24
  export declare function readSessionJsonlExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string): ExecutionTrace;
24
25
  export declare function readLocalStoreExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string, load?: ExportLoader): ExecutionTrace;
@@ -359,7 +359,7 @@ export function readCodexExecutionTrace(threadId, turn, root = codexSessionsDir(
359
359
  return readIncremental(codexRolloutPath(threadId, root), applyRolloutEvent, turn);
360
360
  }
361
361
  const projectTranscriptRoot = () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects');
362
- function projectJsonlPath(threadId, root = projectTranscriptRoot()) {
362
+ export function claudeTranscriptPath(threadId, root = projectTranscriptRoot()) {
363
363
  for (const project of children(root)) {
364
364
  const path = join(root, project, `${threadId}.jsonl`);
365
365
  try {
@@ -371,7 +371,7 @@ function projectJsonlPath(threadId, root = projectTranscriptRoot()) {
371
371
  return null;
372
372
  }
373
373
  export function readProjectJsonlExecutionTrace(threadId, turn, root = projectTranscriptRoot()) {
374
- return readIncremental(projectJsonlPath(threadId, root), applyProjectJsonlEvent, turn);
374
+ return readIncremental(claudeTranscriptPath(threadId, root), applyProjectJsonlEvent, turn);
375
375
  }
376
376
  const sessionJsonlRoot = () => join(process.env.SPEXCODE_PI_AGENT_DIR || join(homedir(), '.pi', 'agent'), 'sessions');
377
377
  function sessionJsonlPath(threadId, root = sessionJsonlRoot()) {
@@ -11,6 +11,7 @@
11
11
  // mounted, an explicit text/html GET gets the Projects UI instead
12
12
  // PUT|DELETE /projects/admin-password admin: set/clear the admin password
13
13
  // PUT|DELETE /projects/:id/password admin: set/clear one project's password
14
+ // DELETE /projects/:id host extension: high-friction catalog registration removal
14
15
  // /p/:projectId/login|logout project session for that project
15
16
  // ANY /p/:projectId/* (+ WS upgrade) authorized → proxied, prefix-stripped, to that project's backend
16
17
  // Authorization never trusts the cookie's name or Path — the token's projectId claim is validated against
@@ -332,7 +333,7 @@ export function startHubGateway(opts) {
332
333
  host: opts.host,
333
334
  label: opts.label ?? 'hub gateway',
334
335
  cleanup: opts.onBindFail,
335
- ready: `[hub] multi-project gateway on ${scheme}://${opts.host ?? '0.0.0.0'}:${port} — /projects + /p/:projectId/*`,
336
+ ready: (actualPort) => `[hub] multi-project gateway on ${scheme}://${opts.host ?? '0.0.0.0'}:${actualPort} — /projects + /p/:projectId/*`,
336
337
  });
337
338
  return server;
338
339
  }
@@ -181,9 +181,12 @@ export function startGateway(opts) {
181
181
  const scheme = secure ? 'https' : 'http';
182
182
  const label = opts.label ?? 'public mode';
183
183
  const gate = isLoopback ? '' : ` — ${gated ? 'password-gated' : 'OPEN (no password)'}`;
184
- const ready = [...(opts.readyLines ?? []), `[gateway] ${label} on ${scheme}://${isLoopback ? 'localhost' : (opts.host ?? '0.0.0.0')}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`];
185
- if (!secure && !isLoopback && !opts.host)
186
- ready.push('[gateway] (TLS off --http)');
184
+ const ready = (port) => {
185
+ const lines = [...(opts.readyLines ?? []), `[gateway] ${label} on ${scheme}://${isLoopback ? 'localhost' : (opts.host ?? '0.0.0.0')}:${port}${gate}, proxying /api to :${opts.upstreamPort}`];
186
+ if (!secure && !isLoopback && !opts.host)
187
+ lines.push('[gateway] (TLS off — --http)');
188
+ return lines;
189
+ };
187
190
  // a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
188
191
  // (see [[spec-cli]] / listen.ts), so `spex serve` and `spex serve ui` fail a port clash identically.
189
192
  listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, ready });
@@ -11,6 +11,7 @@ import { residentForgeState } from '@spexcode/spec-forge/resident';
11
11
  import { resolveProjectIdentity } from '@spexcode/spec-core';
12
12
  import { readReviewSnapshot } from '@spexcode/spec-core';
13
13
  import { sessionEvalProjection } from '@spexcode/spec-eval/sessioneval';
14
+ import { resolveDatabasePath } from '@spexcode/session-selflaunch';
14
15
  const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1';
15
16
  function textOrNull(path) {
16
17
  try {
@@ -128,6 +129,28 @@ function strictSpecTreeRevision(wtPath) {
128
129
  }
129
130
  return parts.sort().join('\n');
130
131
  }
132
+ // The canonical session database ([[production-cutin]]) is a board input exactly like the runtime envelope:
133
+ // lifecycle status lives there, and ANY process may commit to it — a hook's `spex internal session-state`, a CLI
134
+ // declaration, the backend's own routes. Its file identity folds into the session revision, so a commit no leaf
135
+ // pushed (graph-stream's `session-db` leaf held or disabled) is still a moved revision that the patrol answers
136
+ // with the sessions splice. journal_mode=delete rewrites the file in place on every commit, so this is the same
137
+ // strict stat contract as the `.spec` walk: ctime catches a same-size rewrite, ENOENT is a legitimate fresh
138
+ // store, and every other read failure is loud.
139
+ function strictFileRevision(path) {
140
+ try {
141
+ const stat = statSync(path);
142
+ return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
143
+ }
144
+ catch (error) {
145
+ if (error.code === 'ENOENT')
146
+ return '';
147
+ throw error;
148
+ }
149
+ }
150
+ function sessionDatabaseRevision() {
151
+ const databasePath = resolveDatabasePath();
152
+ return [databasePath, strictFileRevision(databasePath)];
153
+ }
131
154
  function worktreeRevision(root) {
132
155
  return {
133
156
  root,
@@ -153,12 +176,17 @@ function sessionInputRevision() {
153
176
  textOrNull(sessionRecordPath(id)),
154
177
  textOrNull(sessionArtifactPath(id, 'prompt')),
155
178
  ]);
179
+ // The canonical database carries the lifecycle those envelopes no longer do, and a commit from ANY process
180
+ // moves it. It is an input only while a record exists to read it through: with no session records the board
181
+ // has no row that derives from the store, and the store's own birth — the first canonical access inside a
182
+ // build initializes it — must not read as an input that moved during that build.
183
+ const canonical = ids.length ? sessionDatabaseRevision() : null;
156
184
  const projections = digest(ids.map((id) => [id, sessionEvalProjection(id)]));
157
185
  const activeRoots = [...new Set(ids.flatMap((id) => {
158
186
  const entry = readPublicRecordEntry(id);
159
187
  return entry.kind === 'ok' && entry.raw.governed && !entry.raw.archived ? [entry.raw.worktree_path] : [];
160
188
  }))].sort();
161
- return { sessions: digest(sessionInputs), projections, projectionIds: ids, activeRoots };
189
+ return { sessions: digest([canonical, sessionInputs]), projections, projectionIds: ids, activeRoots };
162
190
  }
163
191
  function boardInputRevision(board) {
164
192
  const root = repoRoot();
@@ -444,6 +472,8 @@ function startBuild(mode = 'dirty') {
444
472
  });
445
473
  // Give a stale HTTP response a turn to flush before the producer's synchronous setup occupies the event
446
474
  // loop. Fresh callers simply absorb this small scheduling window while waiting on the same flight.
475
+ // A direct cold reader may be the only live work in a short-lived CLI process. Keep this one-shot
476
+ // scheduler referenced until it starts the producer, otherwise Node can exit with its fresh read pending.
447
477
  setTimeout(() => {
448
478
  if (controller.signal.aborted) {
449
479
  rejectBuild(Object.assign(new Error('graph build aborted before start'), { name: 'AbortError' }));
@@ -547,7 +577,7 @@ function startBuild(mode = 'dirty') {
547
577
  catch (error) {
548
578
  rejectBuild(error);
549
579
  }
550
- }, mode === 'patrol' ? 0 : BACKGROUND_START_DELAY_MS).unref?.();
580
+ }, mode === 'patrol' ? 0 : BACKGROUND_START_DELAY_MS);
551
581
  const timeoutError = () => new Error(`graph build did not settle within ${BUILD_TIMEOUT_MS}ms`);
552
582
  // `settle` owns the real builder. The watchdog only rejects `wait`; the slot remains occupied until this
553
583
  // promise settles, so a next read can never overlap an abandoned git/fs build.
@@ -1,4 +1,6 @@
1
- import { loadSpecs, requireGitWorkspace } from '@spexcode/spec-core';
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { loadSpecs, requireGitWorkspace, headSha } from '@spexcode/spec-core';
2
4
  import { resolveLayout } from '@spexcode/spec-core';
3
5
  import { listSessions } from './sessions.js';
4
6
  import { driftIndex, historyIndex, repoRoot } from '@spexcode/spec-core';
@@ -9,6 +11,46 @@ import { buildBoard as assembleBoard, spliceSessions as spliceBoardSessions } fr
9
11
  import { evalContext, evalTimelines } from '@spexcode/spec-eval/evaltab';
10
12
  import { evalNodesAsync } from '@spexcode/spec-eval/scenarios';
11
13
  import { sessionEvalProjections } from '@spexcode/spec-eval/sessioneval';
14
+ import { evalRemarkSourceFingerprint } from '@spexcode/spec-eval/host';
15
+ import { listBlobs } from '@spexcode/spec-eval/cache';
16
+ let timelineCache = null;
17
+ function fileFingerprint(path) {
18
+ try {
19
+ return readFileSync(path).toString('base64');
20
+ }
21
+ catch {
22
+ return '<missing>';
23
+ }
24
+ }
25
+ function timelineCacheKey(root, nodeIds, specs, evalNodes) {
26
+ const hash = createHash('sha256');
27
+ hash.update(root);
28
+ hash.update('\0');
29
+ hash.update(headSha(root) || '<no-head>');
30
+ hash.update('\0');
31
+ hash.update(JSON.stringify(nodeIds));
32
+ for (const spec of specs) {
33
+ hash.update('\0spec\0');
34
+ hash.update(spec.path);
35
+ hash.update('\0');
36
+ hash.update(spec.body);
37
+ }
38
+ for (const node of evalNodes) {
39
+ hash.update('\0eval\0');
40
+ hash.update(node.id);
41
+ hash.update('\0');
42
+ hash.update(node.evalSource ?? '');
43
+ hash.update('\0');
44
+ hash.update(fileFingerprint(node.sidecarPath));
45
+ }
46
+ // Issue remarks are an eval input even though they live outside the spec tree.
47
+ hash.update('\0remarks\0');
48
+ hash.update(evalRemarkSourceFingerprint());
49
+ // Evidence presence is part of each published timeline row (`present` vs `miss`).
50
+ hash.update('\0blobs\0');
51
+ hash.update(listBlobs().join('\0'));
52
+ return hash.digest('hex');
53
+ }
12
54
  // The application adapter is the sole reader of runtime/forge state. graph.ts only receives this result.
13
55
  export async function boardSnapshot() {
14
56
  const root = repoRoot();
@@ -22,7 +64,20 @@ export async function boardSnapshot() {
22
64
  const { issues, stamp: issuesStamp } = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodeIds);
23
65
  const [idx, hidx, evalNodes] = await Promise.all([driftIndex(root), historyIndex(root), evalNodesAsync(root)]);
24
66
  const context = await evalContext(root, specs, idx, hidx, undefined, evalNodes);
25
- const timelines = await evalTimelines(nodeIds, context);
67
+ const key = timelineCacheKey(root, nodeIds, specs, evalNodes);
68
+ let timelines;
69
+ if (timelineCache?.key === key) {
70
+ timelines = timelineCache.timelines;
71
+ }
72
+ else {
73
+ // The board carries only latest-per-scenario counts, so the cold build reads latest-only: the retained
74
+ // sidecar history is served by the detail endpoints and probing it here scales graph latency with the
75
+ // reading count instead of the live verdict population. Freshness itself is NOT deferred — the board
76
+ // publishes verdicts, and a verdict whose freshness was never computed is not a stale verdict, it is no
77
+ // verdict at all. An order-only board would report every measured row as stale and none as fresh.
78
+ timelines = await evalTimelines(nodeIds, context, { latestOnly: true });
79
+ timelineCache = { key, timelines };
80
+ }
26
81
  return {
27
82
  root, specs, layout, sessions, issues, issuesStamp, forgeRevision: residentForgeRevision(),
28
83
  evalTimelines: new Map(nodeIds.map((nodeId, index) => [nodeId, timelines[index]])),
@@ -58,6 +58,8 @@ export type PendingGraphChanges = {
58
58
  export declare const addPendingGraphChange: (pending: PendingGraphChanges, scope: Scope) => PendingGraphChanges;
59
59
  export declare function isSessionCreateCandidateRegistryEvent(relativePath: string, candidatePaths: Iterable<string>): boolean;
60
60
  export declare const notifyBoardChanged: (scope?: Scope) => void;
61
+ export declare const sessionDatabaseWatchIgnore: (databasePath: string) => ((relativePath: string) => boolean);
62
+ export declare function watchSessionDatabase(databasePath: string, onInput: () => void, onFailure: (error: Error) => void, watchFactory?: WatchFactory): TreeWatcherRegistry;
61
63
  type RegistryGroup = {
62
64
  root: string;
63
65
  close(): void;
@@ -1,7 +1,8 @@
1
1
  import { streamSSE } from 'hono/streaming';
2
2
  import { watch, mkdirSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import { join, dirname, relative, resolve, basename } from 'node:path';
4
- import { sessionsRoot, gitCommonDir, repoRoot, sessionBranchIndex, mainBranch } from '@spexcode/spec-core';
4
+ import { sessionsRoot, gitCommonDir, repoRoot, sessionBranchIndex, mainBranch, isTrashWorktreePath } from '@spexcode/spec-core';
5
+ import { resolveDatabasePath } from '@spexcode/session-selflaunch';
5
6
  import { hotSignature, warmSignature, listSessions, pendingSessionCreateWorktreePaths } from './sessions.js';
6
7
  import { getBoard, getBoardForSessionRefresh, invalidateBoard, patrolBoard } from './graphCache.js';
7
8
  import { unitize, tagOf, diffUnits } from '@spexcode/spec-core';
@@ -439,7 +440,7 @@ function fireChanged(scope = 'full', evalTarget) {
439
440
  }
440
441
  // ---- event source 0: an EXPLICIT server-side nudge ----
441
442
  // for a server-side mutation that must show instantly regardless of watcher health: /rename writes the
442
- // session's global record (`session.json` — [[session-rename]]), which lives INSIDE the watched store, so
443
+ // session's global runtime envelope (`runtime.json` — [[session-rename]]), which lives INSIDE the watched store, so
443
444
  // source 1 normally sees the write too. The explicit route call stays because that fs watch is best-effort
444
445
  // (it can fail to attach), and the nudge makes the sub-second rename guarantee deterministic. Same
445
446
  // debounced funnel as every other source; defaults to 'full' but the rename route passes 'sessions'.
@@ -535,6 +536,80 @@ function ensureWatcher(root) {
535
536
  }
536
537
  noteSourceHealthy('store');
537
538
  }
539
+ // ---- event source 1b: the canonical session database (lifecycle commits from ANY process) → 'sessions' ----
540
+ // Since the JSON cutover ([[production-cutin]]) a lifecycle transition is a SQLite commit, not a write inside the
541
+ // store above: that watch still sees the runtime envelope and the prompt, but the state a HOOK authors — mark-active
542
+ // on every prompt and tool call, the stop-gate's declarations, idle — is committed by the hook's OWN process through
543
+ // `spex internal session-state`. The backend's in-process commit observer (index.ts) bridges only its own commits,
544
+ // so without this leaf a hook-authored flip reached the board only when some unrelated signal happened to re-splice:
545
+ // a message sent from the dashboard left the row idle for minutes (measured on the dogfood board: 150s of nothing
546
+ // but pings after the commit). One NON-recursive watch on the database's directory, delivering only the database's
547
+ // own names — the file and its `-journal` (journal_mode=delete writes both on every commit); every other file in
548
+ // that directory is filtered out at delivery. Attach failure is held and repaired like every other source, and
549
+ // [[graph-cache]] folds the same file into its session revision so the patrol covers a held or disabled leaf.
550
+ let sessionDatabaseWatcher = null;
551
+ let activeDatabasePath = null;
552
+ const SESSION_DB_SOURCE = 'session-db';
553
+ export const sessionDatabaseWatchIgnore = (databasePath) => {
554
+ const name = basename(databasePath);
555
+ return (relativePath) => relativePath !== name && !relativePath.startsWith(`${name}-`);
556
+ };
557
+ export function watchSessionDatabase(databasePath, onInput, onFailure, watchFactory) {
558
+ return new TreeWatcherRegistry({
559
+ root: dirname(databasePath),
560
+ source: SESSION_DB_SOURCE,
561
+ scope: 'sessions',
562
+ recursive: false,
563
+ ignore: sessionDatabaseWatchIgnore(databasePath),
564
+ watchFactory,
565
+ onInput: () => onInput(),
566
+ onFailure,
567
+ });
568
+ }
569
+ function closeSessionDatabaseWatcher() {
570
+ sessionDatabaseWatcher?.close();
571
+ sessionDatabaseWatcher = null;
572
+ activeDatabasePath = null;
573
+ }
574
+ function ensureSessionDatabaseWatcher() {
575
+ if (isDisabled(SESSION_DB_SOURCE)) {
576
+ closeSessionDatabaseWatcher();
577
+ return;
578
+ }
579
+ let databasePath;
580
+ try {
581
+ databasePath = resolveDatabasePath();
582
+ }
583
+ catch (error) {
584
+ noteSourceFailure(SESSION_DB_SOURCE, error);
585
+ return;
586
+ }
587
+ if (sessionDatabaseWatcher && activeDatabasePath === databasePath)
588
+ return;
589
+ closeSessionDatabaseWatcher();
590
+ activeDatabasePath = databasePath;
591
+ if (!mayAttach(SESSION_DB_SOURCE))
592
+ return;
593
+ try {
594
+ mkdirSync(dirname(databasePath), { recursive: true });
595
+ }
596
+ catch (error) {
597
+ console.error(`spec-cli: graph watcher '${SESSION_DB_SOURCE}' could not create ${dirname(databasePath)}: ${error instanceof Error ? error.message : String(error)}`);
598
+ }
599
+ const registry = watchSessionDatabase(databasePath, () => fireChanged('sessions'), (error) => {
600
+ if (sessionDatabaseWatcher === registry)
601
+ sessionDatabaseWatcher = null;
602
+ noteSourceFailure(SESSION_DB_SOURCE, error);
603
+ fireChanged('sessions');
604
+ });
605
+ sessionDatabaseWatcher = registry;
606
+ if (!registry.refresh()) {
607
+ if (sessionDatabaseWatcher === registry)
608
+ sessionDatabaseWatcher = null;
609
+ return;
610
+ }
611
+ noteSourceHealthy(SESSION_DB_SOURCE);
612
+ }
538
613
  let refsWatchers = null;
539
614
  const REFS_OBSERVER = 'graph:refs';
540
615
  // @@@ the moved ref NAMES its scope - the watcher has always known which ref moved and threw it away, so
@@ -657,13 +732,14 @@ const worktreeSource = (name) => `worktree:${name}`;
657
732
  const PROJECT_ROOT_SOURCE = 'project-root';
658
733
  let projectRootWatcher = null;
659
734
  const ignoredWorktreePath = (file) => file.split(/[\\/]/).some((segment) => segment === '.git' || segment === 'node_modules');
735
+ const ignoredGeneratedBuildPath = (file) => file.split(/[\\/]/).some((segment) => segment === 'dist' || segment.startsWith('.dist-next-') || segment.startsWith('.dist-previous-'));
660
736
  // @@@ linked worktrees are not graph input for THIS backend - the board's node statuses derive from the
661
737
  // served checkout's own HEAD, so a file under `.worktrees/<node>` belongs to a different branch's tree and
662
738
  // cannot move any status here until it lands and this HEAD advances. Watching them registers one inotify
663
739
  // watch per directory (Linux takes the exact-directory transport) and buys nothing: measured on this repo,
664
740
  // 20,124 of 20,473 watched directories were linked worktrees against 843 in the served tree.
665
741
  // The per-worktree registries above keep their own roots; only the project-root sweep skips them.
666
- export const ignoredProjectRootPath = (file) => ignoredWorktreePath(file) || file.split(/[\\/]/).some((segment) => segment === '.worktrees');
742
+ export const ignoredProjectRootPath = (file) => ignoredWorktreePath(file) || ignoredGeneratedBuildPath(file) || file.split(/[\\/]/).some((segment) => segment === '.worktrees');
667
743
  // The directory whose tree this backend serves is graph input even before it has a `.spec` tree or any live
668
744
  // session worktree. Keeping it in the same root registry as linked worktrees means a first `spex init` or
669
745
  // agent-created spec invalidates a warmed empty board instead of leaving a confidently stale cache until a
@@ -807,6 +883,8 @@ async function reconcileWorktreePass(forcedSessions, era, common) {
807
883
  catch {
808
884
  continue;
809
885
  }
886
+ if (isTrashWorktreePath(wtPath))
887
+ continue;
810
888
  const normalizedPath = resolve(wtPath);
811
889
  if (!wantedPaths.has(normalizedPath)) {
812
890
  if (dropWorktreeWatcher(e.name))
@@ -1033,6 +1111,7 @@ export async function ensureBoardFileWatchers(forceSessionId) {
1033
1111
  activeStoreRoot = storeRoot;
1034
1112
  activeCommonRoot = commonRoot;
1035
1113
  ensureWatcher(storeRoot);
1114
+ ensureSessionDatabaseWatcher();
1036
1115
  ensureRefsWatcher(commonRoot);
1037
1116
  await ensureWorktreeRegistry(forceSessionId);
1038
1117
  ensureProjectRootWatcher();
@@ -1055,6 +1134,7 @@ export function closeBoardFileWatchers() {
1055
1134
  worktreeReconcileFlight = null;
1056
1135
  storeWatcher?.close();
1057
1136
  storeWatcher = null;
1137
+ closeSessionDatabaseWatcher();
1058
1138
  refsWatchers?.close();
1059
1139
  refsWatchers = null;
1060
1140
  registryWatcher?.close();