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
@@ -1,20 +1,114 @@
1
- import { dirname } from 'node:path'
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { dirname, join, relative } from 'node:path'
2
4
  import { git, gitA, repoRoot, driftIndex, historyIndex, type ReviewDiffFile } from '../../spec-cli/src/git.js'
3
5
  import { loadSpecs } from '../../spec-cli/src/specs.js'
4
6
  import { mainBranch } from '../../spec-cli/src/layout.js'
5
7
  import { reviewPayload } from '../../spec-cli/src/sessions.js'
6
- import { evalTimeline, evalContext, readBlobByHash, type EvalEntry, type EvalTimeline } from './evaltab.js'
8
+ import { loadEvalRemarkTracks } from '../../spec-cli/src/issues.js'
9
+ import { evalTimeline, evalContext, readBlobByHash, type EvalEntry, type EvalTimeline, type ScenarioInfo } from './evaltab.js'
7
10
  import { isUiPath } from './cli.js'
11
+ import { parseScenarios, scenarioHash, type Scenario } from './scenarios.js'
8
12
 
9
13
  // ---- the model ----
10
14
 
11
15
  type ScoreState = 'pass' | 'fail' | 'stalePass' | 'staleFail' | 'empty' | null
12
16
 
17
+ export type ScenarioImpactReason = 'code' | 'contract' | 'measurement'
18
+ export type SessionScenarioInfo = ScenarioInfo & { impact: ScenarioImpactReason[] }
19
+
20
+ // The ONE session-scope predicate. Declared scenarios come from the current worktree; impact is orthogonal
21
+ // to freshness and is derived only from the scenario's own code axis, its semantic contract at merge-base,
22
+ // or a reading this session owns. Consumers receive the selected set and never repeat these tests.
23
+ export function selectImpactedScenarios(
24
+ current: Scenario[],
25
+ base: Scenario[],
26
+ nodeCode: string[],
27
+ changedPaths: ReadonlySet<string>,
28
+ evalFileChanged: boolean,
29
+ measuredBySession: ReadonlySet<string>,
30
+ ): { scenario: Scenario; impact: ScenarioImpactReason[] }[] {
31
+ const baseByName = new Map(base.map((scenario) => [scenario.name, scenario]))
32
+ return current.flatMap((scenario) => {
33
+ const impact: ScenarioImpactReason[] = []
34
+ const codeAxis = scenario.code?.length ? scenario.code : nodeCode
35
+ if ([...changedPaths].some((path) => codeClaims(codeAxis, path))) impact.push('code')
36
+ const prior = baseByName.get(scenario.name)
37
+ if (evalFileChanged && (!prior || scenarioHash(prior) !== scenarioHash(scenario))) impact.push('contract')
38
+ if (measuredBySession.has(scenario.name)) impact.push('measurement')
39
+ return impact.length ? [{ scenario, impact }] : []
40
+ })
41
+ }
42
+
43
+ export function unknownCoveragePaths(
44
+ nodeCode: string[],
45
+ changedPaths: ReadonlySet<string>,
46
+ ): string[] {
47
+ return [...changedPaths].filter((path) => (
48
+ isUiPath(path)
49
+ && codeClaims(nodeCode, path)
50
+ ))
51
+ }
52
+
53
+ export function sessionEvalNodeCandidate(
54
+ current: Scenario[],
55
+ nodeCode: string[],
56
+ evalPath: string,
57
+ sidecarPath: string,
58
+ changedPaths: ReadonlySet<string>,
59
+ dirtyPaths: ReadonlySet<string>,
60
+ ): boolean {
61
+ if (changedPaths.has(evalPath) || changedPaths.has(sidecarPath) || dirtyPaths.has(sidecarPath)) return true
62
+ return current.some((scenario) => {
63
+ const codeAxis = scenario.code?.length ? scenario.code : nodeCode
64
+ return [...changedPaths].some((path) => codeClaims(codeAxis, path))
65
+ })
66
+ }
67
+
68
+ type SessionEvalReading = EvalEntry & { inSession: boolean }
69
+
70
+ export function scopeSessionScenarioRows(
71
+ current: Scenario[],
72
+ base: Scenario[],
73
+ scenarioInfo: ScenarioInfo[],
74
+ nodeCode: string[],
75
+ changedPaths: ReadonlySet<string>,
76
+ evalFileChanged: boolean,
77
+ evals: SessionEvalReading[],
78
+ ): { scenarios: SessionScenarioInfo[]; evals: SessionEvalReading[] } {
79
+ const measured = new Set(evals.filter((reading) => reading.inSession).map((reading) => reading.scenario))
80
+ const selected = selectImpactedScenarios(current, base, nodeCode, changedPaths, evalFileChanged, measured)
81
+ const infoByName = new Map(scenarioInfo.map((scenario) => [scenario.name, scenario]))
82
+ const scenarios: SessionScenarioInfo[] = selected.map(({ scenario, impact }) => ({
83
+ ...(infoByName.get(scenario.name) ?? {
84
+ name: scenario.name, expected: scenario.expected,
85
+ ...(scenario.tags?.length ? { tags: scenario.tags } : {}),
86
+ ...(scenario.test ? { test: scenario.test } : {}),
87
+ ...(scenario.code?.length ? { code: scenario.code } : {}),
88
+ }),
89
+ impact,
90
+ }))
91
+ const names = new Set(scenarios.map((scenario) => scenario.name))
92
+ return { scenarios, evals: evals.filter((reading) => names.has(reading.scenario)) }
93
+ }
94
+
95
+ export function completeExportNodeIds(
96
+ changedNodeIds: Iterable<string>,
97
+ scopedNodeIds: Iterable<string>,
98
+ ): string[] {
99
+ return [...new Set([...changedNodeIds, ...scopedNodeIds])]
100
+ }
101
+
102
+ export function mergeBasePath(path: string, oldPaths: ReadonlyMap<string, string>): string {
103
+ return oldPaths.get(path) ?? path
104
+ }
105
+
13
106
  // one eval reading rendered for the export: the latest measurement of one scenario, with its evidence
14
107
  // resolved to inline bytes (an image data-URI, or transcript text) so the document is self-contained.
