spexcode 0.2.8 → 0.4.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 (101) 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/anchors.ts +300 -0
  7. package/spec-cli/src/attach.ts +2 -2
  8. package/spec-cli/src/cli.ts +691 -536
  9. package/spec-cli/src/client.ts +31 -30
  10. package/spec-cli/src/contract-filter.ts +1 -1
  11. package/spec-cli/src/doctor.ts +40 -13
  12. package/spec-cli/src/gateway.ts +11 -7
  13. package/spec-cli/src/git.ts +23 -15
  14. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  15. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  16. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  17. package/spec-cli/src/graphStream.ts +288 -0
  18. package/spec-cli/src/guide.ts +142 -98
  19. package/spec-cli/src/harness-select.ts +2 -2
  20. package/spec-cli/src/harness.ts +30 -14
  21. package/spec-cli/src/help.ts +300 -385
  22. package/spec-cli/src/hooks.ts +1 -1
  23. package/spec-cli/src/index.ts +144 -103
  24. package/spec-cli/src/init.ts +14 -13
  25. package/spec-cli/src/issues.ts +89 -33
  26. package/spec-cli/src/layout.ts +5 -5
  27. package/spec-cli/src/lint.ts +128 -30
  28. package/spec-cli/src/localIssues.ts +61 -60
  29. package/spec-cli/src/materialize.ts +1 -1
  30. package/spec-cli/src/mentions.ts +15 -15
  31. package/spec-cli/src/migrate-table.ts +398 -0
  32. package/spec-cli/src/migrate.ts +386 -0
  33. package/spec-cli/src/ranker.ts +30 -4
  34. package/spec-cli/src/reaper.ts +117 -0
  35. package/spec-cli/src/search.bench.mjs +12 -12
  36. package/spec-cli/src/search.ts +1 -1
  37. package/spec-cli/src/sessions.ts +244 -138
  38. package/spec-cli/src/specs.ts +40 -25
  39. package/spec-cli/src/supervise.ts +2 -2
  40. package/spec-cli/src/tree.ts +5 -5
  41. package/spec-cli/src/uninstall.ts +4 -4
  42. package/spec-cli/templates/hooks/post-checkout +1 -1
  43. package/spec-cli/templates/hooks/post-merge +3 -3
  44. package/spec-cli/templates/hooks/pre-commit +9 -9
  45. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  51. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  52. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
  56. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  59. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  60. package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
  61. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  62. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  63. package/spec-cli/templates/spec/project/spec.md +1 -1
  64. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
  65. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
  66. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
  67. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
  68. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
  69. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
  70. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
  71. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
  74. package/spec-dashboard/dist/index.html +2 -2
  75. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  76. package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
  77. package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
  78. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  79. package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
  80. package/spec-eval/src/humanok.ts +43 -0
  81. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  82. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  83. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  84. package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
  85. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  86. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  87. package/spec-forge/src/cli.ts +14 -13
  88. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  89. package/spec-cli/src/boardStream.ts +0 -179
  90. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
  91. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  92. package/spec-cli/templates/spec/project/.config/spec.md +0 -15
  93. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  94. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  98. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  99. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  100. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  101. /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
