spexcode 0.4.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +163 -30
  7. package/spec-cli/src/cli.ts +97 -42
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +31 -16
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +53 -36
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +157 -76
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +266 -67
  19. package/spec-cli/src/help.ts +84 -41
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +163 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +115 -169
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +162 -0
  39. package/spec-cli/src/sessions.ts +210 -63
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +47 -24
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
  48. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +80 -45
  90. package/spec-eval/src/evaltab.ts +4 -3
  91. package/spec-eval/src/humanok.ts +1 -1
  92. package/spec-eval/src/matrix.ts +693 -0
  93. package/spec-eval/src/scenarios.ts +116 -4
  94. package/spec-eval/src/sessioneval.ts +726 -66
  95. package/spec-cli/src/migrate-table.ts +0 -398
  96. package/spec-cli/src/migrate.ts +0 -386
  97. package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
  98. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  99. package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
  100. package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
  108. package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
  109. package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
  110. package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
  111. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
@@ -443,7 +443,7 @@ function parseNameStatus(out: string): { code: string; from: string; to: string
443
443
  return rows
444
444
  }
445
445
 
446
- export type ReviewDiffFile = { path: string; status: string; additions: number; deletions: number }
446
+ export type ReviewDiffFile = { path: string; oldPath?: string; status: string; additions: number; deletions: number }
447
447
  const DIFF_STATUS: Record<string, string> = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' }
448
448
  export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
449
449
  const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
@@ -453,14 +453,21 @@ export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<R
453
453
  run(['diff', '--numstat', '-M', `${base}..HEAD`]),
454
454
  run(['diff', '--name-status', '-M', `${base}..HEAD`]),
455
455
  ])
456
- const status = new Map<string, string>()
457
- for (const r of parseNameStatus(statusOut)) status.set(r.to, DIFF_STATUS[r.code] ?? r.code)
456
+ const status = new Map<string, { status: string; from: string }>()
457
+ for (const r of parseNameStatus(statusOut)) status.set(r.to, { status: DIFF_STATUS[r.code] ?? r.code, from: r.from })
458
458
  const files: ReviewDiffFile[] = []
459
459
  for (const line of numstatOut.split('\n')) {
460
460
  const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
461
461
  if (!m) continue
462
- const { to } = parseStatPath(m[3]) // numstat renders a rename as `{old => new}`; keep the final path
463
- files.push({ path: to, status: status.get(to) ?? 'modified', additions: m[1] === '-' ? 0 : +m[1], deletions: m[2] === '-' ? 0 : +m[2] })
462
+ const { from, to } = parseStatPath(m[3])
463
+ const detail = status.get(to)
464
+ files.push({
465
+ path: to,
466
+ ...(from !== to ? { oldPath: detail?.from ?? from } : {}),
467
+ status: detail?.status ?? 'modified',
468
+ additions: m[1] === '-' ? 0 : +m[1],
469
+ deletions: m[2] === '-' ? 0 : +m[2],
470
+ })
464
471
  }
465
472
  return files
466
473
  }
@@ -1,6 +1,5 @@
1
- import { basename } from 'path'
2
1
  import { loadSpecs, deriveStatus } from './specs.js'
3
- import { resolveLayout, readConfig } from './layout.js'
2
+ import { resolveLayout } from './layout.js'
4
3
  import { listSessions } from './sessions.js'
5
4
  import { repoRoot, driftIndex, historyIndex } from './git.js'
6
5
  import { residentForgeState } from '../../spec-forge/src/resident.js'
@@ -8,6 +7,11 @@ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
8
7
  import { mergedIssues } from './issues.js'
9
8
  import { evalContext, evalTimeline } from '../../spec-eval/src/evaltab.js'
10
9
  import { evalNodesAsync } from '../../spec-eval/src/scenarios.js'
10
+ import { resolveProjectIdentity } from './project-identity.js'
11
+ import { sessionEvalProjections } from '../../spec-eval/src/sessioneval.js'
12
+ import { publishReviewSnapshot } from './reviewSnapshot.js'
13
+ // @ts-expect-error Shared browser/server scoring keeps graph summary counts aligned with review rows.
14
+ import { evalReviewState } from '../../spec-dashboard/src/reviewFilters.js'
11
15
 