15
108
  export type ExportReading = {
16
109
  scenario: string
17
110
  expected: string
111
+ impact: ScenarioImpactReason[]
18
112
  verdict?: EvalEntry['verdict']
19
113
  fresh: boolean
20
114
  staleAxes: string[]
@@ -31,6 +125,12 @@ export type ExportReading = {
31
125
  | { kind: 'none' }
32
126
  }
33
127
 
128
+ export type ExportUnmeasured = {
129
+ scenario: string
130
+ expected: string
131
+ impact: ScenarioImpactReason[]
132
+ }
133
+
34
134
  // patch ''/old·new null = nothing to show (added → no old, deleted → no new), past the enrichment cap (omitted), or too large (truncated)
35
135
  export type ExportFile = ReviewDiffFile & {
36
136
  patch: string
@@ -52,8 +152,10 @@ export type ExportNode = {
52
152
  deletions: number
53
153
  hasEvalFile: boolean
54
154
  uncoveredFrontend: boolean
155
+ affectedScenarios: number
55
156
  score: ScoreState
56
157
  readings: ExportReading[]
158
+ unmeasured: ExportUnmeasured[]
57
159
  }
58
160
 
59
161
  export type ExportGate = { label: string; ok: boolean; detail: string }
@@ -76,19 +178,34 @@ export type ExportModel = {
76
178
  export async function buildExportModel(id: string): Promise<ExportModel | null> {
77
179
  const payload = await reviewPayload(id)
78
180
  if (!payload) return null
79
- const specs = await loadSpecs()
80
- const specById = new Map(specs.map((s) => [s.id, s]))
81
- // root the eval context at the SESSION's worktree (its branch's readings/freshness), not the backend checkout which would show main's; specs stay backend-shared (paths/titles/hues), only readings + drift are per-worktree
181
+ // root EVERYTHING at the SESSION's worktree — readings, freshness, AND the spec tree itself. The
182
+ // worktree's .spec is the branch's pending proposal ([[source-of-truth]]): a node the branch ADDED
183
+ // exists only there, so a trunk-rooted loadSpecs would silently drop it from the model (the 0fca
184
+ // family's node-existence layer). No worktree → the backend checkout, unchanged.
82
185
  const wtPath = worktreePathForBranch(payload.branch)
83
186
  const ctxRoot = wtPath ?? repoRoot()
187
+ const specs = await loadSpecs(ctxRoot)
188
+ const specById = new Map(specs.map((s) => [s.id, s]))
84
189
  const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
85
190
  const ctx = await evalContext(ctxRoot, specs, didx, hidx)
86
191
 
192
+ const changedPaths = new Set(payload.diff.map((file) => file.path))
193
+ const oldPaths = new Map(payload.diff.flatMap((file) => file.oldPath ? [[file.path, file.oldPath] as const] : []))
194
+
87
195
  // enrich each changed file with its unified diff + full before/after content (derived from the session
88
196
  // worktree at the merge-base ↔ HEAD), so the proof can drill summary → diff → whole-file comparison with no
89
197
  // extra fetch. Capped at MAX_ENRICHED_FILES so a huge changeset can't bloat the page; the rest keep their
90
198
  // row but say so (omitted), never silently blank.
91
- const base = wtPath ? (await gitA(['-C', wtPath, 'merge-base', mainBranch(), 'HEAD'])).trim() : ''
199
+ const [base, shaRows, dirtyState] = wtPath ? await Promise.all([
200
+ gitA(['-C', wtPath, 'merge-base', mainBranch(), 'HEAD']).then((out) => out.trim()),
201
+ gitA(['-C', wtPath, 'rev-list', `${mainBranch()}..HEAD`]),
202
+ worktreeDirtyState(wtPath),
203
+ ]) : ['', '', { paths: new Set<string>(), oldPaths: new Map<string, string>() }] as const
204
+ const dirtyPaths = dirtyState.paths
205
+ for (const path of dirtyPaths) changedPaths.add(path)
206
+ for (const [path, oldPath] of dirtyState.oldPaths) if (!oldPaths.has(path)) oldPaths.set(path, oldPath)
207
+ const shas = new Set(shaRows.split('\n').filter(Boolean))
208
+ const scopedNodes = await sessionScopeNodes(id, ctx, changedPaths, dirtyPaths, oldPaths, base, shas)
92
209
  const enriched = new Map<string, ExportFile>()
93
210
  let budget = MAX_ENRICHED_FILES
94
211
  for (const f of payload.diff) {
@@ -105,37 +222,46 @@ export async function buildExportModel(id: string): Promise<ExportModel | null>
105
222
  if (nid) { const arr = byNode.get(nid) ?? []; arr.push(pf); byNode.set(nid, arr) }
106
223
  else otherFiles.push(pf)
107
224
  }
108
- // the session's primary node always appears, even if it has no file in the diff yet.
109
- if (payload.node && specById.has(payload.node) && !byNode.has(payload.node)) byNode.set(payload.node, [])
110
-
111
225
  const nodes: ExportNode[] = []
112
226
  let passed = 0, total = 0, fresh = 0
113
- for (const [nid, files] of byNode) {
114
- const spec = specById.get(nid)
115
- const tl = await evalTimeline(nid, ctx)
116
- const latest = declaredLatest(tl)
117
- const readings = await Promise.all(latest.map(toExportReading))
118
- for (const r of latest) {
119
- total++
227
+ const scopedById = new Map(scopedNodes.map((node) => [node.id, node]))
228
+ const evalById = new Map(ctx.ynodes.map((node) => [node.id, node]))
229
+ const nodeIds = completeExportNodeIds(byNode.keys(), scopedById.keys())
230
+ for (const id of nodeIds) {
231
+ const scoped = scopedById.get(id)
232
+ const spec = specById.get(id)!
233
+ const files = byNode.get(id) ?? []
234
+ const projection = scoped ? scopedScenarioReadings(scoped.scenarios, scoped.evals) : { latest: [], unmeasured: [] }
235
+ const impactByName = new Map(scoped?.scenarios.map((scenario) => [scenario.name, scenario.impact]) ?? [])
236
+ const readings = await Promise.all(projection.latest.map((reading) => toExportReading(reading, impactByName.get(reading.scenario) ?? [])))
237
+ const unmeasured = projection.unmeasured.map((scenario) => ({
238
+ scenario: scenario.name,
239
+ expected: scenario.expected,
240
+ impact: scenario.impact,
241
+ }))
242
+ total += scoped?.scenarios.length ?? 0
243
+ for (const r of projection.latest) {
120
244
  if (r.fresh) fresh++
121
245
  if (r.fresh && r.verdict?.status === 'pass') passed++
122
246
  }
123
247
  nodes.push({
124
- id: nid,
125
- title: spec?.title ?? nid,
126
- hue: spec?.hue ?? 210,
127
- desc: spec?.desc ?? '',
248
+ id,
249
+ title: scoped?.title ?? spec.title,
250
+ hue: scoped?.hue ?? spec.hue,
251
+ desc: scoped?.desc ?? spec.desc,
128
252
  files,
129
253
  additions: files.reduce((a, f) => a + f.additions, 0),
130
254
  deletions: files.reduce((a, f) => a + f.deletions, 0),
131
- hasEvalFile: tl.hasEvalFile,
132
- uncoveredFrontend: !tl.hasEvalFile && (spec?.code ?? []).some(isUiPath),
133
- score: nodeScore(tl.hasEvalFile, latest),
255
+ hasEvalFile: scoped?.hasEvalFile ?? evalById.has(id),
256
+ uncoveredFrontend: scoped?.uncoveredFrontend ?? false,
257
+ affectedScenarios: scoped?.scenarios.length ?? 0,
258
+ score: nodeScore(scoped?.hasEvalFile ?? evalById.has(id), projection.latest, scoped?.scenarios.length ?? 0),
134
259
  readings,
260
+ unmeasured,
135
261
  })
136
262
  }
137
- // measured nodes first, then by amount changed — the strongest evidence and the biggest change lead.
138
- nodes.sort((a, b) => (b.readings.length - a.readings.length) || ((b.additions + b.deletions) - (a.additions + a.deletions)))
263
+ // affected scenarios first, then by amount changed — review work leads while every changed file remains.
264
+ nodes.sort((a, b) => (b.affectedScenarios - a.affectedScenarios) || ((b.additions + b.deletions) - (a.additions + a.deletions)))
139
265
 
140
266
  // the headline is DERIVED — the node the session is on, else its branch, else the id. No agent claim.
141
267
  const primary = payload.node && specById.has(payload.node) ? specById.get(payload.node)!.title : null
@@ -172,9 +298,10 @@ const MAX_ENRICHED_FILES = 60
172
298
  const MAX_FILE_BYTES = 200_000
173
299
  async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Promise<ExportFile> {
174
300
  const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
301
+ const paths = f.oldPath ? [f.oldPath, f.path] : [f.path]
175
302
  const [patchRaw, oldRaw, newRaw] = await Promise.all([
176
- run(['diff', '-M', `${base}..HEAD`, '--', f.path]),
177
- f.status === 'added' ? Promise.resolve('') : run(['show', `${base}:${f.path}`]),
303
+ run(['diff', '-M', `${base}..HEAD`, '--', ...paths]),
304
+ f.status === 'added' ? Promise.resolve('') : run(['show', `${base}:${f.oldPath ?? f.path}`]),
178
305
  f.status === 'deleted' ? Promise.resolve('') : run(['show', `HEAD:${f.path}`]),
179
306
  ])
180
307
  const cap = (s: string) => (s.length > MAX_FILE_BYTES ? { text: s.slice(0, MAX_FILE_BYTES), cut: true } : { text: s, cut: false })
@@ -192,9 +319,9 @@ async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Prom
192
319
  // resolve a reading's evidence to inline bytes so the proof is a self-contained file: an image → a base64
193
320
  // data-URI; a transcript → its text; the cache miss / no-capture states pass through. the eval cache owns the bytes
194
321
  // (the content-addressed cache) — the proof only asks for them by hash.
195
- async function toExportReading(r: EvalEntry): Promise<ExportReading> {
322
+ async function toExportReading(r: EvalEntry, impact: ScenarioImpactReason[]): Promise<ExportReading> {
196
323
  const base = {
197
- scenario: r.scenario, expected: r.expected, verdict: r.verdict, fresh: r.fresh,
324
+ scenario: r.scenario, expected: r.expected, impact, verdict: r.verdict, fresh: r.fresh,
198
325
  staleAxes: r.staleAxes, score: readingScore(r), evaluator: r.evaluator, ts: r.ts,
199
326
  }
200
327
  if (r.blobState !== 'present' || !r.blob) return { ...base, evidence: { kind: r.blobState === 'miss' ? 'miss' : 'none' } }
@@ -221,6 +348,21 @@ function latestPerScenario(readings: EvalEntry[]): EvalEntry[] {
221
348
  return out
222
349
  }
223
350
 
351
+ export function scopedScenarioReadings(
352
+ scenarios: SessionScenarioInfo[],
353
+ readings: EvalEntry[],
354
+ ): { latest: EvalEntry[]; unmeasured: SessionScenarioInfo[] } {
355
+ const latestByName = new Map(latestPerScenario(readings).map((reading) => [reading.scenario, reading]))
356
+ const latest: EvalEntry[] = []
357
+ const unmeasured: SessionScenarioInfo[] = []
358
+ for (const scenario of scenarios) {
359
+ const reading = latestByName.get(scenario.name)
360
+ if (reading) latest.push(reading)
361
+ else unmeasured.push(scenario)
362
+ }
363
+ return { latest, unmeasured }
364
+ }
365
+
224
366
  // the DECLARED scenarios' latest reading — the SAME declared-bounded computation every other eval face reads
225
367
  // (score.jsx's scenarioStates for the node badge and the eval tab). A reading whose scenario is no longer in
226
368
  // eval.md is residual: the append-only sidecar still carries it, but it is not current loss, so it must not
@@ -246,13 +388,13 @@ function readingScore(r: EvalEntry): ScoreState {
246
388
 
247
389
  // worst-first aggregate over the latest reading per scenario: any fresh fail → fail; else any stale → grey
248
390
  // (✗ if any stale last-failed, else ✓); else any unscored scenario → empty; else every scenario fresh-passes.
249
- export function nodeScore(hasEvalFile: boolean, latest: EvalEntry[]): ScoreState {
391
+ export function nodeScore(hasEvalFile: boolean, latest: EvalEntry[], affectedScenarios = latest.length): ScoreState {
250
392
  if (!hasEvalFile) return null
251
- if (!latest.length) return 'empty'
393
+ if (!affectedScenarios || !latest.length) return 'empty'
252
394
  if (latest.some((r) => r.fresh && verdictMark(r) === 'cross')) return 'fail'
253
395
  const stale = latest.filter((r) => !r.fresh && verdictMark(r))
254
396
  if (stale.length) return stale.some((r) => verdictMark(r) === 'cross') ? 'staleFail' : 'stalePass'
255
- if (latest.some((r) => !verdictMark(r))) return 'empty'
397
+ if (latest.length < affectedScenarios || latest.some((r) => !verdictMark(r))) return 'empty'
256
398
  return 'pass'
257
399
  }
258
400
 
@@ -304,6 +446,38 @@ function worktreePathForBranch(branch: string | null): string | null {
304
446
  return null
305
447
  }
306
448
 
449
+ export function parsePorcelainPaths(out: string): Set<string> {
450
+ const paths = new Set<string>()
451
+ const records = out.split('\0')
452
+ for (let i = 0; i < records.length; i++) {
453
+ const record = records[i]
454
+ if (!record) continue
455
+ const status = record.slice(0, 2)
456
+ const path = record.slice(3)
457
+ if (path) paths.add(path)
458
+ if ((status.includes('R') || status.includes('C')) && records[i + 1]) paths.add(records[++i])
459
+ }
460
+ return paths
461
+ }
462
+
463
+ export function parsePorcelainRenames(out: string): Map<string, string> {
464
+ const renames = new Map<string, string>()
465
+ const records = out.split('\0')
466
+ for (let i = 0; i < records.length; i++) {
467
+ const record = records[i]
468
+ if (!record) continue
469
+ const status = record.slice(0, 2)
470
+ const path = record.slice(3)
471
+ if ((status.includes('R') || status.includes('C')) && records[i + 1]) renames.set(path, records[++i])
472
+ }
473
+ return renames
474
+ }
475
+
476
+ async function worktreeDirtyState(wtPath: string): Promise<{ paths: Set<string>; oldPaths: Map<string, string> }> {
477
+ const out = await gitA(['-C', wtPath, '-c', 'core.quotePath=false', 'status', '--porcelain=v1', '-z', '--untracked-files=all'])
478
+ return { paths: parsePorcelainPaths(out), oldPaths: parsePorcelainRenames(out) }
479
+ }
480
+
307
481
  // ---- the renderer ----
308
482
 
309
483
  // escape interpolated text for HTML (the proof inlines derived data — file paths, scenarios, expected — so
@@ -339,6 +513,7 @@ function renderReading(r: ExportReading): string {
339
513
  <div class="rhead">
340
514
  ${scoreBadge(r.score, r.fresh ? undefined : `stale: ${r.staleAxes.join(', ')}`)}
341
515
  <span class="scenario">${esc(r.scenario)}</span>
516
+ <span class="impact">${esc(r.impact.join(' + '))}</span>
342
517
  ${verdictBadge(r.verdict)}
343
518
  ${stale}
344
519
  <span class="rmeta">${r.evaluator ? `${esc(r.evaluator)} · ` : ''}${esc(r.ts)}</span>
@@ -349,6 +524,18 @@ function renderReading(r: ExportReading): string {
349
524
  </div>`
350
525
  }
351
526
 
527
+ function renderUnmeasured(scenario: ExportUnmeasured): string {
528
+ return `<div class="eval-entry unmeasured">
529
+ <div class="rhead">
530
+ ${scoreBadge('empty', 'unmeasured')}
531
+ <span class="scenario">${esc(scenario.scenario)}</span>
532
+ <span class="impact">${esc(scenario.impact.join(' + '))}</span>
533
+ <span class="verdict legacy">unmeasured</span>
534
+ </div>
535
+ ${scenario.expected ? `<div class="expected"><b>expected</b> ${esc(scenario.expected)}</div>` : ''}
536
+ </div>`
537
+ }
538
+
352
539
  // each diff line is its own block so a long diff scrolls inside its box
353
540
  function renderPatch(patch: string): string {
354
541
  return patch.split('\n').map((ln) => {
@@ -376,9 +563,9 @@ function renderNode(n: ExportNode): string {
376
563
  const stat = `<span class="diffstat"><span class="add">+${n.additions}</span> <span class="del">−${n.deletions}</span> · ${n.files.length} file(s)</span>`
377
564
  const fileList = n.files.length ? `<div class="files">${n.files.map(renderFile).join('')}</div>` : ''
378
565
  let proof: string
379
- if (n.readings.length) proof = n.readings.map(renderReading).join('')
566
+ if (n.affectedScenarios) proof = [...n.unmeasured.map(renderUnmeasured), ...n.readings.map(renderReading)].join('')
380
567
  else if (n.uncoveredFrontend) proof = `<div class="blindspot">⚠ a frontend node with no eval.md — its loss is unmeasured. Give it a scenario so this change can be verified.</div>`
381
- else if (n.hasEvalFile) proof = `<div class="blindspot">declares scenarios but has no eval yet measure with <code>spex eval add ${esc(n.id)}</code></div>`
568
+ else if (n.hasEvalFile) proof = `<div class="noev">no declared scenario is affected by this worktree</div>`
382
569
  else proof = `<div class="noev">no measurable surface (no eval.md)</div>`
383
570
  return `<article class="node" style="--hue:${n.hue}">
384
571
  <div class="nhead">
@@ -398,7 +585,7 @@ export function renderExportHtml(m: ExportModel): string {
398
585
  const idShort = m.id.slice(0, 8)
399
586
  const ribbon = [
400
587
  ...m.gates.map((g) => `<span class="chip ${g.ok ? 'ok' : 'bad'}" title="${esc(g.detail)}">${g.ok ? '✓' : '✗'} ${esc(g.label)}</span>`),
401
- m.score.total ? `<span class="chip ${m.score.passed === m.score.total ? 'ok' : 'warn'}" title="scenarios fresh-passing (of those measured); ${m.score.fresh}/${m.score.total} fresh">★ ${m.score.passed}/${m.score.total} passing</span>` : `<span class="chip warn" title="no evals filed on the changed nodes">★ no measured loss</span>`,
588
+ m.score.total ? `<span class="chip ${m.score.passed === m.score.total ? 'ok' : 'warn'}" title="affected scenarios fresh-passing; ${m.score.fresh}/${m.score.total} have a fresh reading">★ ${m.score.passed}/${m.score.total} passing</span>` : `<span class="chip warn" title="no scenario is affected by this worktree">★ no affected scenarios</span>`,
402
589
  ].join('')
403
590
  const gates = m.gates.map((g) => `<li class="${g.ok ? 'ok' : 'bad'}"><span class="gmark">${g.ok ? '✓' : '✗'}</span><span class="glabel">${esc(g.label)}</span><span class="gdetail">${esc(g.detail)}</span></li>`).join('')
404
591
  const otherBlock = m.otherFiles.length
@@ -489,6 +676,7 @@ h2{margin:42px 0 16px;font-size:14px;letter-spacing:.14em;text-transform:upperca
489
676
  .eval-entry{margin-top:14px;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2)}
490
677
  .rhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
491
678
  .scenario{font-weight:600;color:#dde7f1}
679
+ .impact{font:600 10px/1 ui-monospace,monospace;color:var(--accent)}
492
680
  .rmeta{margin-left:auto;font:11px/1 ui-monospace,monospace;color:var(--dim)}
493
681
  .verdict{font:600 11px/1 ui-monospace,monospace;padding:3px 8px;border-radius:6px;border:1px solid var(--line)}
494
682
  .verdict.pass{color:var(--green);border-color:#1c3a26}.verdict.fail{color:var(--red);border-color:#3a1f1f}
@@ -529,7 +717,12 @@ export type SessionEvalNode = {
529
717
  desc: string
530
718
  hasEvalFile: boolean
531
719
  uncoveredFrontend: boolean
532
- scenarios: { name: string; expected: string; tags?: string[] }[]
720
+ // Changed frontend code that no declared scenario covers. This is node-level UNKNOWN coverage, never a
721
+ // synthetic scenario: it stays outside scenario totals and filters while remaining visible to consumers.
722
+ unknownCoverage: string[]
723
+ // Already scoped by selectImpactedScenarios. Consumers must not infer impact from node membership,
724
+ // freshness, or the full eval.md again.
725
+ scenarios: SessionScenarioInfo[]
533
726
  // each reading carries the trunk eval-concern thread for its (node, scenario) ([[remark-teeth]] / directive
534
727
  // 3): the server-side join (attached by evalTimeline as `EvalEntry.thread`), so the session tab's event
535
728
  // detail reads the comment/remark track directly instead of re-matching a concern key client-side. Absent
@@ -545,45 +738,168 @@ export type SessionEvals = {
545
738
  dirtyNonRuntime: number
546
739
  gates: ExportGate[]
547
740
  nodes: SessionEvalNode[]
741
+ summary: SessionEvalSummary
742
+ evalRevision: SessionEvalRevision
548
743
  }
549
744
 
550
- export async function buildSessionEvals(id: string): Promise<SessionEvals | null> {
551
- const payload = await reviewPayload(id)
552
- if (!payload) return null
553
- const specs = await loadSpecs()
554
- const specById = new Map(specs.map((s) => [s.id, s]))
555
- const wtPath = worktreePathForBranch(payload.branch)
556
- const ctxRoot = wtPath ?? repoRoot()
557
- const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
558
- const ctx = await evalContext(ctxRoot, specs, didx, hidx)
559
- // this session's own commits — the membership test behind `inSession`
560
- const shas = wtPath ? new Set((await gitA(['-C', wtPath, 'rev-list', `${mainBranch()}..HEAD`])).split('\n').filter(Boolean)) : new Set<string>()
745
+ export type SessionEvalSummary = {
746
+ measured: number
747
+ total: number
748
+ pass: number
749
+ fail: number
750
+ review: number
751
+ blind: number
752
+ unknown: number
753
+ }
561
754
 
562
- const ids = new Set<string>()
563
- for (const f of payload.diff) { const nid = nodeForFile(f.path, specs, payload.node); if (nid) ids.add(nid) }
564
- if (payload.node && specById.has(payload.node)) ids.add(payload.node)
755
+ export type SessionEvalRevision = {
756
+ epoch: string
757
+ generation: number
758
+ content: string
759
+ }
760
+
761
+ export type SessionEvalProjection = {
762
+ epoch: string
763
+ generation: number
764
+ phase: 'loading' | 'updating' | 'ready' | 'error'
765
+ revision?: string
766
+ value?: SessionEvalSummary
767
+ lastKnown?: { generation: number; revision: string; value: SessionEvalSummary }
768
+ }
565
769
 
770
+ export class SessionEvalUnavailableError extends Error {
771
+ override name = 'SessionEvalUnavailableError'
772
+ }
773
+
774
+ // The one count projection over the already-affected rows. This is the backend source both the graph
775
+ // glance and the demand full model carry; consumers never repeat impact selection or score classification.
776
+ export function sessionEvalSummary(nodes: SessionEvalNode[]): SessionEvalSummary {
777
+ let total = 0, measured = 0, pass = 0, fail = 0, unknown = 0
778
+ for (const node of nodes) {
779
+ total += node.scenarios.length
780
+ unknown += node.unknownCoverage.length
781
+ const latest = new Map(latestPerScenario(node.evals).map((reading) => [reading.scenario, reading]))
782
+ for (const scenario of node.scenarios) {
783
+ const reading = latest.get(scenario.name)
784
+ if (!reading) continue
785
+ measured++
786
+ if (reading.fresh && reading.verdict?.status === 'pass') pass++
787
+ else if (reading.fresh && reading.verdict?.status === 'fail') fail++
788
+ }
789
+ }
790
+ return {
791
+ measured,
792
+ total,
793
+ pass,
794
+ fail,
795
+ review: measured - pass - fail,
796
+ blind: Math.max(0, total - measured),
797
+ unknown,
798
+ }
799
+ }
800
+
801
+ async function sessionScopeNodes(
802
+ id: string,
803
+ ctx: Awaited<ReturnType<typeof evalContext>>,
804
+ changedPaths: ReadonlySet<string>,
805
+ dirtyPaths: ReadonlySet<string>,
806
+ oldPaths: ReadonlyMap<string, string>,
807
+ base: string,
808
+ shas: ReadonlySet<string>,
809
+ latestOnly = false,
810
+ ): Promise<SessionEvalNode[]> {
811
+ const evalById = new Map(ctx.ynodes.map((node) => [node.id, node]))
566
812
  const nodes: SessionEvalNode[] = []
567
- for (const nid of ids) {
568
- const spec = specById.get(nid)
569
- const tl = await evalTimeline(nid, ctx)
813
+
814
+ for (const spec of ctx.specs) {
815
+ const evalNode = evalById.get(spec.id)
816
+ const current = evalNode?.scenarios ?? []
817
+ // Unknown means the node has no measurement contract at all. A node that has eval.md is known even
818
+ // when individual scenarios narrow their code axes; partial scenario ownership is not a synthetic gap.
819
+ const unknownCoverage = evalNode ? [] : unknownCoveragePaths(spec.code, changedPaths)
820
+
821
+ if (!evalNode) {
822
+ if (unknownCoverage.length) {
823
+ nodes.push({
824
+ id: spec.id, title: spec.title, hue: spec.hue, desc: spec.desc,
825
+ hasEvalFile: false, uncoveredFrontend: true, unknownCoverage,
826
+ scenarios: [], evals: [],
827
+ })
828
+ }
829
+ continue
830
+ }
831
+
832
+ const evalFileChanged = changedPaths.has(evalNode.evalPath)
833
+ const sidecarPath = relative(ctx.root, evalNode.sidecarPath)
834
+ if (!sessionEvalNodeCandidate(current, spec.code, evalNode.evalPath, sidecarPath, changedPaths, dirtyPaths)) continue
835
+
836
+ const timeline = await evalTimeline(spec.id, ctx)
837
+ // A reading is this session's own when the session filed it OR its anchor is a branch commit. This is
838
+ // the same marker the UI and CLI render; measurement impact consumes that marker instead of inventing
839
+ // another attribution rule.
840
+ const evals = timeline.readings.map((reading) => ({
841
+ ...reading,
842
+ inSession: reading.by === id || shas.has(reading.codeSha),
843
+ }))
844
+ const baseEvalPath = mergeBasePath(evalNode.evalPath, oldPaths)
845
+ const baseHasEval = evalFileChanged && base
846
+ ? (await gitA(['-C', ctx.root, 'ls-tree', '--name-only', base, '--', baseEvalPath])).trim() !== ''
847
+ : false
848
+ const baseScenarios = baseHasEval
849
+ ? parseScenarios(await gitA(['-C', ctx.root, 'show', `${base}:${baseEvalPath}`]))
850
+ : []
851
+ const scoped = scopeSessionScenarioRows(current, baseScenarios, timeline.scenarios, spec.code, changedPaths, evalFileChanged, evals)
852
+ if (!scoped.scenarios.length && !unknownCoverage.length) continue
853
+
570
854
  nodes.push({
571
- id: nid,
572
- title: spec?.title ?? nid,
573
- hue: spec?.hue ?? 210,
574
- desc: spec?.desc ?? '',
575
- hasEvalFile: tl.hasEvalFile,
576
- uncoveredFrontend: !tl.hasEvalFile && (spec?.code ?? []).some(isUiPath),
577
- scenarios: tl.scenarios,
578
- // the per-scenario trunk thread rides each reading as `EvalEntry.thread` (evalTimeline's overlay), so
579
- // the event detail has the comment/remark track inline — no extra join here. A reading is the
580
- // session's own when it filed it (`by`) OR when its codeSha is a branch commit — filing alone counts,
581
- // else a session that measured without committing code reads as if it did nothing.
582
- evals: tl.readings.map((r) => ({ ...r, inSession: r.by === id || shas.has(r.codeSha) })),
855
+ id: spec.id, title: spec.title, hue: spec.hue, desc: spec.desc,
856
+ hasEvalFile: timeline.hasEvalFile,
857
+ uncoveredFrontend: !timeline.hasEvalFile && unknownCoverage.length > 0,
858
+ unknownCoverage,
859
+ scenarios: scoped.scenarios,
860
+ // Preserve the whole A/B history for selected scenarios. Fresh, stale, legacy and missing remain
861
+ // honest downstream states; impact selection never removes a row because its reading is stale.
862
+ evals: latestOnly ? latestPerScenario(scoped.evals) as (EvalEntry & { inSession: boolean })[] : scoped.evals,
583
863
  })
584
864
  }
865
+
866
+ return nodes
867
+ }
868
+
869
+ type ReviewPayloadValue = NonNullable<Awaited<ReturnType<typeof reviewPayload>>>
870
+ type SessionEvalModel = Omit<SessionEvals, 'summary' | 'evalRevision'>
871
+
872
+ async function buildSessionEvalModel(
873
+ id: string,
874
+ payload: ReviewPayloadValue,
875
+ wtPath: string | null,
876
+ latestOnly: boolean,
877
+ ): Promise<SessionEvalModel> {
878
+ // spec tree from the session worktree, same root as readings/indexes — a branch-NEW node must exist
879
+ // in this model or the Eval tab/deep link can never reach its readings (see buildExportModel above).
880
+ const ctxRoot = wtPath ?? repoRoot()
881
+ const specs = await loadSpecs(ctxRoot)
882
+ const specById = new Map(specs.map((s) => [s.id, s]))
883
+ const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
884
+ const ctx = await evalContext(ctxRoot, specs, didx, hidx)
885
+ const changedPaths = new Set(payload.diff.map((file) => file.path))
886
+ const oldPaths = new Map(payload.diff.flatMap((file) => file.oldPath ? [[file.path, file.oldPath] as const] : []))
887
+ const [base, shaRows, dirtyState] = wtPath ? await Promise.all([
888
+ gitA(['-C', wtPath, 'merge-base', mainBranch(), 'HEAD']).then((out) => out.trim()),
889
+ gitA(['-C', wtPath, 'rev-list', `${mainBranch()}..HEAD`]),
890
+ worktreeDirtyState(wtPath),
891
+ ]) : ['', '', { paths: new Set<string>(), oldPaths: new Map<string, string>() }] as const
892
+ const dirtyPaths = dirtyState.paths
893
+ // A session evaluation is the proposal as it exists now, not only its committed slice. A dirty source,
894
+ // staged rename, draft eval.md, or uncommitted sidecar therefore enters the SAME affected selector.
895
+ for (const path of dirtyPaths) changedPaths.add(path)
896
+ for (const [path, oldPath] of dirtyState.oldPaths) if (!oldPaths.has(path)) oldPaths.set(path, oldPath)
897
+ // this session's own commits — the membership test behind `inSession` and measurement impact
898
+ const shas = new Set(shaRows.split('\n').filter(Boolean))
899
+ const nodes = await sessionScopeNodes(id, ctx, changedPaths, dirtyPaths, oldPaths, base, shas, latestOnly)
585
900
  // nodes with in-session measurements lead, then the most-measured — the session's own evidence first.
586
- nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length) || (b.evals.length - a.evals.length))
901
+ nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length)
902
+ || (b.scenarios.length - a.scenarios.length) || (b.unknownCoverage.length - a.unknownCoverage.length))
587
903
 
588
904
  const primary = payload.node && specById.has(payload.node) ? specById.get(payload.node)!.title : null
589
905
  return {
@@ -597,3 +913,347 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
597
913
  nodes,
598
914
  }
599
915
  }
916
+
917
+ function untrackedPaths(status: string): string[] {
918
+ const out: string[] = []
919
+ const records = status.split('\0')
920
+ for (let i = 0; i < records.length; i++) {
921
+ const record = records[i]
922
+ if (!record) continue
923
+ const code = record.slice(0, 2)
924
+ const path = record.slice(3)
925
+ if (code === '??' && path) out.push(path)
926
+ if ((code.includes('R') || code.includes('C')) && records[i + 1]) i++
927
+ }
928
+ return out.sort()
929
+ }
930
+
931
+ // One content fingerprint over every axis that can alter the scoped summary. Committed declarations,
932
+ // sidecars and governed code are covered by HEAD; the moving comparison base by the base ref;
933
+ // index/worktree/rename content by the HEAD-relative binary diff; untracked bytes are folded explicitly.
934
+ // Remark tracks are folded directly as well as through main: the disposable plain-file issue store used by
935
+ // controlled runs has no ref move, but it is still the same freshness input and must obey the same fence.
936
+ export async function sessionEvalContentRevision(wtPath: string): Promise<string> {
937
+ const base = mainBranch()
938
+ const [mainSha, headSha, mergeBase, status, dirtyDiff] = await Promise.all([
939
+ gitA(['-C', wtPath, 'rev-parse', base]).then((out) => out.trim()),
940
+ gitA(['-C', wtPath, 'rev-parse', 'HEAD']).then((out) => out.trim()),
941
+ gitA(['-C', wtPath, 'merge-base', base, 'HEAD']).then((out) => out.trim()),
942
+ gitA(['-C', wtPath, '-c', 'core.quotePath=false', 'status', '--porcelain=v1', '-z', '--untracked-files=all']),
943
+ gitA(['-C', wtPath, 'diff', 'HEAD', '--binary', '--no-ext-diff', '--']),
944
+ ])
945
+ const untracked = await Promise.all(untrackedPaths(status).map(async (path) => {
946
+ try {
947
+ const bytes = await readFile(join(wtPath, path))
948
+ return `${path}\0${createHash('sha256').update(bytes).digest('hex')}`
949
+ } catch {
950
+ return `${path}\0<gone>`
951
+ }
952
+ }))
953
+ const remarks = [...loadEvalRemarkTracks()]
954
+ .sort(([a], [b]) => a.localeCompare(b))
955
+ .map(([key, track]) => [key, track.thread])
956
+ return createHash('sha256')
957
+ .update([mainSha, headSha, mergeBase, status, dirtyDiff, ...untracked, JSON.stringify(remarks)].join('\0'))
958
+ .digest('hex')
959
+ }
960
+
961
+ type SummaryBuildResult =
962
+ | { kind: 'stable'; revision: string; summary: SessionEvalSummary }
963
+ | { kind: 'unstable' }
964
+ | { kind: 'missing' }
965
+
966
+ export type SessionEvalSummaryBuilder = (id: string, path: string) => Promise<SummaryBuildResult>
967
+
968
+ type ProjectionEntry = {
969
+ id: string
970
+ path: string
971
+ generation: number
972
+ phase: SessionEvalProjection['phase']
973
+ current?: { generation: number; revision: string; value: SessionEvalSummary }
974
+ scheduled: number | null
975
+ running: number | null
976
+ observerHolds: Set<string>
977
+ }
978
+
979
+ type ProjectionTarget = 'all' | { id?: string; path?: string }
980
+
981
+ // Pure generation coordinator around an injected stable builder. Snapshot construction only serializes
982
+ // entries and authorizes the newest dirty generations; the async batch runs after that snapshot has captured
983
+ // `updating(lastKnown)`, then emits one completion nudge for all stable/error results in the batch.
984
+ export class SessionEvalProjectionCache {
985
+ readonly epoch: string
986
+ private readonly entries = new Map<string, ProjectionEntry>()
987
+ private readonly observerHolds = new Map<string, ProjectionTarget>()
988
+ private readonly observerWaiters = new Set<() => void>()
989
+ private batch: Promise<void> | null = null
990
+ private notify: () => void
991
+
992
+ constructor(private readonly build: SessionEvalSummaryBuilder, notify: () => void = () => {}, epoch: string = randomUUID()) {
993
+ this.notify = notify
994
+ this.epoch = epoch
995
+ }
996
+
997
+ setNotify(notify: () => void): void { this.notify = notify }
998
+
999
+ snapshot(sessions: { id: string; path: string }[]): Map<string, SessionEvalProjection> {
1000
+ const live = new Set(sessions.map((session) => session.id))
1001
+ for (const id of this.entries.keys()) if (!live.has(id)) this.entries.delete(id)
1002
+ const out = new Map<string, SessionEvalProjection>()
1003
+ for (const session of sessions) {
1004
+ let entry = this.entries.get(session.id)
1005
+ if (!entry) {
1006
+ entry = {
1007
+ id: session.id,
1008
+ path: session.path,
1009
+ generation: 0,
1010
+ phase: 'loading',
1011
+ scheduled: null,
1012
+ running: null,
1013
+ observerHolds: new Set(),
1014
+ }
1015
+ this.entries.set(session.id, entry)
1016
+ } else entry.path = session.path
1017
+ entry.observerHolds = new Set([...this.observerHolds]
1018
+ .filter(([, target]) => this.matches(entry!, target))
1019
+ .map(([observer]) => observer))
1020
+ if (entry.observerHolds.size) entry.phase = 'updating'
1021
+ if ((entry.phase === 'loading' || entry.phase === 'updating')
1022
+ && entry.observerHolds.size === 0
1023
+ && entry.running !== entry.generation && entry.scheduled !== entry.generation) entry.scheduled = entry.generation
1024
+ out.set(session.id, this.project(entry))
1025
+ }
1026
+ queueMicrotask(() => this.startBatch())
1027
+ return out
1028
+ }
1029
+
1030
+ invalidate(target: ProjectionTarget = 'all'): number {
1031
+ let changed = 0
1032
+ for (const entry of this.entries.values()) {
1033
+ if (target !== 'all' && target.id !== entry.id && target.path !== entry.path) continue
1034
+ entry.generation++
1035
+ entry.phase = 'updating'
1036
+ entry.scheduled = null
1037
+ changed++
1038
+ }
1039
+ return changed
1040
+ }
1041
+
1042
+ holdObserver(observer: string, target: ProjectionTarget = 'all'): boolean {
1043
+ if (this.observerHolds.has(observer)) return false
1044
+ this.observerHolds.set(observer, target)
1045
+ for (const entry of this.entries.values()) {
1046
+ if (!this.matches(entry, target)) continue
1047
+ entry.observerHolds.add(observer)
1048
+ entry.generation++
1049
+ entry.phase = 'updating'
1050
+ entry.scheduled = null
1051
+ }
1052
+ return true
1053
+ }
1054
+
1055
+ releaseObserver(observer: string): boolean {
1056
+ if (!this.observerHolds.delete(observer)) return false
1057
+ for (const entry of this.entries.values()) {
1058
+ if (!entry.observerHolds.delete(observer)) continue
1059
+ entry.generation++
1060
+ entry.phase = 'updating'
1061
+ entry.scheduled = null
1062
+ }
1063
+ for (const check of [...this.observerWaiters]) check()
1064
+ return true
1065
+ }
1066
+
1067
+ isObserverHeld(id: string, path: string): boolean {
1068
+ const entry = this.entries.get(id)
1069
+ if (entry?.observerHolds.size) return true
1070
+ return [...this.observerHolds.values()].some((target) => this.matches({ id, path }, target))
1071
+ }
1072
+
1073
+ waitUntilObservable(id: string, path: string, timeoutMs: number): Promise<boolean> {
1074
+ if (!this.isObserverHeld(id, path)) return Promise.resolve(true)
1075
+ return new Promise((resolve) => {
1076
+ let timer: ReturnType<typeof setTimeout> | null = null
1077
+ const finish = (observable: boolean) => {
1078
+ if (timer) clearTimeout(timer)
1079
+ this.observerWaiters.delete(check)
1080
+ resolve(observable)
1081
+ }
1082
+ const check = () => {
1083
+ if (!this.isObserverHeld(id, path)) finish(true)
1084
+ }
1085
+ this.observerWaiters.add(check)
1086
+ timer = setTimeout(() => finish(false), Math.max(0, timeoutMs))
1087
+ timer.unref?.()
1088
+ check()
1089
+ })
1090
+ }
1091
+
1092
+ get(id: string): SessionEvalProjection | null {
1093
+ const entry = this.entries.get(id)
1094
+ return entry ? this.project(entry) : null
1095
+ }
1096
+
1097
+ async idle(): Promise<void> {
1098
+ await Promise.resolve()
1099
+ while (this.batch) await this.batch
1100
+ }
1101
+
1102
+ accept(id: string, generation: number, revision: string, value: SessionEvalSummary): boolean {
1103
+ const entry = this.entries.get(id)
1104
+ if (!entry || entry.generation !== generation || entry.observerHolds.size) return false
1105
+ const changed = entry.phase !== 'ready' || entry.current?.revision !== revision
1106
+ entry.current = { generation, revision, value }
1107
+ entry.phase = 'ready'
1108
+ entry.scheduled = null
1109
+ if (changed) this.notify()
1110
+ return true
1111
+ }
1112
+
1113
+ private project(entry: ProjectionEntry): SessionEvalProjection {
1114
+ const stable = entry.current
1115
+ return {
1116
+ epoch: this.epoch,
1117
+ generation: entry.generation,
1118
+ phase: entry.phase,
1119
+ ...(entry.phase === 'ready' && stable
1120
+ ? { revision: stable.revision, value: stable.value }
1121
+ : stable ? { lastKnown: stable } : {}),
1122
+ }
1123
+ }
1124
+
1125
+ private matches(entry: { id: string; path: string }, target: ProjectionTarget): boolean {
1126
+ return target === 'all' || target.id === entry.id || target.path === entry.path
1127
+ }
1128
+
1129
+ private startBatch(): void {
1130
+ if (this.batch) return
1131
+ const hasScheduled = () => [...this.entries.values()].some((entry) => entry.scheduled != null && entry.running == null)
1132
+ if (!hasScheduled()) return
1133
+ this.batch = (async () => {
1134
+ let publish = false
1135
+ while (hasScheduled()) {
1136
+ const jobs = [...this.entries.values()].filter((entry) => entry.scheduled != null && entry.running == null)
1137
+ const changed = await Promise.all(jobs.map((entry) => this.runEntry(entry)))
1138
+ publish = publish || changed.some(Boolean)
1139
+ }
1140
+ if (publish) this.notify()
1141
+ })().finally(() => {
1142
+ this.batch = null
1143
+ if (hasScheduled()) this.startBatch()
1144
+ })
1145
+ }
1146
+
1147
+ private async runEntry(entry: ProjectionEntry): Promise<boolean> {
1148
+ const generation = entry.scheduled!
1149
+ entry.scheduled = null
1150
+ entry.running = generation
1151
+ let result: SummaryBuildResult
1152
+ try { result = await this.build(entry.id, entry.path) }
1153
+ catch (error) {
1154
+ console.warn(`spec-eval: session summary build failed for ${entry.id}: ${error instanceof Error ? error.message : String(error)}`)
1155
+ result = { kind: 'missing' }
1156
+ }
1157
+ entry.running = null
1158
+ if (this.entries.get(entry.id) !== entry || entry.generation !== generation) return false
1159
+ if (result.kind === 'unstable') {
1160
+ entry.generation++
1161
+ entry.phase = 'updating'
1162
+ entry.scheduled = null
1163
+ this.notify()
1164
+ return false
1165
+ }
1166
+ if (result.kind === 'missing') {
1167
+ entry.phase = 'error'
1168
+ return true
1169
+ }
1170
+ entry.current = { generation, revision: result.revision, value: result.summary }
1171
+ entry.phase = 'ready'
1172
+ return true
1173
+ }
1174
+ }
1175
+
1176
+ async function buildSummaryAttempt(id: string, _path: string): Promise<SummaryBuildResult> {
1177
+ const payload = await reviewPayload(id)
1178
+ if (!payload) return { kind: 'missing' }
1179
+ const wtPath = worktreePathForBranch(payload.branch)
1180
+ const ctxPath = wtPath ?? repoRoot()
1181
+ const before = await sessionEvalContentRevision(ctxPath)
1182
+ const cacheKey = `${id}\0${before}`
1183
+ const cached = summaryByContent.get(cacheKey)
1184
+ if (cached) {
1185
+ const after = await sessionEvalContentRevision(ctxPath)
1186
+ return before === after
1187
+ ? { kind: 'stable', revision: after, summary: cached }
1188
+ : { kind: 'unstable' }
1189
+ }
1190
+ const model = await buildSessionEvalModel(id, payload, wtPath, true)
1191
+ const after = await sessionEvalContentRevision(ctxPath)
1192
+ if (before !== after) return { kind: 'unstable' }
1193
+ const summary = sessionEvalSummary(model.nodes)
1194
+ // Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse.
1195
+ for (const key of summaryByContent.keys()) if (key.startsWith(`${id}\0`)) summaryByContent.delete(key)
1196
+ summaryByContent.set(cacheKey, summary)
1197
+ return { kind: 'stable', revision: after, summary }
1198
+ }
1199
+
1200
+ const summaryByContent = new Map<string, SessionEvalSummary>()
1201
+ const projectionCache = new SessionEvalProjectionCache(buildSummaryAttempt)
1202
+ const OBSERVER_RECOVERY_TIMEOUT_MS = 10_000
1203
+
1204
+ async function awaitObservableInputs(id: string, path: string): Promise<void> {
1205
+ if (await projectionCache.waitUntilObservable(id, path, OBSERVER_RECOVERY_TIMEOUT_MS)) return
1206
+ throw new SessionEvalUnavailableError('session eval inputs remain temporarily unobservable')
1207
+ }
1208
+
1209
+ export function setSessionEvalProjectionNotify(notify: () => void): void { projectionCache.setNotify(notify) }
1210
+ export function sessionEvalProjections(sessions: { id: string; path: string }[]): Map<string, SessionEvalProjection> {
1211
+ return projectionCache.snapshot(sessions)
1212
+ }
1213
+ export function invalidateSessionEvalProjections(target: 'all' | { id?: string; path?: string } = 'all'): number {
1214
+ return projectionCache.invalidate(target)
1215
+ }
1216
+ export function holdSessionEvalProjectionObserver(
1217
+ observer: string,
1218
+ target: 'all' | { id?: string; path?: string } = 'all',
1219
+ ): boolean {
1220
+ return projectionCache.holdObserver(observer, target)
1221
+ }
1222
+ export function releaseSessionEvalProjectionObserver(observer: string): boolean {
1223
+ return projectionCache.releaseObserver(observer)
1224
+ }
1225
+ export async function awaitSessionEvalProjectionIdle(): Promise<void> { await projectionCache.idle() }
1226
+
1227
+ export async function buildSessionEvals(id: string): Promise<SessionEvals | null> {
1228
+ // A full model is demand-only. It fences itself against both the content fingerprint and the graph cache's
1229
+ // generation, and only publishes its summary when it is still the newest observed generation.
1230
+ for (;;) {
1231
+ await projectionCache.idle()
1232
+ const payload = await reviewPayload(id)
1233
+ if (!payload) return null
1234
+ const wtPath = worktreePathForBranch(payload.branch)
1235
+ const ctxPath = wtPath ?? repoRoot()
1236
+ await awaitObservableInputs(id, ctxPath)
1237
+ const known = projectionCache.get(id)
1238
+ const generation = known?.generation ?? 0
1239
+ const before = await sessionEvalContentRevision(ctxPath)
1240
+ const model = await buildSessionEvalModel(id, payload, wtPath, false)
1241
+ const after = await sessionEvalContentRevision(ctxPath)
1242
+ const current = projectionCache.get(id)
1243
+ if (before !== after || projectionCache.isObserverHeld(id, ctxPath)
1244
+ || (current && current.generation !== generation)) {
1245
+ if (before !== after) projectionCache.invalidate({ id })
1246
+ continue
1247
+ }
1248
+ const summary = sessionEvalSummary(model.nodes)
1249
+ const cacheKey = `${id}\0${after}`
1250
+ for (const key of summaryByContent.keys()) if (key.startsWith(`${id}\0`)) summaryByContent.delete(key)
1251
+ summaryByContent.set(cacheKey, summary)
1252
+ if (current) projectionCache.accept(id, generation, after, summary)
1253
+ return {
1254
+ ...model,
1255
+ summary,
1256
+ evalRevision: { epoch: projectionCache.epoch, generation, content: after },
1257
+ }
1258
+ }
1259
+ }