@@ -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
@@ -0,0 +1,288 @@
1
+ import { streamSSE } from 'hono/streaming'
2
+ import type { Context } from 'hono'
3
+ import { watch, mkdirSync, readdirSync, readFileSync, existsSync, type FSWatcher } from 'node:fs'
4
+ import { join, dirname } from 'node:path'
5
+ import { sessionsRoot, gitCommonDir } from './layout.js'
6
+ import { hotSignature, warmSignature } from './sessions.js'
7
+ import { getBoard, invalidateBoard } from './graphCache.js'
8
+ import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
9
+
10
+ // @@@ board-stream — the board's freshness is PUSHED, not polled. A dashboard subscribes here ONCE; in
11
+ // plain mode it gets a bare `graph-changed` and refetches /api/graph (the legacy protocol, kept verbatim
12
+ // for old clients); in DELTA mode (`?mode=delta`) the server itself rebuilds on change and streams the
13
+ // hash-chained patch ([[graph-delta]]): a `graph-full {to, graph}` on connect, then `graph-delta
14
+ // {from, to, set, del}` per change — a few KB against the ~600KB snapshot, with a full-snapshot send
15
+ // whenever the patch wouldn't win (bigger than the board, or the unit decomposition's id-uniqueness
16
+ // precondition failed), so a delta subscriber is NEVER worse off than a full refetch.
17
+ //
18
+ // Every source carries the DOMAIN of the change it saw — 'sessions' (only the session rows moved) or 'full'
19
+ // (anything could have) — and fireChanged funnels them into ONE debounced pipeline, escalating to the max
20
+ // scope seen in the window so the cache can splice sessions instead of rebuilding whole ([[graph-cache]]).
21
+ // Sources: (1) fs.watch on the per-user session store — every lifecycle transition lands as a
22
+ // sessions/<id>/session.json write → 'sessions'; (2) fs.watch on the shared git dir's refs (+
23
+ // packed-refs/HEAD) — a commit/merge moves a ref, reshaping the tree → 'full'; (3) fs.watch on the git
24
+ // worktree REGISTRY (+ each live worktree's `.spec`) — an uncommitted spec edit in a linked worktree → 'full';
25
+ // (4) two subscriber-gated pollers of the tmux-derived signatures ([[sessions]]) that never touch a file —
26
+ // a 100ms HOT syscall poll and a 1s WARM tmux poll, both → 'sessions'; (5) a delta-gated ~15s cold-tick
27
+ // PATROL that invalidates FULL, rebuilds and diffs — the self-heal authority that catches whatever every
28
+ // leaf watcher missed (and is loud when it has to: see the repair accounting below) → 'full'. Plain mode
29
+ // without delta subscribers keeps its zero-build behavior: sources just fan out `graph-changed`.
30
+
31
+ type Scope = 'sessions' | 'full'
32
+ type Notify = () => void
33
+ type Frame = { event: string; data: string }
34
+ type DeltaSend = (frame: Frame) => void
35
+ const plainSubs = new Set<Notify>()
36
+ const deltaSubs = new Set<DeltaSend>()
37
+ let debounce: ReturnType<typeof setTimeout> | null = null
38
+ let pendingScope: Scope | null = null // the MAX change scope accumulated across the current debounce window
39
+ const maxScope = (a: Scope | null, b: Scope): Scope => (a === 'full' || b === 'full' ? 'full' : 'sessions')
40
+
41
+ // under SPEXCODE_BOARD_DEBUG=1, every broadcast logs its changed unit keys + trigger tags + build ms.
42
+ const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
43
+ // the set of trigger tags accrued SINCE THE LAST BROADCAST — each fireChanged adds its scope, the cold tick
44
+ // adds 'patrol'. Cleared on every broadcast. Its job: prove WHO caused a broadcast, so a change that only
45
+ // the patrol saw (tag set === {'patrol'}) is flagged as a repair — some leaf watcher was blind.
46
+ const triggerTags = new Set<string>()
47
+
48
+ // watchers a test can amputate to prove the patrol still heals the graph ([[graph-stream]]): a CSV of
49
+ // store,refs,worktrees makes the matching ensure* a no-op (with a one-time warning), so a change on that
50
+ // path reaches subscribers ONLY via the cold-tick patrol — the missing-watcher scenario, on demand.
51
+ const DISABLED = new Set((process.env.SPEXCODE_DISABLE_WATCHERS || '').split(',').map((s) => s.trim()).filter(Boolean))
52
+ const warnedDisabled = new Set<string>()
53
+ function isDisabled(name: string): boolean {
54
+ if (!DISABLED.has(name)) return false
55
+ if (!warnedDisabled.has(name)) { warnedDisabled.add(name); console.warn(`spec-cli: graph watcher '${name}' disabled via SPEXCODE_DISABLE_WATCHERS — the cold-tick patrol must cover it`) }
56
+ return true
57
+ }
58
+
59
+ // ---- the rebuild→diff→broadcast pipeline (runs only while delta subscribers exist) ----
60
+ // last successfully-broadcast snapshot: the delta chain's anchor. `lastFullFrame` is what a fresh
61
+ // subscriber gets instantly; `lastUnits`+`lastTag` are what the next diff chains from. A snapshot that
62
+ // failed the unitize precondition anchors nothing (lastUnits=null) so every following send is a full.
63
+ let lastUnits: Units | null = null
64
+ let lastTag = ''
65
+ let lastFullFrame: Frame | null = null
66
+ let building = false
67
+ let dirty = false
68
+
69
+ async function rebuildAndBroadcast(): Promise<void> {
70
+ if (building) { dirty = true; return }
71
+ building = true
72
+ try {
73
+ do {
74
+ dirty = false
75
+ let board: unknown
76
+ // share the route's single-flight build ([[graph-cache]]); fireChanged() already invalidated the
77
+ // cache (at the accumulated scope), so this gets a fresh build/splice (or joins one a concurrent poll
78
+ // already started).
79
+ const t0 = Date.now()
80
+ try { board = await getBoard() } catch { for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }; continue }
81
+ const buildMs = Date.now() - t0
82
+ const boardJson = JSON.stringify(board)
83
+ const { units, ok } = unitize(board as Record<string, unknown>)
84
+ const tag = tagOf(units)
85
+ if (tag === lastTag) continue
86
+ // the changed unit keys — computed against the prior anchor when we have one (a first paint has no
87
+ // anchor, so no repair claim can be made against it).
88
+ const changedKeys = lastUnits ? (() => { const { set, del } = diffUnits(lastUnits, units); return [...Object.keys(set), ...del] })() : []
89
+ const fullFrame: Frame = { event: 'graph-full', data: `{"to":"${tag}","graph":${boardJson}}` }
90
+ let frame = fullFrame
91
+ if (lastUnits && ok) {
92
+ const { set, del } = diffUnits(lastUnits, units)
93
+ const deltaData = JSON.stringify({ from: lastTag, to: tag, set, del })
94
+ // guaranteed win: ship the patch only when it actually beats the snapshot
95
+ if (deltaData.length < fullFrame.data.length) frame = { event: 'graph-delta', data: deltaData }
96
+ }
97
+ lastUnits = ok ? units : null
98
+ lastTag = tag
99
+ lastFullFrame = fullFrame
100
+ for (const send of [...deltaSubs]) { try { send(frame) } catch { /* swept on abort */ } }
101
+ for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
102
+ // ---- repair accounting: a real (tag-moved) broadcast whose ONLY trigger was the cold-tick patrol
103
+ // means a leaf watcher was BLIND — the patrol self-healed it. That is a bug report, not routine, so
104
+ // it is ALWAYS loud (repairs are supposed to be zero — [[graph-stream]]). Under DEBUG, every
105
+ // broadcast logs its changed keys + triggers + build ms.
106
+ const tags = [...triggerTags]
107
+ if (changedKeys.length && tags.length === 1 && tags[0] === 'patrol')
108
+ console.warn(`spec-cli: PATROL-REPAIR — the cold tick caught a change no leaf watcher pushed; changed units: [${changedKeys.join(', ')}] — a blind watcher, investigate`)
109
+ if (DEBUG)
110
+ console.warn(`spec-cli: graph broadcast — changed [${changedKeys.join(', ')}] triggers {${tags.join(', ')}} build ${buildMs}ms`)
111
+ triggerTags.clear()
112
+ } while (dirty)
113
+ } finally { building = false }
114
+ }
115
+
116
+ // a merge/launch/close touches several record files at once; collapse the burst into ONE signal. Each call
117
+ // carries its change SCOPE; the window accumulates the MAX ([[graph-cache]] escalates none→sessions→full).
118
+ // With delta subscribers the debounced fire rebuilds and broadcasts (plain subs then ride the same
119
+ // tag-moved gate — no spurious refetches); without them it stays the zero-build legacy notify.
120
+ function fireChanged(scope: Scope = 'full'): void {
121
+ pendingScope = maxScope(pendingScope, scope)
122
+ // invalidate the route's board cache ([[graph-cache]]) on EVERY change signal, at the accumulated scope,
123
+ // before the debounce guard — a plain-mode client that polls /api/graph (no delta rebuild here) must
124
+ // still see fresh data on its next poll, and a delta rebuild below re-reads the same now-stale cache.
125
+ invalidateBoard(pendingScope)
126
+ triggerTags.add(scope)
127
+ // DEBOUNCE = 25ms. Real fs-event bursts (a merge touching many records) were MEASURED to span 0–5ms, so a
128
+ // 25ms window collapses them with room to spare while shaving ~125ms off the old 150ms lag; anything
129
+ // wider than the window is coalesced anyway by the in-flight build's dirty-rerun loop, which is the real
130
+ // burst absorber. So the debounce is a micro-collapse, not the coalescer.
131
+ if (debounce) return
132
+ debounce = setTimeout(() => {
133
+ debounce = null
134
+ pendingScope = null
135
+ if (deltaSubs.size) void rebuildAndBroadcast()
136
+ else for (const notify of [...plainSubs]) { try { notify() } catch { /* swept on abort */ } }
137
+ }, 25)
138
+ }
139
+
140
+ // ---- event source 0: an EXPLICIT server-side nudge ----
141
+ // for a server-side mutation that must show instantly regardless of watcher health: /rename writes the
142
+ // session's global record (`session.json` — [[session-rename]]), which lives INSIDE the watched store, so
143
+ // source 1 normally sees the write too. The explicit route call stays because that fs watch is best-effort
144
+ // (it can fail to attach), and the nudge makes the sub-second rename guarantee deterministic. Same
145
+ // debounced funnel as every other source; defaults to 'full' but the rename route passes 'sessions'.
146
+ export const notifyBoardChanged = (scope: Scope = 'full'): void => fireChanged(scope)
147
+
148
+ // ---- event source 1: the session store (lifecycle status writes) → 'sessions' ----
149
+ let watcher: FSWatcher | null = null
150
+ function ensureWatcher(): void {
151
+ if (watcher) return
152
+ if (isDisabled('store')) return
153
+ const root = sessionsRoot()
154
+ try { mkdirSync(root, { recursive: true }) } catch { /* best-effort; the watch below still tries */ }
155
+ try { watcher = watch(root, { recursive: true }, () => fireChanged('sessions')) } catch { watcher = null }
156
+ }
157
+
158
+ // ---- event source 2: git refs (a commit/merge reshapes the tree the moment the ref moves) → 'full' ----
159
+ // refs/ recursively for loose refs (heads, worktree branches), plus the common dir itself non-recursively
160
+ // for packed-refs rewrites and HEAD flips. Best-effort like every source: no watch → the cold tick covers.
161
+ let refsWatchers: FSWatcher[] | null = null
162
+ function ensureRefsWatcher(): void {
163
+ if (refsWatchers) return
164
+ if (isDisabled('refs')) return
165
+ refsWatchers = []
166
+ try {
167
+ const common = gitCommonDir()
168
+ try { refsWatchers.push(watch(join(common, 'refs'), { recursive: true }, () => fireChanged('full'))) } catch { /* loose refs unwatched */ }
169
+ try { refsWatchers.push(watch(common, (_e, f) => { if (f === 'packed-refs' || f === 'HEAD') fireChanged('full') })) } catch { /* packed refs unwatched */ }
170
+ } catch { /* not a repo? the cold tick still covers */ }
171
+ }
172
+
173
+ // ---- event source 3: the git worktree REGISTRY + each live worktree's `.spec` → 'full' ----
174
+ // An UNCOMMITTED spec edit in a linked worktree moves no ref and writes no session record, so neither
175
+ // source 1 nor 2 sees it — only a watch on the worktree's own `.spec` does. The registry (`<git-common>/
176
+ // worktrees/<name>/`) is the index of live worktrees; watching it non-recursively catches add/remove of a
177
+ // worktree, and on each event we RECONCILE the per-worktree `.spec` watchers (resolving each entry's tree
178
+ // via its `gitdir` file). Everything best-effort: a failed watch just leaves that path to the patrol.
179
+ let registryWatcher: FSWatcher | null = null
180
+ const specWatchers = new Map<string, FSWatcher>() // registry entry name → recursive watch on <worktree>/.spec
181
+ function reconcileWorktrees(): void {
182
+ let dir: string
183
+ try { dir = join(gitCommonDir(), 'worktrees') } catch { return }
184
+ let ents: import('node:fs').Dirent[] = []
185
+ try { ents = readdirSync(dir, { withFileTypes: true }) } catch { /* no worktrees registry yet */ }
186
+ const live = new Set<string>()
187
+ for (const e of ents) {
188
+ if (!e.isDirectory()) continue
189
+ live.add(e.name)
190
+ if (specWatchers.has(e.name)) continue
191
+ try {
192
+ // the entry's `gitdir` file points at the worktree's `<tree>/.git` (file or dir); its parent is the tree.
193
+ const wtPath = dirname(readFileSync(join(dir, e.name, 'gitdir'), 'utf8').trim())
194
+ const specDir = join(wtPath, '.spec')
195
+ if (existsSync(specDir)) specWatchers.set(e.name, watch(specDir, { recursive: true }, () => fireChanged('full')))
196
+ } catch { /* best-effort; the patrol covers an unwatched worktree */ }
197
+ }
198
+ for (const [name, w] of [...specWatchers]) if (!live.has(name)) { try { w.close() } catch { /* already gone */ } ; specWatchers.delete(name) }
199
+ }
200
+ function ensureWorktreeRegistry(): void {
201
+ if (registryWatcher) return
202
+ if (isDisabled('worktrees')) return
203
+ try {
204
+ const dir = join(gitCommonDir(), 'worktrees')
205
+ try { mkdirSync(dir, { recursive: true }) } catch { /* best-effort */ }
206
+ // a registry add/remove is itself a 'full' change (a new/gone worktree reshapes the overlay); also
207
+ // reconcile the per-worktree `.spec` watchers on every registry event.
208
+ registryWatcher = watch(dir, () => { reconcileWorktrees(); fireChanged('full') })
209
+ } catch { registryWatcher = null }
210
+ reconcileWorktrees() // attach for the worktrees that already exist when the source starts
211
+ }
212
+
213
+ // ---- event source 4: the two-tier tmux-derived pollers (liveness + activity — never a file write) → 'sessions' ----
214
+ // The signals a store watch can't see are tmux-derived, and they split by cost ([[sessions]]): a HOT 100ms
215
+ // poll of a cheap syscall-only fingerprint (a socket dying, a listener wedging) and a WARM 1s poll of the
216
+ // pane-title self-summaries (a headline change is a tmux round-trip, too dear at 100ms). Both fire 'sessions'.
217
+ let hotPoller: ReturnType<typeof setInterval> | null = null
218
+ let warmPoller: ReturnType<typeof setInterval> | null = null
219
+ let lastHot = ''
220
+ let lastWarm = ''
221
+ function ensurePollers(): void {
222
+ if (!hotPoller) hotPoller = setInterval(() => {
223
+ void hotSignature().then((sig) => { if (sig !== lastHot) { lastHot = sig; fireChanged('sessions') } }).catch(() => {})
224
+ }, 100)
225
+ if (!warmPoller) warmPoller = setInterval(() => {
226
+ void warmSignature().then((sig) => { if (sig !== lastWarm) { lastWarm = sig; fireChanged('sessions') } }).catch(() => {})
227
+ }, 1000)
228
+ }
229
+
230
+ // ---- event source 5: the cold-tick PATROL — the server-side replacement for every client's slow fallback
231
+ // poll, AND the self-heal authority. Rebuild+diff on a relaxed timer so what NO watcher saw (an uncommitted
232
+ // worktree spec edit a registry watch missed, a forge issue refresh) still lands. It INVALIDATES FULL first
233
+ // — otherwise getBoard() serves the stale cache and the patrol is a no-op (the real bug this fixes) — and
234
+ // tags the window 'patrol' so the repair accounting can flag a change only it caught. Delta-gated: plain-only
235
+ // clients keep their own client-side fallback, so without delta subscribers this must not burn builds.
236
+ let coldTick: ReturnType<typeof setInterval> | null = null
237
+ function ensureColdTick(): void {
238
+ if (coldTick) return
239
+ coldTick = setInterval(() => {
240
+ if (!deltaSubs.size) return
241
+ invalidateBoard('full')
242
+ triggerTags.add('patrol')
243
+ void rebuildAndBroadcast()
244
+ }, 15000)
245
+ }
246
+
247
+ function stopSourcesIfIdle(): void {
248
+ if (plainSubs.size + deltaSubs.size > 0) return
249
+ if (hotPoller) { clearInterval(hotPoller); hotPoller = null; lastHot = '' }
250
+ if (warmPoller) { clearInterval(warmPoller); warmPoller = null; lastWarm = '' }
251
+ if (coldTick) { clearInterval(coldTick); coldTick = null }
252
+ }
253
+
254
+ // GET /api/graph/stream — one SSE per dashboard tab, server→client only, with a periodic `ping` so an
255
+ // idle proxy never times the connection out. On a backend hot-reload the stream drops and EventSource
256
+ // auto-reconnects to the fresh child; a delta subscriber's reconnect lands a fresh `graph-full`, so the
257
+ // chain re-anchors with no client-side repair logic.
258
+ export function boardStream(c: Context) {
259
+ const delta = c.req.query('mode') === 'delta'
260
+ ensureWatcher()
261
+ ensureRefsWatcher()
262
+ ensureWorktreeRegistry()
263
+ return streamSSE(c, async (stream) => {
264
+ let aborted = false
265
+ const send: DeltaSend = (frame) => { void stream.writeSSE(frame).catch(() => {}) }
266
+ const notify: Notify = () => { void stream.writeSSE({ event: 'graph-changed', data: 'x' }).catch(() => {}) }
267
+ if (delta) { deltaSubs.add(send); ensureColdTick() } else { plainSubs.add(notify) }
268
+ ensurePollers()
269
+ const unsub = (): void => { deltaSubs.delete(send); plainSubs.delete(notify); stopSourcesIfIdle() }
270
+ stream.onAbort(() => { aborted = true; unsub() })
271
+ try {
272
+ await stream.writeSSE({ event: 'ready', data: 'x' })
273
+ if (delta) {
274
+ // seed the chain: the cached anchor snapshot immediately (same tag the next delta chains from),
275
+ // then a fire so a connect during a quiet stretch converges to truly-current within one build.
276
+ if (lastFullFrame) { await stream.writeSSE(lastFullFrame).catch(() => {}) ; fireChanged() }
277
+ else void rebuildAndBroadcast()
278
+ }
279
+ while (!aborted) {
280
+ // ping every 10s — the client's heartbeat contract is 2.5× this window ([[graph-stream]]), so a
281
+ // silent-death gap is caught inside one client watchdog interval, and idle proxies never time out.
282
+ await stream.sleep(10000)
283
+ if (aborted) break
284
+ await stream.writeSSE({ event: 'ping', data: 'x' })
285
+ }
286
+ } finally { unsub() }
287
+ })
288
+ }