12
16
  // a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
13
17
  function resolveParent(path: string, byDir: Record<string, string>): string | null {
@@ -20,20 +24,22 @@ function resolveParent(path: string, byDir: Record<string, string>): string | nu
20
24
  return null
21
25
  }
22
26
 
23
- // the board's eval summary ([[graph-lean]]): the LATEST reading per scenario, each kept as the VERBATIM
24
- // reading object — a filter, never a projection. Consumers hang optional fields off a reading (the
25
- // annotator's timelineBlob rides only video readings), so dropping a field here is a SILENT downstream
26
- // degradation no error would surface; the field-preservation unit test pins this contract.
27
+ // The server-only review snapshot keeps latest readings verbatim. Graph JSON receives only counts.
27
28
  export function latestPerScenario<T extends { scenario: string }>(readings: T[]): T[] {
28
29
  const seen = new Set<string>()
29
30
  return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
30
31
  }
31
32
 
32
- // the board's scenario fold ([[graph-lean]]): the declared set rides SLIM {name, tags} is everything an
33
- // overview surface joins state onto (badge, stats, focus rows, search rows); the prose (description/
34
- // expected) and per-scenario code stay off the hot poll, carried by `/api/specs/lite` and `/api/specs/:id/evals`.
35
- export function slimScenarios(scenarios: { name: string; tags?: string[] }[]): { name: string; tags?: string[] }[] {
36
- return scenarios.map((s) => ({ name: s.name, ...(s.tags?.length ? { tags: s.tags } : {}) }))
33
+ export function nodeEvalSummary(scenarios: { name: string }[], readings: any[]) {
34
+ type State = 'pass' | 'fail' | 'stalePass' | 'staleFail' | 'empty'
35
+ const latest = new Map(latestPerScenario(readings).map((reading) => [reading.scenario, reading]))
36
+ const summary = { total: scenarios.length, pass: 0, fail: 0, stalePass: 0, staleFail: 0, empty: 0 }
37
+ for (const scenario of scenarios) {
38
+ const reading = latest.get(scenario.name)
39
+ const state = (reading ? evalReviewState(reading) : 'empty') as State
40
+ summary[state]++
41
+ }
42
+ return summary
37
43
  }
38
44
 
39
45
  export async function buildBoard() {
@@ -104,10 +110,8 @@ export async function buildBoard() {
104
110
  }),
105
111
  ...Object.values(ghostById),
106
112
  ]
107
- // fold each node's issues onto it through the unified Issue port ([[issues]]): the resident forge slice
108
- // AND the local store's threads, one merged store-tagged list (full set issues, open subset →
109
- // openIssues, attached only when non-empty). Non-blocking: residentForgeState never waits on `gh` and is
110
- // empty absent a forge, so the fold then carries the local slice alone. Sorted open-first, newest first.
113
+ // Reconcile Issues once. Full rows stay in the server-only review snapshot; graph nodes get counts and
114
+ // open identity only, enough for tile/stat/tree glances without reconstructing the list.
111
115
  const isOpen = (i: { status: string }) => i.status === 'open'
112
116
  const merged = mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id))
113
117
  // ONE board-level freshness stamp over EVERY issue thread (noded or nodeless, both stores):
@@ -128,37 +132,44 @@ export async function buildBoard() {
128
132
  for (const n of nodes) {
129
133
  const issues = issuesByNode[n.id]
130
134
  if (!issues || !issues.length) continue
131
- n.issues = issues
132
- .sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || b.created.localeCompare(a.created))
133
- .map((i) => ({ id: i.id, store: i.store, status: i.status, concern: i.concern, url: i.url }))
134
- const open = n.issues.filter(isOpen)
135
- if (open.length) n.openIssues = open
135
+ const open = issues.filter(isOpen)
136
+ n.reviewSummary = {
137
+ ...(n.reviewSummary || {}),
138
+ issues: { open: open.length, closed: issues.length - open.length, openIds: open.map((issue) => issue.id) },
139
+ }
136
140
  }
