spexcode 0.2.8 → 0.3.0

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 (97) hide show
  1. package/README.md +56 -44
  2. package/package.json +3 -3
  3. package/spec-cli/bin/spex.mjs +2 -2
  4. package/spec-cli/hooks/dispatch.sh +1 -1
  5. package/spec-cli/hooks/harness.sh +26 -6
  6. package/spec-cli/src/attach.ts +2 -2
  7. package/spec-cli/src/cli.ts +695 -536
  8. package/spec-cli/src/client.ts +31 -30
  9. package/spec-cli/src/contract-filter.ts +1 -1
  10. package/spec-cli/src/doctor.ts +40 -13
  11. package/spec-cli/src/gateway.ts +11 -7
  12. package/spec-cli/src/git.ts +2 -2
  13. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  14. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  15. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  16. package/spec-cli/src/graphStream.ts +288 -0
  17. package/spec-cli/src/guide.ts +123 -96
  18. package/spec-cli/src/harness-select.ts +2 -2
  19. package/spec-cli/src/harness.ts +30 -14
  20. package/spec-cli/src/help.ts +289 -384
  21. package/spec-cli/src/hooks.ts +1 -1
  22. package/spec-cli/src/index.ts +130 -103
  23. package/spec-cli/src/init.ts +9 -9
  24. package/spec-cli/src/issues.ts +89 -33
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +73 -8
  27. package/spec-cli/src/localIssues.ts +42 -60
  28. package/spec-cli/src/materialize.ts +1 -1
  29. package/spec-cli/src/mentions.ts +15 -15
  30. package/spec-cli/src/migrate-table.ts +397 -0
  31. package/spec-cli/src/migrate.ts +386 -0
  32. package/spec-cli/src/ranker.ts +30 -4
  33. package/spec-cli/src/reaper.ts +117 -0
  34. package/spec-cli/src/search.bench.mjs +10 -10
  35. package/spec-cli/src/search.ts +1 -1
  36. package/spec-cli/src/sessions.ts +244 -138
  37. package/spec-cli/src/specs.ts +25 -15
  38. package/spec-cli/src/supervise.ts +2 -2
  39. package/spec-cli/src/tree.ts +5 -5
  40. package/spec-cli/src/uninstall.ts +4 -4
  41. package/spec-cli/templates/hooks/post-checkout +1 -1
  42. package/spec-cli/templates/hooks/post-merge +3 -3
  43. package/spec-cli/templates/hooks/pre-commit +9 -9
  44. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  45. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  51. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  52. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +4 -4
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/forge-link/spec.md +1 -1
  56. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  57. package/spec-cli/templates/spec/project/{.config → .plugins}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/{.config → .plugins}/spec.md +4 -4
  59. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  60. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/spec.md +1 -1
  62. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-C7Bzsv86.js} +9 -9
  63. package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +2 -0
  64. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-D5iB4Ac2.js} +1 -1
  65. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-CMFTsQhg.js} +1 -1
  66. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-DwuTKgdP.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-CBS5_cmK.js} +3 -3
  68. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CqAnjWfI.js} +2 -2
  69. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BW5f0OaW.js} +1 -1
  70. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-Cc26X4ce.css} +1 -1
  71. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-Ce0wDyQS.js} +8 -8
  72. package/spec-dashboard/dist/index.html +2 -2
  73. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  74. package/{spec-yatsu → spec-eval}/src/cli.ts +164 -95
  75. package/{spec-yatsu → spec-eval}/src/evaltab.ts +24 -24
  76. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  77. package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
  78. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  79. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  80. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  81. package/{spec-yatsu → spec-eval}/src/sidecar.ts +7 -1
  82. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  83. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  84. package/spec-forge/src/cli.ts +14 -13
  85. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  86. package/spec-cli/src/boardStream.ts +0 -179
  87. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  88. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  89. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  90. /package/spec-cli/templates/presets/careful/{.config → .plugins}/clarify-before-code/spec.md +0 -0
  91. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  92. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  93. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  94. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/memory-hygiene/spec.md +0 -0
