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.
- package/README.md +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +163 -30
- package/spec-cli/src/cli.ts +97 -42
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +31 -16
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +53 -36
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +157 -76
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +266 -67
- package/spec-cli/src/help.ts +84 -41
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +163 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +115 -169
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +162 -0
- package/spec-cli/src/sessions.ts +210 -63
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +47 -24
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +80 -45
- package/spec-eval/src/evaltab.ts +4 -3
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -398
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
- package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
- package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
- package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
- package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
|
@@ -1,20 +1,114 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
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 ?
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
125
|
-
title:
|
|
126
|
-
hue:
|
|
127
|
-
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:
|
|
132
|
-
uncoveredFrontend:
|
|
133
|
-
|
|
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
|
-
//
|
|
138
|
-
nodes.sort((a, b) => (b.
|
|
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`, '--',
|
|
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.
|
|
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="
|
|
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
|
|
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
|
-
|
|
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
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
const
|
|
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:
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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)
|
|
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
|
+
}
|