137
141
 
138
- // fold each measurable node's eval state onto it as the LEAN summary ([[graph-lean]]): `evals` carries only
139
- // the LATEST reading per scenario (newest-first), which is all any overview surface consumes (the score
140
- // badge, stats, search all reduce to latest-per-scenario anyway); the full timeline stays off the board
141
- // and is lazy-loaded by the eval tab from `/api/specs/:id/evals`. `scenarios` (the declared set) rides
142
- // SLIM — {name, tags} only, the fields every overview surface joins state onto — with its prose
143
- // (description/expected) and per-scenario code off the hot poll: they ride the `/api/specs/lite` corpus
144
- // (search palette, focus-panel preview) and the `/api/specs/:id/evals` timeline (eval tab).
142
+ // Reconcile current Evals once. Latest rows/declarations stay server-only for paged review; graph nodes
143
+ // receive the explicit per-state count projection and nothing row-shaped.
145
144
  // evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-measurable nodes. The
146
145
  // eval-file walk rides fs/promises ([[graph-cache]]) so it yields the event loop instead of stalling /health.
147
146
  const ynodes = await evalNodesAsync(root)
148
147
  const ectx = await evalContext(root, specs, idx, hidx, undefined, ynodes)
149
- await Promise.all(nodes.map(async (n) => {
148
+ const evalReviewNodes = (await Promise.all(nodes.map(async (n) => {
150
149
  const tl = await evalTimeline(n.id, ectx)
151
- if (tl.hasEvalFile) { n.evals = latestPerScenario(tl.readings); n.scenarios = slimScenarios(tl.scenarios) }
152
- }))
150
+ if (!tl.hasEvalFile) return null
151
+ const latest = latestPerScenario(tl.readings)
152
+ n.reviewSummary = { ...(n.reviewSummary || {}), evals: nodeEvalSummary(tl.scenarios, latest) }
153
+ return { id: n.id, hue: n.hue, scenarios: tl.scenarios, evals: latest, readings: tl.readings }
154
+ }))).filter((node): node is NonNullable<typeof node> => node !== null)
155
+
156
+ publishReviewSnapshot({ issues: merged, evalNodes: evalReviewNodes })
153
157
 
154
158
  const opsByPath: Record<string, any[]> = {}
155
159
  opWts.forEach((w) => { opsByPath[w.path] = w.ops })
156
- const sess = sessions.map((s) => ({ ...s, source: s.path, ops: opsByPath[s.path] || [] }))
160
+ const evalProjections = sessionEvalProjections(sessions)
161
+ const sess = sessions.map((s) => ({
162
+ ...s,
163
+ source: s.path,
164
+ ops: opsByPath[s.path] || [],
165
+ evalSummary: evalProjections.get(s.id),
166
+ }))
157
167
 
158
- const dash = readConfig(root).dashboard
159
- // project names the tab ([[tab-title]]); projectIcon is the tab favicon ([[tab-icon]]) both ride the
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 }
168
+ // One resolved identity projection feeds title, favicon, rail, and catalog compatibility. A worktree
169
+ // backend reads the actual served tree's branch config and identity; endpoint registration uses the
170
+ // same git toplevel, so a linked worktree occupies its own host slot instead of replacing main.
171
+ const identity = resolveProjectIdentity(root, root)
172
+ return { nodes, sessions: sess, identity, issuesStamp }
162
173
  }
163
174
 
164
175
  // @@@ spliceSessions — the SESSIONS-ONLY producer ([[graph-cache]]). A session-scoped change (a lifecycle
@@ -172,8 +183,14 @@ export async function buildBoard() {
172
183
  // splice is byte-indistinguishable from a full rebuild whenever only session state moved.
173
184
  export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>>): Promise<Awaited<ReturnType<typeof buildBoard>>> {
174
185
  const sessions = await listSessions()
186
+ const evalProjections = sessionEvalProjections(sessions)
175
187
  const opsByPath: Record<string, any[]> = {}
176
188
  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] || [] }))