@@ -1,5 +1,5 @@
1
1
  import { apiBase, assertProjectMatch, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
2
- import type { SessionEvals } from '../../spec-yatsu/src/proof.js'
2
+ import type { SessionEvals } from '../../spec-eval/src/sessioneval.js'
3
3
 
4
4
  export class BackendError extends Error {
5
5
  constructor(message: string, readonly status?: number) {
@@ -25,7 +25,7 @@ const guarded = (verb: string) => assertProjectMatch(`spex ${verb}`)
25
25
  const post = (body: unknown): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
26
26
  const seg = (id: string) => encodeURIComponent(id)
27
27
 
28
- // GET /api/sessions — the board, used by `spex ls`, and by `spex watch`/`wait` as their poll source.
28
+ // GET /api/sessions — the board, used by `spex session ls`, and by `spex session watch`/`wait` as their poll source.
29
29
  export async function clientListSessions(): Promise<Session[]> {
30
30
  const r = await apiFetch('/api/sessions')
31
31
  if (!r.ok) throw new BackendError(`backend error ${r.status} listing sessions`, r.status)
@@ -46,13 +46,13 @@ export async function clientCapture(id: string): Promise<CaptureResult> {
46
46
  return { ok: false, status: r.status, reason: (await r.text().catch(() => '')) || `status ${r.status}` }
47
47
  }
48
48
 
49
- // POST /api/sessions/:id/keys — prompt dispatch (the backend routes it through the rendezvous socket,
50
- // socket-only + fail-loud; a non-accepted prompt comes back ok:false / HTTP 502).
49
+ // POST /api/sessions/:id/input {kind:"text"} — prompt dispatch (the backend routes it through the rendezvous
50
+ // socket, socket-only + fail-loud; a non-accepted prompt comes back ok:false / HTTP 502).
51
51
  export async function clientSend(id: string, text: string, from?: string): Promise<DispatchResult> {
52
52
  await guarded('session send')
53
53
  // `from` = the sending agent's own session id; the backend logs the comms edge ([[comms-edge]]) only when
54
54
  // it's present (an agent send), so a human-shell send stays unrecorded.
55
- const r = await apiFetch(`/api/sessions/${seg(id)}/keys`, post({ text, ...(from ? { from } : {}) }))
55
+ const r = await apiFetch(`/api/sessions/${seg(id)}/input`, post({ kind: 'text', text, ...(from ? { from } : {}) }))
56
56
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
57
57
  }
58
58
 
@@ -64,18 +64,18 @@ export async function clientReview(id: string): Promise<ReviewPayload | null> {
64
64
  return await r.json() as ReviewPayload
65
65
  }
66
66
 
67
- // GET /api/sessions/:id/proof — the rendered proof EXPORT artifact ([[review-proof]]): the self-contained
68
- // HTML the backend builds (default), or the model JSON (`json:true` ?format=json). The engine runs on the
69
- // backend, so the CLI is a thin fetcher that writes/opens these bytes — works against a remote backend
70
- // unchanged. 404 no such session.
71
- export type ProofResult = { ok: true; body: string } | { ok: false; status: number }
72
- export async function clientProof(id: string, json = false): Promise<ProofResult> {
73
- const r = await apiFetch(`/api/sessions/${seg(id)}/proof${json ? '?format=json' : ''}`)
67
+ // GET /api/sessions/:id/evals?format=html — the rendered EXPORT artifact ([[session-eval]]): the
68
+ // self-contained HTML the backend builds. The engine runs on the backend, so the CLI is a thin fetcher
69
+ // that writes/opens these bytes — works against a remote backend unchanged. 404 → no such session.
70
+ export type ExportResult = { ok: true; body: string } | { ok: false; status: number }
71
+ export async function clientEvalExport(id: string): Promise<ExportResult> {
72
+ const r = await apiFetch(`/api/sessions/${seg(id)}/evals?format=html`)
74
73
  if (r.ok) return { ok: true, body: await r.text() }
75
74
  return { ok: false, status: r.status }
76
75
  }
77
76
 
78
- // GET /api/sessions/:id/evals — the session EVAL model ([[review-proof]]'s interactive face): the changed
77
+ // GET /api/sessions/:id/evals — the session EVAL model ([[session-eval]]'s interactive face, the same
78
+ // route's default JSON representation): the changed
79
79
  // nodes' worktree-rooted reading rows (each carrying `inSession`), no diff enrichment, no inlined evidence
80
80
  // bytes — what `spex eval` renders, the dashboard Eval tab's source. 404 → no such session.
81
81
  export type EvalsResult = { ok: true; model: SessionEvals } | { ok: false; status: number }
@@ -95,17 +95,17 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
95
95
  // POST /api/sessions/:id/resume — bring the agent back (relaunch ONLY if confirmed offline); demotes
96
96
  // working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
97
97
  // `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
98
- export async function clientReopen(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
99
- await guarded('session reopen')
98
+ export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
99
+ await guarded('session resume')
100
100
  const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
101
101
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
102
102
  }
103
103
 
104
- // POST /api/sessions/:id/exit — the soft stop: kill tmux + socket, KEEP the worktree (session goes offline,
104
+ // POST /api/sessions/:id/stop — the soft stop: kill tmux + socket, KEEP the worktree (session goes offline,
105
105
  // resumable). Distinct from close. {ok:false} = no such session.
106
- export async function clientExit(id: string): Promise<boolean> {
107
- await guarded('session exit')
108
- const r = await apiFetch(`/api/sessions/${seg(id)}/exit`, post({}))
106
+ export async function clientStop(id: string): Promise<boolean> {
107
+ await guarded('session stop')
108
+ const r = await apiFetch(`/api/sessions/${seg(id)}/stop`, post({}))
109
109
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
110
110
  }
111
111
 
@@ -124,19 +124,20 @@ export async function clientRename(id: string, name: string): Promise<boolean> {
124
124
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
125
125
  }
126
126
 
127
- // POST /api/sessions/:id/rawkey — the raw nav-key channel (tmux send-keys, NEVER the prompt socket): an
128
- // ordered token batch drives an interactive TUI menu ([[nav-mode-key-ordering]]). {ok:false} = unknown
129
- // session, no live pane, or no valid token delivered.
130
- export async function clientRawkey(id: string, keys: string[]): Promise<boolean> {
131
- await guarded('session rawkey')
132
- const r = await apiFetch(`/api/sessions/${seg(id)}/rawkey`, post({ keys }))
127
+ // POST /api/sessions/:id/input {kind:"keys"} — the LAST-RESORT raw nav-key face of send (tmux send-keys,
128
+ // NEVER the prompt socket): an ordered token batch drives an interactive TUI menu
129
+ // ([[nav-mode-key-ordering]]). {ok:false} = unknown session, no live pane, or no valid token delivered.
130
+ export async function clientSendRawKeys(id: string, keys: string[]): Promise<boolean> {
131
+ await guarded('session send')
132
+ const r = await apiFetch(`/api/sessions/${seg(id)}/input`, post({ kind: 'keys', keys }))
133
133
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
134
134
  }
135
135
 
136
- // GET /api/sessions/:id/prompt — the session's originating prompt (404 if none recorded).
137
- export type PromptResult = { ok: true; prompt: string } | { ok: false; status: number }
138
- export async function clientPrompt(id: string): Promise<PromptResult> {
139
- const r = await apiFetch(`/api/sessions/${seg(id)}/prompt`)
140
- if (r.ok) return { ok: true, prompt: await r.text() }
136
+ // GET /api/sessions/:id — the session RECORD detail (`spex session show`): the board row plus the full
137
+ // originating prompt. 404 no such session.
138
+ export type ShowResult = { ok: true; session: Session & { prompt: string | null } } | { ok: false; status: number }
139
+ export async function clientShow(id: string): Promise<ShowResult> {
140
+ const r = await apiFetch(`/api/sessions/${seg(id)}`)
141
+ if (r.ok) return { ok: true, session: await r.json() as Session & { prompt: string | null } }
141
142
  return { ok: false, status: r.status }
142
143
  }
@@ -138,7 +138,7 @@ export function settleIndexStat(proj: string, files: string[]): void {
138
138
 
139
139
  // the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
140
140
  // config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
141
- // (yatsu blobs), so only OUR two files go, never the dir.
141
+ // (evidence blobs), so only OUR two files go, never the dir.
142
142
  export function removeContractFilter(proj: string): void {
143
143
  let common: string
144
144
  try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'
13
13
  import { execFileSync } from 'node:child_process'
14
14
  import { homedir } from 'node:os'
15
15
  import { loadSystemConfig, loadSkillConfig } from './specs.js'
16
- import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord } from './layout.js'
16
+ import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout } from './layout.js'
17
17
 
18
18
  // this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
19
19
  // materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
@@ -219,6 +219,21 @@ async function doctor(): Promise<number> {
219
219
  line('spex-adopted', adopted ? 'yes (.spec/ + spexcode.json)' : root ? 'no — run `spex init`' : 'not a git repo')
220
220
  line('root', base)
221
221
  line('mode', managed ? 'managed worktree (backend-launched session)' : 'standalone repo (bring-your-own-agent)')
222
+ // the issues-workflow switch ([[local-issues]]): its only home is the `issues.enabled` settings key
223
+ // (v0.3.0 — the on|off|status CLI verbs are gone), so doctor is where its state is READ. A legacy
224
+ // pre-rename `proposals.enabled` key is no longer consulted at runtime (no fallback) — flag it here so
225
+ // an old settings file gets repaired instead of silently drifting from what its author believes.
226
+ if (adopted) {
227
+ const { issuesEnabled } = await import('./localIssues.js')
228
+ // probe the SAME files the switch actually reads — the trunk checkout's settings pair, not this
229
+ // worktree's (readConfig resolves to mainCheckout, so a legacy key only matters there).
230
+ const cfgHome = (() => { try { return mainCheckout() } catch { return base } })()
231
+ const legacy = ['spexcode.json', 'spexcode.local.json'].filter((f) => {
232
+ try { return 'proposals' in JSON.parse(readFileSync(join(cfgHome, f), 'utf8')) } catch { return false }
233
+ })
234
+ line('issues workflow', `${issuesEnabled() ? 'ON' : 'OFF'} (spexcode.json issues.enabled)`)
235
+ if (legacy.length) line(' LEGACY key', `\`proposals\` found in ${legacy.map((f) => join(cfgHome, f)).join(', ')} — no longer read; rename it to "issues": { "enabled": … }`)
236
+ }
222
237
 
223
238
  // --- preconditions: nothing downstream fires without these ---
224
239
  L.push('\nPreconditions (without these nothing downstream fires)')
@@ -247,7 +262,7 @@ async function doctor(): Promise<number> {
247
262
  const present = h.contractFiles(base).every((f) => /<!--\s*spexcode:start\s*-->/.test(read(f)))
248
263
  line(`in ${h.id}`, present ? `block present (${h.contractFiles(base).map((f) => f.replace(base + '/', '')).join(', ')})` : 'NOT landed — run `spex doctor contract` / materialize')
249
264
  }
250
- line('view', 'spex doctor contract')
265
+ line('view', 'spex doctor --contract')
251
266
 
252
267
  // --- hooks: the shim → dispatch, the manifest, and EVERY handler readable in the worktree ---
253
268
  L.push('\nLayer 3 — hooks (shim → dispatch · manifest · handler-existence)')
@@ -293,7 +308,7 @@ async function doctor(): Promise<number> {
293
308
  line('layer 2', body.length === 0 ? 'ABSENT (no contract)' : 'see per-harness above')
294
309
  line('layer 3', manifestText ? 'see handler-existence above' : 'ABSENT (no manifest — agent ungoverned)')
295
310
  line('layer 4', up ? 'present' : managed ? 'EXPECTED but backend down' : 'absent (normal for bring-your-own-agent)')
296
- line('layer 5', dd.conflict ? 'CONFLICT (double-delivery — see Layer 5; `spex doctor conflicts`)' : 'clean (single channel)')
311
+ line('layer 5', dd.conflict ? 'CONFLICT (double-delivery — see Layer 5; `spex doctor --conflicts`)' : 'clean (single channel)')
297
312
 
298
313
  // --- footprint: every artifact Spex wrote here, + any slot held by something not ours ---
299
314
  L.push('\nFootprint (what Spex wrote into this environment)')
@@ -323,12 +338,12 @@ function contract(): number {
323
338
  }
324
339
 
325
340
  // the focused double-delivery check: JUST Layer 5, exit non-zero when a conflict is live so it gates a script
326
- // / yatsu. Anchors at the repo root like doctor (the shims + contract + skills live there).
341
+ // / eval. Anchors at the repo root like doctor (the shims + contract + skills live there).
327
342
  async function conflicts(): Promise<number> {
328
343
  const cwd = process.cwd()
329
344
  const base = repoRoot(cwd) ?? cwd
330
345
  const { lines, conflict } = await doubleDeliveryReport(base)
331
- console.log(['spex doctor conflicts — does SpexCode reach this agent through more than one discovery channel?\n', ...lines].join('\n'))
346
+ console.log(['spex doctor --conflicts — does SpexCode reach this agent through more than one discovery channel?\n', ...lines].join('\n'))
332
347
  return conflict ? 1 : 0
333
348
  }
334
349
 
@@ -338,26 +353,38 @@ async function conflicts(): Promise<number> {
338
353
  function noteStaged(verb: string): number {
339
354
  console.error(`spex doctor ${verb} is not available yet — it is staged behind the hook-degradation prerequisite
340
355
  (the live hooks must detect a missing managed session and degrade before they can be safely wired into your
341
- own agent's config). Meanwhile: \`spex doctor\` reports your coverage, and \`spex doctor contract\` prints
356
+ own agent's config). Meanwhile: \`spex doctor\` reports your coverage, and \`spex doctor --contract\` prints
342
357
  the workflow text you can hand any agent.`)
343
358
  return 2
344
359
  }
345
360
 
346
361
  function usage(): number {
347
362
  console.error(`spex doctor — diagnose how the SpexCode workflow reaches your agent
348
- (bare) per-layer report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
349
- contract print the surface:system contract text (hand it to any agent)
350
- conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)
351
- install [staged] wire the materialized contract + hooks into your agent (--agent claude, --minimal)
352
- uninstall [staged] reverse exactly what install wrote`)
363
+ (bare) per-layer report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
364
+ --contract print the surface:system contract text (hand it to any agent)
365
+ --conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)
366
+ --migrate one-shot 0.2.x 0.3.0 vocabulary migration for an adopter repo (renames + hook-asset
367
+ upgrade + body rewrites; refuses on a dirty tree / undrained sessions / an already-migrated
368
+ tree; stages everything, commits nothing; removed in 0.4.0)
369
+ install [staged] wire the materialized contract + hooks into your agent (--agent claude, --minimal)
370
+ uninstall [staged] reverse exactly what install wrote`)
353
371
  return 0
354
372
  }
355
373
 
356
374
  export async function runDoctor(args: string[]): Promise<number> {
375
+ // contract/conflicts are FLAGS, not subcommands ([[cli-surface]] §4: another representation of the same
376
+ // diagnosis read, not a distinct action). The old positional spellings signpost — report, never run.
377
+ // --migrate is the one MUTATING flag: the term-limited 0.2.x→0.3.0 adopter migrator ([[migrate]],
378
+ // ships with 0.3.0, deleted in 0.4.0) — dispatched first so its refusal/summary is never diluted
379
+ // by the diagnosis report.
380
+ if (args.includes('--migrate')) return await (await import('./migrate.js')).runMigrate()
381
+ if (args.includes('--contract')) return contract()
382
+ if (args.includes('--conflicts')) return await conflicts()
357
383
  switch (args[0]) {
358
384
  case undefined: return await doctor()
359
- case 'contract': return contract()
360
- case 'conflicts': return await conflicts()
385
+ case 'contract': case 'conflicts':
386
+ console.error(`spex: \`spex doctor ${args[0]}\` was removed in v0.3.0 — use: spex doctor --${args[0]}`)
387
+ return 2
361
388
  case 'install': return noteStaged('install')
362
389
  case 'uninstall': return noteStaged('uninstall')
363
390
  case 'help': case '--help': case '-h': return usage()
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url'
15
15
  import { homedir } from 'node:os'
16
16
  import { loginPage } from './login-page.js'
17
17
  import { listenOrExit } from './listen.js'
18
+ import { installConnectionReaper } from './reaper.js'
18
19
 
19
20
  // @@@ resolvePublicConfig - the cert/gate is a RESOLVED value, never hardcoded. Reads the same precedence
20
21
  // chain the spec promises: flag > env > spexcode.json > self-signed default. Returns null when public mode
@@ -150,14 +151,17 @@ export function startGateway(opts: GatewayOpts): void {
150
151
  }
151
152
 
152
153
  // server-side connection reaping ([[spec-cli]] / [[public-mode]]) - the internet-facing gateway is the
153
- // public server in public mode, so it carries the SAME reaping as the child: an abandoned/slow connection
154
- // dies here instead of piling up. Idle keep-alive / slow-header / never-completing request only; the gated
155
- // WS upgrade (handled below) is an active connection, not reaped by these. Set AT CONSTRUCTION so the
156
- // connection-checking sweep is armed with our cadence (a post-hoc set leaves it under-effective).
154
+ // public server in public mode, so it carries the SAME reaping as the child. The `reap` HTTP timeouts are
155
+ // kept (harmless) but are NOT the mechanism MEASURED (eval server-reaps-abandoned-connections), Node's
156
+ // headersTimeout/requestTimeout don't reap an INCOMPLETE request; the real reaper is the socket-level
157
+ // `installConnectionReaper` (reaper.ts), applied below at every http server the serving path creates.
158
+ // Idle keep-alive / slow-loris / never-completing request only; the gated WS upgrade (handled below) is an
159
+ // active stream and exempt for its lifetime.
157
160
  const reap = { keepAliveTimeout: 10000, headersTimeout: 20000, requestTimeout: 60000, connectionsCheckingInterval: 10000 }
158
161
  const server = secure
159
162
  ? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key, ...reap }, handler)
160
163
  : http.createServer(reap, handler)
164
+ installConnectionReaper(server)
161
165
 
162
166
  // @@@ WS gate - the terminal socket rides an HTTP upgrade. Gate it by the SAME cookie (the browser sends
163
167
  // it on the same-origin handshake), then raw-pipe to the loopback supervisor, replaying the buffered
@@ -173,7 +177,7 @@ export function startGateway(opts: GatewayOpts): void {
173
177
  socket.on('error', bail); up.on('error', bail)
174
178
  })
175
179
 
176
- // `spex dashboard` passes an explicit host (loopback by default, --host to widen); `--public` passes
180
+ // `spex serve ui` passes an explicit host (loopback by default, --host to widen); `--public` passes
177
181
  // none → bind ALL interfaces (the original behaviour, IPv4+IPv6), so adding the local path never
178
182
  // narrows the public gateway's reach. The gate note keys on LOOPBACK, not on host-being-explicit:
179
183
  // an ungated loopback bind is normal, an ungated wide bind is announced — never silent.
@@ -186,7 +190,7 @@ export function startGateway(opts: GatewayOpts): void {
186
190
  if (!secure && !isLoopback && !opts.host) console.log('[gateway] (TLS off — --http)')
187
191
  }
188
192
  // a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
189
- // (see [[spec-cli]] / listen.ts), so `spex serve` and `spex dashboard` fail a port clash identically.
193
+ // (see [[spec-cli]] / listen.ts), so `spex serve` and `spex serve ui` fail a port clash identically.
190
194
  listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, onListen })
191
195
  }
192
196
 
@@ -271,7 +275,7 @@ export function ensureDashboardBuilt(repoRoot: string, distDir: string): void {
271
275
  }
272
276
  }
273
277
 
274
- // @@@ serveDashboardLocal - the engine behind `spex dashboard`: the SAME gateway as public mode, bound to
278
+ // @@@ serveDashboardLocal - the engine behind `spex serve ui`: the SAME gateway as public mode, bound to
275
279
  // loopback by default with no TLS and no password — `--host` widens the bind to a chosen interface
276
280
  // (LAN/tailnet viewing) while staying plain HTTP; the internet face remains `spex serve --public`.
277
281
  // It serves the bundled dist and proxies /api + the terminal socket to a separately-run `spex serve`.
@@ -6,7 +6,7 @@ import { join, isAbsolute, resolve } from 'node:path'
6
6
  const US = '\x1f', RS = '\x1e'
7
7
 
8
8
  // @@@ bounded git children - a git child that never exits (wedged fs, a hijacked PATH git, a dead network
9
- // mount) must not pin its awaiter forever: [[board-cache]]'s settle guarantee starts at this seam. Every
9
+ // mount) must not pin its awaiter forever: [[graph-cache]]'s settle guarantee starts at this seam. Every
10
10
  // shared helper passes a generous timeout (an order of magnitude above the slowest legitimate full-history
11
11
  // walk) with SIGKILL — same pattern sessions.ts's tmux/ps probes already use — so a hung child dies and the
12
12
  // call fails like any other git failure instead of hanging its caller's promise. The kill is warned loudly:
@@ -154,7 +154,7 @@ function parseStatPath(token: string): { from: string; to: string } {
154
154
  }
155
155
 
156
156
  // Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
157
- // once — the backend checkout (board, loadSpecs) plus every session worktree ([[review-proof]]'s eval
157
+ // once — the backend checkout (board, loadSpecs) plus every session worktree ([[session-eval]]'s eval
158
158
  // tab roots its readings at the session's branch). A single-slot cache thrashes between those roots:
159
159
  // each eval-tab request evicts the board's entry and vice versa, so every request re-runs a full-history
160
160
  // `git log` and re-parses it on the event loop — which is what starves every other request (the board,
@@ -6,8 +6,8 @@ import { repoRoot, driftIndex, historyIndex } from './git.js'
6
6
  import { residentForgeState } from '../../spec-forge/src/resident.js'
7
7
  import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
8
8
  import { mergedIssues } from './issues.js'
9
- import { evalContext, evalTimeline } from '../../spec-yatsu/src/evaltab.js'
10
- import { yatsuNodesAsync } from '../../spec-yatsu/src/yatsu.js'
9
+ import { evalContext, evalTimeline } from '../../spec-eval/src/evaltab.js'
10
+ import { evalNodesAsync } from '../../spec-eval/src/scenarios.js'
11
11
 
12
12
  // a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
13
13
  function resolveParent(path: string, byDir: Record<string, string>): string | null {
@@ -20,7 +20,7 @@ function resolveParent(path: string, byDir: Record<string, string>): string | nu
20
20
  return null
21
21
  }
22
22
 
23
- // the board's eval summary ([[board-lean]]): the LATEST reading per scenario, each kept as the VERBATIM
23
+ // the board's eval summary ([[graph-lean]]): the LATEST reading per scenario, each kept as the VERBATIM
24
24
  // reading object — a filter, never a projection. Consumers hang optional fields off a reading (the
25
25
  // annotator's timelineBlob rides only video readings), so dropping a field here is a SILENT downstream
26
26
  // degradation no error would surface; the field-preservation unit test pins this contract.
@@ -29,7 +29,7 @@ export function latestPerScenario<T extends { scenario: string }>(readings: T[])
29
29
  return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
30
30
  }
31
31
 
32
- // the board's scenario fold ([[board-lean]]): the declared set rides SLIM — {name, tags} is everything an
32
+ // the board's scenario fold ([[graph-lean]]): the declared set rides SLIM — {name, tags} is everything an
33
33
  // overview surface joins state onto (badge, stats, focus rows, search rows); the prose (description/
34
34
  // expected) and per-scenario code stay off the hot poll, carried by `/api/specs/lite` and `/api/specs/:id/evals`.
35
35
  export function slimScenarios(scenarios: { name: string; tags?: string[] }[]): { name: string; tags?: string[] }[] {
@@ -44,7 +44,7 @@ export async function buildBoard() {
44
44
  const root = repoRoot()
45
45
  const [layout, sessions, specs] = await Promise.all([resolveLayout(), listSessions(), loadSpecs()])
46
46
  // the eval fold's freshness axes: WARM hits — loadSpecs already computed this HEAD's drift + history
47
- // indices, so these are the same cached walks, fetched once and reused for every yatsu node (the history
47
+ // indices, so these are the same cached walks, fetched once and reused for every measurable node (the history
48
48
  // index drives the rename-safe scenario axis, mirroring a spec node's own freshness).
49
49
  const idx = await driftIndex(root)
50
50
  const hidx = await historyIndex(root)
@@ -96,7 +96,7 @@ export async function buildBoard() {
96
96
  const nodes = [
97
97
  ...specs.map((n: any) => {
98
98
  const overlays = overlaysByNode[n.id] || []
99
- // `body` and its derivation `parts` are DROPPED from the board payload ([[board-lean]]): together ~56% of
99
+ // `body` and its derivation `parts` are DROPPED from the board payload ([[graph-lean]]): together ~56% of
100
100
  // the bytes, and detail the graph overview never renders. The detail view fetches them per node from
101
101
  // `/api/specs/:id/content` on open, and the search palette fetches the body corpus from `/api/specs/lite`
102
102
  // once on open — both off this hot poll. `undefined` makes JSON.stringify omit the keys.
@@ -109,8 +109,21 @@ export async function buildBoard() {
109
109
  // openIssues, attached only when non-empty). Non-blocking: residentForgeState never waits on `gh` and is
110
110
  // empty absent a forge, so the fold then carries the local slice alone. Sorted open-first, newest first.
111
111
  const isOpen = (i: { status: string }) => i.status === 'open'
112
+ const merged = mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id))
113
+ // ONE board-level freshness stamp over EVERY issue thread (noded or nodeless, both stores):
114
+ // open-count : thread-count : reply-count : latest-activity. Every thread write — open, reply, remark,
115
+ // resolve, retract, close — moves at least one component, so a store write ALWAYS moves board bytes:
116
+ // [[graph-delta]] suppresses no-change broadcasts, and without a moving byte an external write would
117
+ // stay invisible to viewers until the fallback poll ([[remark-substrate]] write-visibility). The per-node
118
+ // fold below stays [[graph-lean]]-slim (no reply payloads); this stamp is the freshness carrier.
119
+ const issuesStamp = [
120
+ merged.filter(isOpen).length,
121
+ merged.length,
122
+ merged.reduce((n, i) => n + i.replies.length, 0),
123
+ merged.flatMap((i) => [i.created, ...i.replies.flatMap((r) => [r.at, r.resolvedAt ?? ''])]).reduce((a, b) => (b > a ? b : a), ''),
124
+ ].join(':')
112
125
  const issuesByNode: Record<string, ReturnType<typeof mergedIssues>> = {}
113
- for (const issue of mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id)))
126
+ for (const issue of merged)
114
127
  for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
115
128
  for (const n of nodes) {
116
129
  const issues = issuesByNode[n.id]
@@ -122,20 +135,20 @@ export async function buildBoard() {
122
135
  if (open.length) n.openIssues = open
123
136
  }
124
137
 
125
- // fold each yatsu node's eval state onto it — as the LEAN summary ([[board-lean]]): `evals` carries only
138
+ // fold each measurable node's eval state onto it — as the LEAN summary ([[graph-lean]]): `evals` carries only
126
139
  // the LATEST reading per scenario (newest-first), which is all any overview surface consumes (the score
127
140
  // badge, stats, search all reduce to latest-per-scenario anyway); the full timeline stays off the board
128
141
  // and is lazy-loaded by the eval tab from `/api/specs/:id/evals`. `scenarios` (the declared set) rides
129
142
  // SLIM — {name, tags} only, the fields every overview surface joins state onto — with its prose
130
143
  // (description/expected) and per-scenario code off the hot poll: they ride the `/api/specs/lite` corpus
131
144
  // (search palette, focus-panel preview) and the `/api/specs/:id/evals` timeline (eval tab).
132
- // evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-yatsu nodes. The
133
- // yatsu walk rides fs/promises ([[board-cache]]) so it yields the event loop instead of stalling /health.
134
- const ynodes = await yatsuNodesAsync(root)
145
+ // evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-measurable nodes. The
146
+ // eval-file walk rides fs/promises ([[graph-cache]]) so it yields the event loop instead of stalling /health.
147
+ const ynodes = await evalNodesAsync(root)
135
148
  const ectx = await evalContext(root, specs, idx, hidx, undefined, ynodes)
136
149
  await Promise.all(nodes.map(async (n) => {
137
150
  const tl = await evalTimeline(n.id, ectx)
138
- if (tl.hasYatsu) { n.evals = latestPerScenario(tl.readings); n.scenarios = slimScenarios(tl.scenarios) }
151
+ if (tl.hasEvalFile) { n.evals = latestPerScenario(tl.readings); n.scenarios = slimScenarios(tl.scenarios) }
139
152
  }))
140
153
 
141
154
  const opsByPath: Record<string, any[]> = {}
@@ -144,6 +157,23 @@ export async function buildBoard() {
144
157
 
145
158
  const dash = readConfig(root).dashboard
146
159
  // project names the tab ([[tab-title]]); projectIcon is the tab favicon ([[tab-icon]]) — both ride the
147
- // /api/board poll so they re-derive from whichever backend the viewer reached. Empty icon → frontend default.
148
- return { nodes, sessions: sess, project: dash?.title || basename(root), projectIcon: dash?.icon || '' }
160
+ // /api/graph poll so they re-derive from whichever backend the viewer reached. Empty icon → frontend default.
161
+ return { nodes, sessions: sess, project: dash?.title || basename(root), projectIcon: dash?.icon || '', issuesStamp }
162
+ }
163
+
164
+ // @@@ spliceSessions — the SESSIONS-ONLY producer ([[graph-cache]]). A session-scoped change (a lifecycle
165
+ // write, a liveness/activity poll flip) reshapes only the board's `sessions` rows — the node/meta units are
166
+ // untouched — so the cache re-derives ONLY the sessions and splices them onto the previous board verbatim,
167
+ // skipping the whole loadSpecs/layout/eval assembly a full buildBoard() pays. Pure aside from listSessions:
168
+ // each row is decorated EXACTLY as buildBoard's sess mapping (`{...s, source: s.path, ops}`), and every
169
+ // path's `ops` is REUSED from the previous board (a path→ops map). A session path absent in `prev` gets []
170
+ // — a brand-new worktree has no pending spec ops yet, and any later ops-CHANGING event (a commit, a
171
+ // worktree `.spec` edit) is refs/worktree-scoped, i.e. a FULL rebuild, never a sessions splice. So the
172
+ // splice is byte-indistinguishable from a full rebuild whenever only session state moved.
173
+ export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>>): Promise<Awaited<ReturnType<typeof buildBoard>>> {
174
+ const sessions = await listSessions()
175
+ const opsByPath: Record<string, any[]> = {}
176
+ for (const s of prev.sessions) opsByPath[s.source] = s.ops
177
+ const sess = sessions.map((s) => ({ ...s, source: s.path, ops: opsByPath[s.path] || [] }))
178
+ return { ...prev, sessions: sess }
149
179
  }
@@ -1,13 +1,13 @@
1
- import { buildBoard } from './board.js'
1
+ import { buildBoard, spliceSessions } from './graph.js'
2
2
 
3
- // @@@ board-cache — single-flight + cache for the hot /api/board build ([[board-lean]]). Assembling the
3
+ // @@@ graph-cache — single-flight + cache for the hot /api/graph build ([[graph-lean]]). Assembling the
4
4
  // board is expensive (two full-history git-log walks cold, a full `.spec` fs walk every build), so the
5
5
  // route MUST NOT rebuild per request: index.ts once ran `buildBoard()` inline on EVERY poll, so a normal
6
6
  // dashboard's overlapping polls (+ SSE-triggered refetches) multiplied into N simultaneous builds and
7
7
  // starved the event loop — one real user could wedge the backend. Here ONE build is shared by all
8
8
  // concurrent callers (a promise memo — this IS the max-concurrent-builds cap: at most one runs) and its
9
9
  // result is cached until a REAL change invalidates it. The cache is invalidated by the SAME freshness
10
- // signals [[board-stream]] already watches (session-store writes, git-ref moves, the cold tick), via
10
+ // signals [[graph-stream]] already watches (session-store writes, git-ref moves, the cold tick), via
11
11
  // invalidateBoard(). So a poll storm costs ONE build, a quiet stretch costs ZERO, and the SSE rebuild and
12
12
  // the route share the very same in-flight build.
13
13
 
@@ -22,7 +22,7 @@ const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500)
22
22
  // below, so a never-settling buildBoard() would pin the single-flight forever — every later read (even of a
23
23
  // perfectly good cached board) short-circuits into the pinned promise before `valid` is consulted,
24
24
  // invalidation can't help, no log ever fires, and only a restart cures it (the live wedge: hung git
25
- // children → /api/board 503 forever, silently). So the build races a generous watchdog that REJECTS loudly;
25
+ // children → /api/graph 503 forever, silently). So the build races a generous watchdog that REJECTS loudly;
26
26
  // the rejection flows through the SAME finally → inflight clears → the next read retries fresh. Sitting at
27
27
  // the single-flight boundary, this one wall bounds every never-settle cause — including ones with no child
28
28
  // process at all (fs/promises under libuv threadpool starvation); git.ts's per-child timeouts merely make
@@ -30,53 +30,73 @@ const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500)
30
30
  // fires on a genuine wedge.
31
31
  const BUILD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_BUILD_TIMEOUT_MS || 120000)
32
32
 
33
- let cached: Board | null = null // last completed build; served while `valid`
33
+ // the cache's staleness has a DOMAIN, not just a bit: a 'sessions' change (a lifecycle write, a
34
+ // liveness/activity poll flip) touches only the session rows, so the next read can SPLICE fresh sessions
35
+ // onto the still-valid node/meta units instead of re-walking git+`.spec`; a 'full' change (a ref move, a
36
+ // worktree `.spec` edit, the cold-tick patrol) can reshape anything, so the next read does the whole
37
+ // buildBoard(). 'none' = clean.
38
+ type Scope = 'sessions' | 'full'
39
+ let cached: Board | null = null // last completed build; served while `dirty === 'none'`
34
40
  let cachedJson: string | null = null // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
35
- let valid = false
41
+ let dirty: Scope | 'none' = 'full' // no cached board yet → the first read builds fully
36
42
  let inflight: Promise<Board> | null = null
37
43
  let gen = 0 // bumped on every invalidation — detects a change that landed MID-build
38
44
 
39
- // mark the cache stale. Called by every board-stream freshness source (see boardStream.fireChanged), so a
40
- // real change forces the next getBoard() to rebuild while a quiet poll storm keeps hitting the cache.
41
- export function invalidateBoard(): void {
45
+ // mark the cache stale at a SCOPE. Called by every board-stream freshness source (see
46
+ // boardStream.fireChanged), so a real change forces the next getBoard() to rebuild while a quiet poll storm
47
+ // keeps hitting the cache. The scope only ESCALATES within a dirty window: none→sessions→full, and a
48
+ // 'sessions' signal arriving while 'full' is already pending stays 'full' (a full rebuild subsumes a
49
+ // sessions splice). cachedJson is dropped either way — a splice replaces the board object, so its old
50
+ // serialization is stale regardless of scope.
51
+ export function invalidateBoard(scope: Scope = 'full'): void {
42
52
  gen++
43
- valid = false
53
+ if (scope === 'full' || dirty === 'full') dirty = 'full'
54
+ else dirty = 'sessions'
55
+ cachedJson = null
44
56
  }
45
57
 
46
58
  // the coalesced board read the route and the SSE rebuild both go through. A concurrent caller during a
47
59
  // build shares the in-flight promise; a caller after a completed build gets the cached value until the
48
- // next invalidation. A change that lands WHILE a build runs (gen moved) leaves the cache invalid so the
49
- // NEXT read rebuilds the just-finished build still returns to its waiters (freshest available when they
50
- // asked), never cached as current. Mirrors [[board-stream]]'s building/dirty loop.
60
+ // next invalidation. A 'sessions'-scoped dirty with a cached board takes the SPLICE path (spliceSessions
61
+ // fresh session rows onto the cached node/meta units) under the SAME single-flight promise + watchdog +
62
+ // generation rules; anything else (dirty 'full', or no cache to splice onto) does a full buildBoard(). A
63
+ // change that lands WHILE a build runs (gen moved) leaves the cache dirty so the NEXT read rebuilds — a
64
+ // 'full' invalidation landing mid-splice leaves it dirty 'full' for the next read. The just-finished build
65
+ // still returns to its waiters (freshest available when they asked), never cached as current. Mirrors
66
+ // [[graph-stream]]'s building/dirty loop.
51
67
  export function getBoard(): Promise<Board> {
52
68
  if (inflight) return inflight
53
- if (valid && cached) return Promise.resolve(cached)
69
+ if (dirty === 'none' && cached) return Promise.resolve(cached)
54
70
  const startGen = gen
71
+ const sessionsOnly = dirty === 'sessions' && cached !== null
72
+ const prev = cached
55
73
  const p = (async () => {
56
74
  const t0 = Date.now()
57
75
  let watchdog: ReturnType<typeof setTimeout> | undefined
58
76
  try {
59
77
  const board = await Promise.race([
60
- buildBoard(),
78
+ sessionsOnly ? spliceSessions(prev!) : buildBoard(),
61
79
  // the race consumes the loser's eventual settlement, so an abandoned build that fails later
62
80
  // can't surface as an unhandled rejection; unref'd so a pending watchdog never holds a one-shot
63
81
  // CLI process open.
64
82
  new Promise<never>((_, reject) => {
65
83
  watchdog = setTimeout(() => {
66
- console.warn(`spec-cli: /api/board build did not settle within ${BUILD_TIMEOUT_MS}ms — wedged build abandoned so the next read can retry`)
67
- reject(new Error(`board build did not settle within ${BUILD_TIMEOUT_MS}ms`))
84
+ console.warn(`spec-cli: /api/graph build did not settle within ${BUILD_TIMEOUT_MS}ms — wedged build abandoned so the next read can retry`)
85
+ reject(new Error(`graph build did not settle within ${BUILD_TIMEOUT_MS}ms`))
68
86
  }, BUILD_TIMEOUT_MS)
69
87
  watchdog.unref?.()
70
88
  }),
71
89
  ])
72
90
  cached = board
73
91
  cachedJson = null // invalidate the memoized serialization; re-serialized lazily on first read
74
- valid = gen === startGen
92
+ // clean ONLY if no invalidation landed during the build; otherwise leave `dirty` at whatever scope
93
+ // those in-build invalidations escalated it to (a mid-splice 'full' stays 'full' for the next read).
94
+ if (gen === startGen) dirty = 'none'
75
95
  return board
76
96
  } finally {
77
97
  clearTimeout(watchdog)
78
98
  const ms = Date.now() - t0
79
- if (ms > BUDGET_MS) console.warn(`spec-cli: /api/board build took ${ms}ms (budget ${BUDGET_MS}ms) — hot path is slow`)
99
+ if (ms > BUDGET_MS) console.warn(`spec-cli: /api/graph build took ${ms}ms (budget ${BUDGET_MS}ms) — hot path is slow`)
80
100
  inflight = null
81
101
  }
82
102
  })()
@@ -84,9 +104,9 @@ export function getBoard(): Promise<Board> {
84
104
  return p
85
105
  }
86
106
 
87
- // the SERIALIZED board for the /api/board route — JSON.stringify runs ONCE per build, not once per poll,
107
+ // the SERIALIZED board for the /api/graph route — JSON.stringify runs ONCE per build, not once per poll,
88
108
  // so a poll storm of cache hits costs zero serialization CPU (only the etag hash for the 304 path). The SSE
89
- // path still takes the object (getBoard) because it decomposes it into delta units ([[board-delta]]).
109
+ // path still takes the object (getBoard) because it decomposes it into delta units ([[graph-delta]]).
90
110
  export async function getBoardJson(): Promise<string> {
91
111
  const board = await getBoard()
92
112
  if (board === cached && cachedJson !== null) return cachedJson
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto'
2
2
 
3
3
  // @@@ board-delta — the pure core of the board's incremental push: decompose a board snapshot into a keyed
4
- // UNIT MAP, tag it, and diff two unit maps into a minimal {set, del} patch. The transport ([[board-stream]])
4
+ // UNIT MAP, tag it, and diff two unit maps into a minimal {set, del} patch. The transport ([[graph-stream]])
5
5
  // chains these patches over SSE (`from`/`to` tags) so a subscribed dashboard applies a few KB per change
6
6
  // instead of refetching the full ~600KB snapshot; the client-side mirror of apply/reconstruct lives in the
7
7
  // dashboard's data layer. Everything here is pure and synchronous — no fs, no git, no stream — so the