189
+ const sess = sessions.map((s) => ({
190
+ ...s,
191
+ source: s.path,
192
+ ops: opsByPath[s.path] || [],
193
+ evalSummary: evalProjections.get(s.id),
194
+ }))
178
195
  return { ...prev, sessions: sess }
179
196
  }
@@ -1,11 +1,17 @@
1
1
  import { streamSSE } from 'hono/streaming'
2
2
  import type { Context } from 'hono'
3
- import { watch, mkdirSync, readdirSync, readFileSync, existsSync, type FSWatcher } from 'node:fs'
3
+ import { watch, mkdirSync, readdirSync, readFileSync, type FSWatcher } from 'node:fs'
4
4
  import { join, dirname } from 'node:path'
5
5
  import { sessionsRoot, gitCommonDir } from './layout.js'
6
6
  import { hotSignature, warmSignature } from './sessions.js'
7
7
  import { getBoard, invalidateBoard } from './graphCache.js'
8
8
  import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
9
+ import {
10
+ holdSessionEvalProjectionObserver,
11
+ invalidateSessionEvalProjections,
12
+ releaseSessionEvalProjectionObserver,
13
+ setSessionEvalProjectionNotify,
14
+ } from '../../spec-eval/src/sessioneval.js'
9
15
 
10
16
  // @@@ board-stream — the board's freshness is PUSHED, not polled. A dashboard subscribes here ONCE; in
11
17
  // plain mode it gets a bare `graph-changed` and refetches /api/graph (the legacy protocol, kept verbatim
@@ -21,7 +27,7 @@ import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
21
27
  // Sources: (1) fs.watch on the per-user session store — every lifecycle transition lands as a
22
28
  // sessions/<id>/session.json write → 'sessions'; (2) fs.watch on the shared git dir's refs (+
23
29
  // 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';
30
+ // worktree REGISTRY (+ each live worktree root and gitdir index) — dirty source/spec/sidecar/rename/stage → 'full';
25
31
  // (4) two subscriber-gated pollers of the tmux-derived signatures ([[sessions]]) that never touch a file —
26
32
  // a 100ms HOT syscall poll and a 1s WARM tmux poll, both → 'sessions'; (5) a delta-gated ~15s cold-tick
27
33
  // PATROL that invalidates FULL, rebuilds and diffs — the self-heal authority that catches whatever every
@@ -29,6 +35,7 @@ import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
29
35
  // without delta subscribers keeps its zero-build behavior: sources just fan out `graph-changed`.
30
36
 
31
37
  type Scope = 'sessions' | 'full'
38
+ type EvalTarget = 'all' | { id?: string; path?: string }
32
39
  type Notify = () => void
33
40
  type Frame = { event: string; data: string }
34
41
  type DeltaSend = (frame: Frame) => void
@@ -94,9 +101,12 @@ async function rebuildAndBroadcast(): Promise<void> {
94
101
  // guaranteed win: ship the patch only when it actually beats the snapshot
95
102
  if (deltaData.length < fullFrame.data.length) frame = { event: 'graph-delta', data: deltaData }
96
103
  }
97
- lastUnits = ok ? units : null
98
- lastTag = tag
99
- lastFullFrame = fullFrame
104
+ // the anchor is only meaningful while a delta subscriber holds the chain live: with none left,
105
+ // nothing rebuilds on change, so a cached anchor would silently age into a stale first frame for the
106
+ // NEXT era's subscriber (issue #70). A build that completes after the last unsub caches nothing
107
+ // (stopSourcesIfIdle cleared the anchor; leaving lastTag/lastUnits stale-cleared is consistent —
108
+ // rebuilds only run while delta subscribers exist, so nothing chains from them meanwhile).
109
+ if (deltaSubs.size) { lastUnits = ok ? units : null; lastTag = tag; lastFullFrame = fullFrame }
100
110
  for (const send of [...deltaSubs]) { try { send(frame) } catch { /* swept on abort */ } }
101
111
  for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
102
112
  // ---- repair accounting: a real (tag-moved) broadcast whose ONLY trigger was the cold-tick patrol
@@ -117,7 +127,10 @@ async function rebuildAndBroadcast(): Promise<void> {
117
127
  // carries its change SCOPE; the window accumulates the MAX ([[graph-cache]] escalates none→sessions→full).
118
128
  // With delta subscribers the debounced fire rebuilds and broadcasts (plain subs then ride the same
119
129
  // tag-moved gate — no spurious refetches); without them it stays the zero-build legacy notify.
120
- function fireChanged(scope: Scope = 'full'): void {
130
+ function fireChanged(scope: Scope = 'full', evalTarget?: EvalTarget): void {
131
+ // Advance eval input generations BEFORE invalidating/building the board, so the first frame caused by an
132
+ // input event is `updating(lastKnown)`. Summary completion calls this function without a target.
133
+ if (evalTarget) invalidateSessionEvalProjections(evalTarget)
121
134
  pendingScope = maxScope(pendingScope, scope)
122
135
  // invalidate the route's board cache ([[graph-cache]]) on EVERY change signal, at the accumulated scope,
123
136
  // before the debounce guard — a plain-mode client that polls /api/graph (no delta rebuild here) must
@@ -143,7 +156,11 @@ function fireChanged(scope: Scope = 'full'): void {
143
156
  // source 1 normally sees the write too. The explicit route call stays because that fs watch is best-effort
144
157
  // (it can fail to attach), and the nudge makes the sub-second rename guarantee deterministic. Same
145
158
  // 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)
159
+ export const notifyBoardChanged = (scope: Scope = 'full'): void =>
160
+ fireChanged(scope, scope === 'full' ? 'all' : undefined)
161
+
162
+ // Stable summary batches re-enter the SAME session-unit graph path; this is not a second transport.
163
+ setSessionEvalProjectionNotify(() => fireChanged('sessions'))
147
164
 
148
165
  // ---- event source 1: the session store (lifecycle status writes) → 'sessions' ----
149
166
  let watcher: FSWatcher | null = null
@@ -157,57 +174,268 @@ function ensureWatcher(): void {
157
174
 
158
175
  // ---- event source 2: git refs (a commit/merge reshapes the tree the moment the ref moves) → 'full' ----
159
176
  // 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.
177
+ // for packed-refs rewrites and HEAD flips. Ordinary graph units still have the patrol; eval projections are
178
+ // observer-held across a failure and only become current after a replacement watch authorizes a rescan.
161
179
  let refsWatchers: FSWatcher[] | null = null
162
- function ensureRefsWatcher(): void {
180
+ const REFS_OBSERVER = 'graph:refs'
181
+
182
+ export function watchSessionEvalRefs(
183
+ common: string,
184
+ onInput: () => void,
185
+ onFailure: () => void,
186
+ ): FSWatcher[] {
187
+ const attached: FSWatcher[] = []
188
+ let failed = false
189
+ const close = () => {
190
+ for (const watcher of attached) { try { watcher.close() } catch { /* already gone */ } }
191
+ }
192
+ const fail = () => {
193
+ if (failed) return
194
+ failed = true
195
+ close()
196
+ onFailure()
197
+ }
198
+ try {
199
+ attached.push(watch(join(common, 'refs'), { recursive: true }, (_event, file) => {
200
+ if (file == null) { fail(); return }
201
+ onInput()
202
+ }))
203
+ attached.push(watch(common, (_event, file) => {
204
+ if (file == null) { fail(); return }
205
+ if (file === 'packed-refs' || file === 'HEAD') onInput()
206
+ }))
207
+ for (const watcher of attached) watcher.on('error', fail)
208
+ return attached
209
+ } catch (error) {
210
+ close()
211
+ throw error
212
+ }
213
+ }
214
+
215
+ function refsWatcherFailed(): void {
216
+ refsWatchers = null
217
+ if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
218
+ setImmediate(() => ensureRefsWatcher(false))
219
+ }
220
+
221
+ function ensureRefsWatcher(retry = true): void {
163
222
  if (refsWatchers) return
164
- if (isDisabled('refs')) return
165
- refsWatchers = []
223
+ if (isDisabled('refs')) {
224
+ if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
225
+ return
226
+ }
166
227
  try {
167
228
  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 */ }
229
+ refsWatchers = watchSessionEvalRefs(common, () => fireChanged('full', 'all'), refsWatcherFailed)
230
+ if (releaseSessionEvalProjectionObserver(REFS_OBSERVER)) fireChanged('full')
231
+ } catch {
232
+ refsWatchers = null
233
+ if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
234
+ if (retry) setImmediate(() => ensureRefsWatcher(false))
235
+ }
171
236
  }
172
237
 
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.
238
+ // ---- event source 3: worktree registry + working roots + per-worktree indexes → 'full' ----
239
+ // Summary inputs include ordinary dirty source and staged-only changes, not just `.spec`. Each registry row
240
+ // therefore owns a recursive working-root watcher plus a non-recursive watcher on git's worktree metadata
241
+ // dir (`index`). A delivered event advances that worktree's eval generation before the graph rebuild.
179
242
  let registryWatcher: FSWatcher | null = null
180
- const specWatchers = new Map<string, FSWatcher>() // registry entry name → recursive watch on <worktree>/.spec
243
+ type WorktreeWatch = { path: string; root: FSWatcher; index: FSWatcher }
244
+ const worktreeWatchers = new Map<string, WorktreeWatch>()
245
+ const worktreeRetryAttempted = new Set<string>()
246
+ const worktreeRetryCount = new Map<string, number>()
247
+ const worktreeObserver = (name: string): string => `graph:worktree:${name}`
248
+
249
+ export function scheduleWorktreeResubscribe(
250
+ name: string,
251
+ attempted: Set<string>,
252
+ retry: () => void,
253
+ delayMs = 0,
254
+ ): boolean {
255
+ if (attempted.has(name)) return false
256
+ attempted.add(name)
257
+ const timer = setTimeout(() => {
258
+ attempted.delete(name)
259
+ retry()
260
+ }, delayMs)
261
+ timer.unref?.()
262
+ return true
263
+ }
264
+
265
+ function scheduleWorktreeRetry(name: string): boolean {
266
+ if (worktreeRetryAttempted.has(name)) return false
267
+ const attempt = (worktreeRetryCount.get(name) ?? 0) + 1
268
+ worktreeRetryCount.set(name, attempt)
269
+ const delayMs = Math.min(1_000, 25 * (2 ** Math.min(attempt - 1, 5)))
270
+ return scheduleWorktreeResubscribe(name, worktreeRetryAttempted, reconcileWorktrees, delayMs)
271
+ }
272
+
273
+ const ignoredWorktreePath = (file: string): boolean =>
274
+ file === '.git' || file.startsWith('.git/') || file === 'node_modules' || file.startsWith('node_modules/')
275
+
276
+ export function watchSessionEvalWorktree(
277
+ wtPath: string,
278
+ gitDir: string,
279
+ onInput: () => void,
280
+ onFailure: () => void,
281
+ ): { root: FSWatcher; index: FSWatcher } {
282
+ let failed = false
283
+ let root: FSWatcher | null = null
284
+ let index: FSWatcher | null = null
285
+ const fail = () => {
286
+ if (failed) return
287
+ failed = true
288
+ try { root?.close() } catch { /* already gone */ }
289
+ try { index?.close() } catch { /* already gone */ }
290
+ onFailure()
291
+ }
292
+ try {
293
+ root = watch(wtPath, { recursive: true }, (_event, filename) => {
294
+ if (filename == null) { fail(); return }
295
+ const file = String(filename)
296
+ if (!ignoredWorktreePath(file)) onInput()
297
+ })
298
+ index = watch(gitDir, (_event, filename) => {
299
+ if (filename == null) { fail(); return }
300
+ if (String(filename) === 'index') onInput()
301
+ })
302
+ root.on('error', fail)
303
+ index.on('error', fail)
304
+ return { root, index }
305
+ } catch (error) {
306
+ fail()
307
+ throw error
308
+ }
309
+ }
310
+
311
+ function dropWorktreeWatcher(name: string): WorktreeWatch | null {
312
+ const row = worktreeWatchers.get(name)
313
+ if (!row) return null
314
+ worktreeWatchers.delete(name)
315
+ try { row.root.close() } catch { /* already gone */ }
316
+ try { row.index.close() } catch { /* already gone */ }
317
+ return row
318
+ }
319
+
320
+ function watcherFailed(name: string, path: string): void {
321
+ // Failure/overflow has no trustworthy path. Keep last-known, mark the target updating, and immediately
322
+ // resubscribe; the authorized summary build is the one authoritative rescan, never a periodic sweep.
323
+ dropWorktreeWatcher(name)
324
+ if (holdSessionEvalProjectionObserver(worktreeObserver(name), { path })) fireChanged('full')
325
+ scheduleWorktreeRetry(name)
326
+ }
327
+
181
328
  function reconcileWorktrees(): void {
182
329
  let dir: string
183
330
  try { dir = join(gitCommonDir(), 'worktrees') } catch { return }
184
331
  let ents: import('node:fs').Dirent[] = []
185
332
  try { ents = readdirSync(dir, { withFileTypes: true }) } catch { /* no worktrees registry yet */ }
186
333
  const live = new Set<string>()
334
+ let released = false
187
335
  for (const e of ents) {
188
336
  if (!e.isDirectory()) continue
189
337
  live.add(e.name)
190
- if (specWatchers.has(e.name)) continue
338
+ if (worktreeWatchers.has(e.name) || worktreeRetryAttempted.has(e.name)) continue
191
339
  try {
192
340
  // the entry's `gitdir` file points at the worktree's `<tree>/.git` (file or dir); its parent is the tree.
193
341
  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 */ }
342
+ const { root, index } = watchSessionEvalWorktree(
343
+ wtPath,
344
+ join(dir, e.name),
345
+ () => fireChanged('full', { path: wtPath }),
346
+ () => watcherFailed(e.name, wtPath),
347
+ )
348
+ const row = { path: wtPath, root, index }
349
+ worktreeWatchers.set(e.name, row)
350
+ worktreeRetryAttempted.delete(e.name)
351
+ worktreeRetryCount.delete(e.name)
352
+ // The replacement is live before its hold is removed. This delta authorizes one double-read rescan,
353
+ // so edits made anywhere in the unwatched interval are inside the new generation's fingerprint.
354
+ if (releaseSessionEvalProjectionObserver(worktreeObserver(e.name))) fireChanged('full')
355
+ } catch {
356
+ // An attach failure is observable: mark unknown/full now. The next registry change or explicit graph
357
+ // source setup retries; no patrol is allowed to call the eval projection current.
358
+ let path: string | null = null
359
+ try { path = dirname(readFileSync(join(dir, e.name, 'gitdir'), 'utf8').trim()) } catch { /* broken row */ }
360
+ if (holdSessionEvalProjectionObserver(worktreeObserver(e.name), path ? { path } : 'all')) fireChanged('full')
361
+ scheduleWorktreeRetry(e.name)
362
+ }
363
+ }
364
+ for (const name of worktreeWatchers.keys()) if (!live.has(name)) {
365
+ dropWorktreeWatcher(name)
366
+ released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released
367
+ }
368
+ for (const name of worktreeRetryCount.keys()) if (!live.has(name)) {
369
+ worktreeRetryAttempted.delete(name)
370
+ worktreeRetryCount.delete(name)
371
+ released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released
197
372
  }
198
- for (const [name, w] of [...specWatchers]) if (!live.has(name)) { try { w.close() } catch { /* already gone */ } ; specWatchers.delete(name) }
373
+ if (released) fireChanged('full')
374
+ }
375
+ const WORKTREE_REGISTRY_OBSERVER = 'graph:worktree-registry'
376
+
377
+ export function watchSessionEvalRegistry(
378
+ dir: string,
379
+ onInput: () => void,
380
+ onFailure: () => void,
381
+ ): FSWatcher {
382
+ let watcher: FSWatcher | null = null
383
+ let failed = false
384
+ const fail = () => {
385
+ if (failed) return
386
+ failed = true
387
+ try { watcher?.close() } catch { /* already gone */ }
388
+ onFailure()
389
+ }
390
+ try {
391
+ watcher = watch(dir, (_event, file) => {
392
+ if (file == null) { fail(); return }
393
+ onInput()
394
+ })
395
+ watcher.on('error', fail)
396
+ return watcher
397
+ } catch (error) {
398
+ try { watcher?.close() } catch { /* partial attach */ }
399
+ throw error
400
+ }
401
+ }
402
+
403
+ function registryWatcherFailed(): void {
404
+ registryWatcher = null
405
+ if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
406
+ setImmediate(() => ensureWorktreeRegistry(false))
199
407
  }
200
- function ensureWorktreeRegistry(): void {
201
- if (registryWatcher) return
202
- if (isDisabled('worktrees')) return
408
+
409
+ function ensureWorktreeRegistry(retry = true): void {
410
+ if (registryWatcher) { reconcileWorktrees(); return }
411
+ if (isDisabled('worktrees')) {
412
+ if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
413
+ return
414
+ }
203
415
  try {
204
416
  const dir = join(gitCommonDir(), 'worktrees')
205
417
  try { mkdirSync(dir, { recursive: true }) } catch { /* best-effort */ }
206
418
  // a registry add/remove is itself a 'full' change (a new/gone worktree reshapes the overlay); also
207
419
  // reconcile the per-worktree `.spec` watchers on every registry event.
208
- registryWatcher = watch(dir, () => { reconcileWorktrees(); fireChanged('full') })
209
- } catch { registryWatcher = null }
420
+ registryWatcher = watchSessionEvalRegistry(dir, () => {
421
+ reconcileWorktrees()
422
+ fireChanged('full', 'all')
423
+ }, registryWatcherFailed)
424
+ } catch {
425
+ registryWatcher = null
426
+ if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
427
+ if (retry) setImmediate(() => ensureWorktreeRegistry(false))
428
+ }
210
429
  reconcileWorktrees() // attach for the worktrees that already exist when the source starts
430
+ if (registryWatcher && releaseSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER)) fireChanged('full')
431
+ }
432
+
433
+ // Attach the canonical filesystem sources before an HTTP snapshot starts summary work. This closes the
434
+ // request→SSE handoff gap: an edit after the snapshot build has a watcher before it can occur.
435
+ export function ensureBoardFileWatchers(): void {
436
+ ensureWatcher()
437
+ ensureRefsWatcher()
438
+ ensureWorktreeRegistry()
211
439
  }
212
440
 
213
441
  // ---- event source 4: the two-tier tmux-derived pollers (liveness + activity — never a file write) → 'sessions' ----
@@ -245,6 +473,11 @@ function ensureColdTick(): void {
245
473
  }
246
474
 
247
475
  function stopSourcesIfIdle(): void {
476
+ // the delta anchor dies with its era's last subscriber: past this point changes invalidate the cache but
477
+ // never rebuild, so lastFullFrame would drift arbitrarily far from the real board — and the next era's
478
+ // first subscriber would be anchored on it (its warm-terminal set then drops live sessions' panes, and the
479
+ // client's recovery lanes can latch each other out — issue #70). A new era opens on a fresh build instead.
480
+ if (deltaSubs.size === 0) { lastUnits = null; lastTag = ''; lastFullFrame = null }
248
481
  if (plainSubs.size + deltaSubs.size > 0) return
249
482
  if (hotPoller) { clearInterval(hotPoller); hotPoller = null; lastHot = '' }
250
483
  if (warmPoller) { clearInterval(warmPoller); warmPoller = null; lastWarm = '' }
@@ -257,9 +490,7 @@ function stopSourcesIfIdle(): void {
257
490
  // chain re-anchors with no client-side repair logic.
258
491
  export function boardStream(c: Context) {
259
492
  const delta = c.req.query('mode') === 'delta'
260
- ensureWatcher()
261
- ensureRefsWatcher()
262
- ensureWorktreeRegistry()
493
+ ensureBoardFileWatchers()
263
494
  return streamSSE(c, async (stream) => {
264
495
  let aborted = false
265
496
  const send: DeltaSend = (frame) => { void stream.writeSSE(frame).catch(() => {}) }