spexcode 0.2.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +300 -0
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +691 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +23 -15
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +142 -98
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +300 -385
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +144 -103
- package/spec-cli/src/init.ts +14 -13
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +128 -30
- package/spec-cli/src/localIssues.ts +61 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +398 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +12 -12
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +40 -25
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
- package/spec-eval/src/humanok.ts +43 -0
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/spec.md +0 -15
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
2
2
|
import { readFile, readdir } from 'node:fs/promises'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
3
4
|
import { join, relative, basename } from 'node:path'
|
|
4
5
|
import { mintIds } from '../../spec-cli/src/specs.js'
|
|
5
6
|
|
|
6
|
-
export const
|
|
7
|
-
export const SIDECAR_FILE = '
|
|
7
|
+
export const EVAL_FILE = 'eval.md'
|
|
8
|
+
export const SIDECAR_FILE = 'evals.ndjson'
|
|
8
9
|
|
|
9
10
|
export type Scenario = {
|
|
10
11
|
name: string
|
|
@@ -16,11 +17,11 @@ export type Scenario = {
|
|
|
16
17
|
related?: string[]
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export type
|
|
20
|
+
export type EvalNode = {
|
|
20
21
|
id: string // the node's CANONICAL spec id (leaf name, '_'-disambiguated on a leaf collision)
|
|
21
22
|
dir: string // absolute node directory
|
|
22
|
-
|
|
23
|
-
sidecarPath: string // absolute path to
|
|
23
|
+
evalPath: string // repo-relative path to eval.md — the SCENARIO freshness axis
|
|
24
|
+
sidecarPath: string // absolute path to evals.ndjson
|
|
24
25
|
scenarios: Scenario[]
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -32,7 +33,7 @@ const LIST_KEYS: readonly ScenarioKey[] = ['tags', 'code', 'related']
|
|
|
32
33
|
// carried — kept (not dropped) so the validator can name a typo'd field instead of silently swallowing it.
|
|
33
34
|
type RawItem = { fields: Partial<Record<ScenarioKey, string>>; unknownKeys: string[] }
|
|
34
35
|
|
|
35
|
-
// tiny indentation parser for
|
|
36
|
+
// tiny indentation parser for eval.md's frontmatter `scenarios:` block (no YAML dep), shared by parseScenarios and validateScenarios so they can't disagree; reports hasFrontmatter/hasKey so the validator can tell "none declared" from "malformed"
|
|
36
37
|
function walkScenarios(src: string): { hasFrontmatter: boolean; hasKey: boolean; items: RawItem[] } {
|
|
37
38
|
const m = src.match(/^---\n([\s\S]*?)\n---/)
|
|
38
39
|
if (!m) return { hasFrontmatter: false, hasKey: false, items: [] }
|
|
@@ -117,6 +118,20 @@ function assignField(cur: RawItem, kv: string, lines: string[], idx: number, key
|
|
|
117
118
|
|
|
118
119
|
const unquote = (s: string) => s.replace(/^["'](.*)["']$/, '$1').trim()
|
|
119
120
|
|
|
121
|
+
// @@@scenario contract hash - the deterministic content hash of a scenario's SEMANTIC text, stamped on each
|
|
122
|
+
// reading at filing time ([[eval-core]]'s scenario freshness axis). Hashes ONLY the measurement contract —
|
|
123
|
+
// description (what to measure) + expected (what zero loss looks like) — never name/tags/test/code/related.
|
|
124
|
+
// Normalization (spec'd, don't drift): each field independently collapses every whitespace run (space, tab,
|
|
125
|
+
// CR, LF) to a single space and trims its ends, so a prose re-wrap, an indent shift, CRLF churn or trailing
|
|
126
|
+
// whitespace never changes the hash; the two normalized fields join with a single '\n' (neither can contain
|
|
127
|
+
// one after normalization, so the join is unambiguous) and sha256-hex the UTF-8 bytes. The hash is pure text
|
|
128
|
+
// over the parsed declaration — no git, no file position — so it is identical wherever and however the same
|
|
129
|
+
// contract text is read.
|
|
130
|
+
const normSemantic = (s: string) => s.replace(/\s+/g, ' ').trim()
|
|
131
|
+
export function scenarioHash(s: Pick<Scenario, 'description' | 'expected'>): string {
|
|
132
|
+
return createHash('sha256').update(`${normSemantic(s.description)}\n${normSemantic(s.expected)}`, 'utf8').digest('hex')
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
// a scenario's optional list field (`code:`/`related:`) is a comma-separated path list (a YAML flow list
|
|
121
136
|
// `[a, b]` or bare `a, b`, or a single path) — the tiny parser stays scalar-only, so it is split here.
|
|
122
137
|
function parseCodeList(raw: string): string[] {
|
|
@@ -148,7 +163,7 @@ export function parseScenarios(src: string): Scenario[] {
|
|
|
148
163
|
// disables only the membership check, never the ≥1-tag requirement.
|
|
149
164
|
export function validateScenarios(src: string, tagLibrary: string[] = []): string[] {
|
|
150
165
|
const { hasFrontmatter, hasKey, items } = walkScenarios(src)
|
|
151
|
-
if (!hasFrontmatter) return ['no frontmatter block —
|
|
166
|
+
if (!hasFrontmatter) return ['no frontmatter block — an eval.md must declare a `scenarios:` list']
|
|
152
167
|
if (!hasKey) return ['frontmatter has no `scenarios:` key — declare at least one scenario']
|
|
153
168
|
if (!items.length) return ['`scenarios:` declares no scenarios — add one (name + description + expected)']
|
|
154
169
|
const errs: string[] = []
|
|
@@ -170,17 +185,17 @@ export function validateScenarios(src: string, tagLibrary: string[] = []): strin
|
|
|
170
185
|
for (const u of it.unknownKeys) errs.push(`${label}: unknown field \`${u}\` (allowed: ${SCENARIO_KEYS.join(', ')})`)
|
|
171
186
|
if (it.fields.name) counts.set(it.fields.name, (counts.get(it.fields.name) ?? 0) + 1)
|
|
172
187
|
})
|
|
173
|
-
for (const [n, c] of counts) if (c > 1) errs.push(`duplicate scenario name '${n}' (${c}×) — names must be unique within
|
|
188
|
+
for (const [n, c] of counts) if (c > 1) errs.push(`duplicate scenario name '${n}' (${c}×) — names must be unique within an eval.md`)
|
|
174
189
|
return errs
|
|
175
190
|
}
|
|
176
191
|
|
|
177
|
-
// walk `.spec` for every dir holding
|
|
192
|
+
// walk `.spec` for every dir holding an eval.md; the node id is its CANONICAL spec id ([[id-url-safe]]) —
|
|
178
193
|
// minted by the SAME rule as specs.ts's loader (mintIds: the leaf dir name, or on a leaf collision the
|
|
179
194
|
// shortest globally-unique '_'-joined trailing suffix) over the SAME universe (every dir holding a spec.md,
|
|
180
|
-
// not just the
|
|
181
|
-
// them measures). So the id
|
|
182
|
-
// second, diverging bare-leaf scheme.
|
|
183
|
-
function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string }[]):
|
|
195
|
+
// not just the eval subset — a leaf that collides among spec nodes is disambiguated even when only one of
|
|
196
|
+
// them measures). So the id eval verbs answer to is exactly the id board/scan/search already print — never a
|
|
197
|
+
// second, diverging bare-leaf scheme. An eval.md beside no spec.md keeps its leaf name (no spec id to align with).
|
|
198
|
+
function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string }[]): EvalNode[] {
|
|
184
199
|
const specBase = join(root, '.spec')
|
|
185
200
|
const ids = mintIds(specDirs.map((d) => relative(specBase, d).split(/[/\\]/)))
|
|
186
201
|
const idByDir = new Map(specDirs.map((d, i) => [d, ids[i]]))
|
|
@@ -188,14 +203,14 @@ function assembleNodes(root: string, specDirs: string[], hits: { dir: string; sr
|
|
|
188
203
|
.map(({ dir, src }) => ({
|
|
189
204
|
id: idByDir.get(dir) ?? basename(dir),
|
|
190
205
|
dir,
|
|
191
|
-
|
|
206
|
+
evalPath: relative(root, join(dir, EVAL_FILE)),
|
|
192
207
|
sidecarPath: join(dir, SIDECAR_FILE),
|
|
193
208
|
scenarios: parseScenarios(src),
|
|
194
209
|
}))
|
|
195
210
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
196
211
|
}
|
|
197
212
|
|
|
198
|
-
export function
|
|
213
|
+
export function evalNodes(root: string): EvalNode[] {
|
|
199
214
|
const specDir = join(root, '.spec')
|
|
200
215
|
const specDirs: string[] = []
|
|
201
216
|
const hits: { dir: string; src: string }[] = []
|
|
@@ -205,17 +220,17 @@ export function yatsuNodes(root: string): YatsuNode[] {
|
|
|
205
220
|
let ents
|
|
206
221
|
try { ents = readdirSync(dir, { withFileTypes: true }) } catch { continue }
|
|
207
222
|
if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
|
|
208
|
-
if (existsSync(join(dir,
|
|
223
|
+
if (existsSync(join(dir, EVAL_FILE))) hits.push({ dir, src: readFileSync(join(dir, EVAL_FILE), 'utf8') })
|
|
209
224
|
for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
|
|
210
225
|
}
|
|
211
226
|
return assembleNodes(root, specDirs, hits)
|
|
212
227
|
}
|
|
213
228
|
|
|
214
|
-
// async twin of
|
|
229
|
+
// async twin of evalNodes for the HOT board build ([[graph-cache]]): reading each eval.md through
|
|
215
230
|
// fs/promises YIELDS the event loop between files, so the walk no longer stalls a `/health` probe in one
|
|
216
|
-
// ~600ms uninterrupted stretch. Same output (canonical ids, id-sorted) as
|
|
231
|
+
// ~600ms uninterrupted stretch. Same output (canonical ids, id-sorted) as evalNodes; only buildBoard uses
|
|
217
232
|
// it, other callers keep the sync form.
|
|
218
|
-
export async function
|
|
233
|
+
export async function evalNodesAsync(root: string): Promise<EvalNode[]> {
|
|
219
234
|
const specDir = join(root, '.spec')
|
|
220
235
|
const specDirs: string[] = []
|
|
221
236
|
const hits: { dir: string; src: string }[] = []
|
|
@@ -225,25 +240,25 @@ export async function yatsuNodesAsync(root: string): Promise<YatsuNode[]> {
|
|
|
225
240
|
let ents
|
|
226
241
|
try { ents = await readdir(dir, { withFileTypes: true }) } catch { continue }
|
|
227
242
|
if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
|
|
228
|
-
if (existsSync(join(dir,
|
|
243
|
+
if (existsSync(join(dir, EVAL_FILE))) hits.push({ dir, src: await readFile(join(dir, EVAL_FILE), 'utf8') })
|
|
229
244
|
for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
|
|
230
245
|
}
|
|
231
246
|
return assembleNodes(root, specDirs, hits)
|
|
232
247
|
}
|
|
233
248
|
|
|
234
|
-
export type
|
|
249
|
+
export type EvalResolution<T> = { ok: true; node: T } | { ok: false; ambiguous: boolean; error: string }
|
|
235
250
|
|
|
236
|
-
// resolve a user-supplied node ref against the
|
|
237
|
-
// name stays the convenience it always was while it names exactly ONE
|
|
251
|
+
// resolve a user-supplied node ref against the measurable set: an EXACT canonical id always wins; a bare leaf
|
|
252
|
+
// name stays the convenience it always was while it names exactly ONE measurable node; a leaf several nodes
|
|
238
253
|
// share fails LOUD listing the candidate canonical ids — never an arbitrary first hit, so a reading can
|
|
239
254
|
// only land on the node the caller actually named.
|
|
240
|
-
export function
|
|
255
|
+
export function resolveEvalNode<T extends Pick<EvalNode, 'id' | 'dir'>>(nodes: T[], ref: string): EvalResolution<T> {
|
|
241
256
|
const exact = nodes.find((n) => n.id === ref)
|
|
242
257
|
if (exact) return { ok: true, node: exact }
|
|
243
258
|
const byLeaf = nodes.filter((n) => basename(n.dir) === ref)
|
|
244
259
|
if (byLeaf.length === 1) return { ok: true, node: byLeaf[0] }
|
|
245
260
|
if (byLeaf.length > 1) {
|
|
246
|
-
return { ok: false, ambiguous: true, error: `'${ref}' is ambiguous — ${byLeaf.length}
|
|
261
|
+
return { ok: false, ambiguous: true, error: `'${ref}' is ambiguous — ${byLeaf.length} measurable nodes share that leaf name; use a canonical id: ${byLeaf.map((n) => n.id).sort().join(', ')}` }
|
|
247
262
|
}
|
|
248
|
-
return { ok: false, ambiguous: false, error: `no
|
|
263
|
+
return { ok: false, ambiguous: false, error: `no measurable node '${ref}' (a node needs an eval.md)` }
|
|
249
264
|
}
|
|
@@ -10,9 +10,9 @@ import { isUiPath } from './cli.js'
|
|
|
10
10
|
|
|
11
11
|
type ScoreState = 'pass' | 'fail' | 'stalePass' | 'staleFail' | 'empty' | null
|
|
12
12
|
|
|
13
|
-
// one
|
|
13
|
+
// one eval reading rendered for the export: the latest measurement of one scenario, with its evidence
|
|
14
14
|
// resolved to inline bytes (an image data-URI, or transcript text) so the document is self-contained.
|
|
15
|
-
export type
|
|
15
|
+
export type ExportReading = {
|
|
16
16
|
scenario: string
|
|
17
17
|
expected: string
|
|
18
18
|
verdict?: EvalEntry['verdict']
|
|
@@ -32,7 +32,7 @@ export type ProofReading = {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// patch ''/old·new null = nothing to show (added → no old, deleted → no new), past the enrichment cap (omitted), or too large (truncated)
|
|
35
|
-
export type
|
|
35
|
+
export type ExportFile = ReviewDiffFile & {
|
|
36
36
|
patch: string
|
|
37
37
|
oldText: string | null
|
|
38
38
|
newText: string | null
|
|
@@ -41,24 +41,24 @@ export type ProofFile = ReviewDiffFile & {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// one changed spec node: its diff slice (the files this node owns that the session touched) joined with its
|
|
44
|
-
// measured loss (latest reading per scenario). A frontend node with no
|
|
45
|
-
export type
|
|
44
|
+
// measured loss (latest reading per scenario). A frontend node with no eval.md is an honest blind spot.
|
|
45
|
+
export type ExportNode = {
|
|
46
46
|
id: string
|
|
47
47
|
title: string
|
|
48
48
|
hue: number
|
|
49
49
|
desc: string
|
|
50
|
-
files:
|
|
50
|
+
files: ExportFile[]
|
|
51
51
|
additions: number
|
|
52
52
|
deletions: number
|
|
53
|
-
|
|
53
|
+
hasEvalFile: boolean
|
|
54
54
|
uncoveredFrontend: boolean
|
|
55
55
|
score: ScoreState
|
|
56
|
-
readings:
|
|
56
|
+
readings: ExportReading[]
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export type
|
|
59
|
+
export type ExportGate = { label: string; ok: boolean; detail: string }
|
|
60
60
|
|
|
61
|
-
export type
|
|
61
|
+
export type ExportModel = {
|
|
62
62
|
id: string
|
|
63
63
|
node: string | null
|
|
64
64
|
branch: string | null
|
|
@@ -66,14 +66,14 @@ export type ProofModel = {
|
|
|
66
66
|
generatedAt: string
|
|
67
67
|
ahead: number
|
|
68
68
|
dirtyNonRuntime: number
|
|
69
|
-
gates:
|
|
70
|
-
score: { passed: number; total: number; fresh: number } // the
|
|
71
|
-
nodes:
|
|
72
|
-
otherFiles:
|
|
69
|
+
gates: ExportGate[]
|
|
70
|
+
score: { passed: number; total: number; fresh: number } // the eval summary across all nodes
|
|
71
|
+
nodes: ExportNode[]
|
|
72
|
+
otherFiles: ExportFile[] // changed files no spec node claims
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
// null when no session has that id (route → 404).
|
|
76
|
-
export async function
|
|
76
|
+
export async function buildExportModel(id: string): Promise<ExportModel | null> {
|
|
77
77
|
const payload = await reviewPayload(id)
|
|
78
78
|
if (!payload) return null
|
|
79
79
|
const specs = await loadSpecs()
|
|
@@ -89,7 +89,7 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
89
89
|
// extra fetch. Capped at MAX_ENRICHED_FILES so a huge changeset can't bloat the page; the rest keep their
|
|
90
90
|
// row but say so (omitted), never silently blank.
|
|
91
91
|
const base = wtPath ? (await gitA(['-C', wtPath, 'merge-base', mainBranch(), 'HEAD'])).trim() : ''
|
|
92
|
-
const enriched = new Map<string,
|
|
92
|
+
const enriched = new Map<string, ExportFile>()
|
|
93
93
|
let budget = MAX_ENRICHED_FILES
|
|
94
94
|
for (const f of payload.diff) {
|
|
95
95
|
if (wtPath && base && budget > 0) { enriched.set(f.path, await enrichFile(wtPath, base, f)); budget-- }
|
|
@@ -97,8 +97,8 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
// group the session's real changes (merge-base diff) by the spec node that owns each file.
|
|
100
|
-
const byNode = new Map<string,
|
|
101
|
-
const otherFiles:
|
|
100
|
+
const byNode = new Map<string, ExportFile[]>()
|
|
101
|
+
const otherFiles: ExportFile[] = []
|
|
102
102
|
for (const f of payload.diff) {
|
|
103
103
|
const nid = nodeForFile(f.path, specs, payload.node)
|
|
104
104
|
const pf = enriched.get(f.path)!
|
|
@@ -108,13 +108,13 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
108
108
|
// the session's primary node always appears, even if it has no file in the diff yet.
|
|
109
109
|
if (payload.node && specById.has(payload.node) && !byNode.has(payload.node)) byNode.set(payload.node, [])
|
|
110
110
|
|
|
111
|
-
const nodes:
|
|
111
|
+
const nodes: ExportNode[] = []
|
|
112
112
|
let passed = 0, total = 0, fresh = 0
|
|
113
113
|
for (const [nid, files] of byNode) {
|
|
114
114
|
const spec = specById.get(nid)
|
|
115
115
|
const tl = await evalTimeline(nid, ctx)
|
|
116
116
|
const latest = declaredLatest(tl)
|
|
117
|
-
const readings = await Promise.all(latest.map(
|
|
117
|
+
const readings = await Promise.all(latest.map(toExportReading))
|
|
118
118
|
for (const r of latest) {
|
|
119
119
|
total++
|
|
120
120
|
if (r.fresh) fresh++
|
|
@@ -128,9 +128,9 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
128
128
|
files,
|
|
129
129
|
additions: files.reduce((a, f) => a + f.additions, 0),
|
|
130
130
|
deletions: files.reduce((a, f) => a + f.deletions, 0),
|
|
131
|
-
|
|
132
|
-
uncoveredFrontend: !tl.
|
|
133
|
-
score: nodeScore(tl.
|
|
131
|
+
hasEvalFile: tl.hasEvalFile,
|
|
132
|
+
uncoveredFrontend: !tl.hasEvalFile && (spec?.code ?? []).some(isUiPath),
|
|
133
|
+
score: nodeScore(tl.hasEvalFile, latest),
|
|
134
134
|
readings,
|
|
135
135
|
})
|
|
136
136
|
}
|
|
@@ -156,8 +156,8 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
// the gate checklist, derived from the cockpit payload's gates (the SAME numbers `spex review` prints).
|
|
160
|
-
function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>):
|
|
159
|
+
// the gate checklist, derived from the cockpit payload's gates (the SAME numbers `spex session review` prints).
|
|
160
|
+
function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>): ExportGate[] {
|
|
161
161
|
const g = p.gates
|
|
162
162
|
return [
|
|
163
163
|
{ label: 'lint', ok: g.lint.errorCount === 0, detail: `${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)` },
|
|
@@ -170,7 +170,7 @@ function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>): Pr
|
|
|
170
170
|
// gitA returns '' for a missing path — exactly the added/deleted (and best-effort rename) side; each side capped to MAX_FILE_BYTES
|
|
171
171
|
const MAX_ENRICHED_FILES = 60
|
|
172
172
|
const MAX_FILE_BYTES = 200_000
|
|
173
|
-
async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Promise<
|
|
173
|
+
async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Promise<ExportFile> {
|
|
174
174
|
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
175
175
|
const [patchRaw, oldRaw, newRaw] = await Promise.all([
|
|
176
176
|
run(['diff', '-M', `${base}..HEAD`, '--', f.path]),
|
|
@@ -190,9 +190,9 @@ async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Prom
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
// resolve a reading's evidence to inline bytes so the proof is a self-contained file: an image → a base64
|
|
193
|
-
// data-URI; a transcript → its text; the cache miss / no-capture states pass through.
|
|
193
|
+
// data-URI; a transcript → its text; the cache miss / no-capture states pass through. the eval cache owns the bytes
|
|
194
194
|
// (the content-addressed cache) — the proof only asks for them by hash.
|
|
195
|
-
async function
|
|
195
|
+
async function toExportReading(r: EvalEntry): Promise<ExportReading> {
|
|
196
196
|
const base = {
|
|
197
197
|
scenario: r.scenario, expected: r.expected, verdict: r.verdict, fresh: r.fresh,
|
|
198
198
|
staleAxes: r.staleAxes, score: readingScore(r), evaluator: r.evaluator, ts: r.ts,
|
|
@@ -223,7 +223,7 @@ function latestPerScenario(readings: EvalEntry[]): EvalEntry[] {
|
|
|
223
223
|
|
|
224
224
|
// the DECLARED scenarios' latest reading — the SAME declared-bounded computation every other eval face reads
|
|
225
225
|
// (score.jsx's scenarioStates for the node badge and the eval tab). A reading whose scenario is no longer in
|
|
226
|
-
//
|
|
226
|
+
// eval.md is residual: the append-only sidecar still carries it, but it is not current loss, so it must not
|
|
227
227
|
// become a proof reading card, a passed/total tick, or a node score. The proof was the one face driven by the
|
|
228
228
|
// readings that happen to exist rather than the scenarios that are declared — this bounds it like the rest, so
|
|
229
229
|
// a retired scenario's stale reading can't make the proof disagree with the dashboard (phantom card, off ribbon).
|
|
@@ -246,8 +246,8 @@ function readingScore(r: EvalEntry): ScoreState {
|
|
|
246
246
|
|
|
247
247
|
// worst-first aggregate over the latest reading per scenario: any fresh fail → fail; else any stale → grey
|
|
248
248
|
// (✗ if any stale last-failed, else ✓); else any unscored scenario → empty; else every scenario fresh-passes.
|
|
249
|
-
export function nodeScore(
|
|
250
|
-
if (!
|
|
249
|
+
export function nodeScore(hasEvalFile: boolean, latest: EvalEntry[]): ScoreState {
|
|
250
|
+
if (!hasEvalFile) return null
|
|
251
251
|
if (!latest.length) return 'empty'
|
|
252
252
|
if (latest.some((r) => r.fresh && verdictMark(r) === 'cross')) return 'fail'
|
|
253
253
|
const stale = latest.filter((r) => !r.fresh && verdictMark(r))
|
|
@@ -258,9 +258,9 @@ export function nodeScore(hasYatsu: boolean, latest: EvalEntry[]): ScoreState {
|
|
|
258
258
|
|
|
259
259
|
// ---- file → node mapping ----
|
|
260
260
|
|
|
261
|
-
// which spec node owns a changed file: a file inside a node's directory (its spec.md /
|
|
261
|
+
// which spec node owns a changed file: a file inside a node's directory (its spec.md / eval.md / sidecar)
|
|
262
262
|
// belongs to the NEAREST such node; otherwise the node whose governed `code:` claims it (exact path,
|
|
263
|
-
// directory prefix, or `*` glob — the same matching `spex
|
|
263
|
+
// directory prefix, or `*` glob — the same matching `spex eval lint --changed` uses). A shared file is
|
|
264
264
|
// governed by MANY nodes (ordinary composition); when the session has a primary node that also governs it,
|
|
265
265
|
// attribute it THERE so a node/<id> session's stake in cli.ts/index.ts groups under its own node, not
|
|
266
266
|
// whichever sibling sorts first.
|
|
@@ -324,7 +324,7 @@ function verdictBadge(v: EvalEntry['verdict']): string {
|
|
|
324
324
|
return `<span class="verdict note" title="${esc(v.note ?? '')}">≈ note</span>`
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
function renderReading(r:
|
|
327
|
+
function renderReading(r: ExportReading): string {
|
|
328
328
|
const ev = r.evidence
|
|
329
329
|
const body =
|
|
330
330
|
ev.kind === 'image' ? `<img class="shot" src="${ev.dataUri}" alt="${esc(r.scenario)}">`
|
|
@@ -333,9 +333,9 @@ function renderReading(r: ProofReading): string {
|
|
|
333
333
|
: ev.kind === 'data' ? `<pre class="transcript data">${esc(ev.text)}</pre>`
|
|
334
334
|
: ev.kind === 'miss' ? `<div class="noev">⌀ miss original file — the evidence bytes were pruned</div>`
|
|
335
335
|
: `<div class="noev">attested without a capture</div>`
|
|
336
|
-
const stale = r.fresh ? '' : `<span class="stale" title="${esc(r.staleAxes.join(', '))} changed since
|
|
336
|
+
const stale = r.fresh ? '' : `<span class="stale" title="${esc(r.staleAxes.join(', '))} changed since this eval">stale</span>`
|
|
337
337
|
const note = r.verdict?.note ? `<div class="rnote"><b>note</b> ${esc(r.verdict.note)}</div>` : ''
|
|
338
|
-
return `<div class="
|
|
338
|
+
return `<div class="eval-entry">
|
|
339
339
|
<div class="rhead">
|
|
340
340
|
${scoreBadge(r.score, r.fresh ? undefined : `stale: ${r.staleAxes.join(', ')}`)}
|
|
341
341
|
<span class="scenario">${esc(r.scenario)}</span>
|
|
@@ -359,7 +359,7 @@ function renderPatch(patch: string): string {
|
|
|
359
359
|
}).join('')
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
function renderFile(f:
|
|
362
|
+
function renderFile(f: ExportFile): string {
|
|
363
363
|
const row = `<span class="fstatus ${esc(f.status)}">${esc(f.status)}</span> <span class="fpath">${esc(f.path)}</span> <span class="fnum"><span class="add">+${f.additions}</span> <span class="del">−${f.deletions}</span></span>`
|
|
364
364
|
if (f.omitted) return `<div class="file flat"><div class="frow">${row}<span class="note">diff omitted — large changeset</span></div></div>`
|
|
365
365
|
if (!f.patch && f.oldText == null && f.newText == null) return `<div class="file flat"><div class="frow">${row}</div></div>`
|
|
@@ -372,14 +372,14 @@ function renderFile(f: ProofFile): string {
|
|
|
372
372
|
<div class="file-body">${f.patch ? `<div class="patch">${renderPatch(f.patch)}</div>` : '<div class="note">no textual diff</div>'}${cmp}</div></details>`
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
function renderNode(n:
|
|
375
|
+
function renderNode(n: ExportNode): string {
|
|
376
376
|
const stat = `<span class="diffstat"><span class="add">+${n.additions}</span> <span class="del">−${n.deletions}</span> · ${n.files.length} file(s)</span>`
|
|
377
377
|
const fileList = n.files.length ? `<div class="files">${n.files.map(renderFile).join('')}</div>` : ''
|
|
378
378
|
let proof: string
|
|
379
379
|
if (n.readings.length) proof = n.readings.map(renderReading).join('')
|
|
380
|
-
else if (n.uncoveredFrontend) proof = `<div class="blindspot">⚠ a frontend node with no
|
|
381
|
-
else if (n.
|
|
382
|
-
else proof = `<div class="noev">no measurable surface (no
|
|
380
|
+
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>`
|
|
382
|
+
else proof = `<div class="noev">no measurable surface (no eval.md)</div>`
|
|
383
383
|
return `<article class="node" style="--hue:${n.hue}">
|
|
384
384
|
<div class="nhead">
|
|
385
385
|
<span class="huedot"></span>
|
|
@@ -390,15 +390,15 @@ function renderNode(n: ProofNode): string {
|
|
|
390
390
|
</div>
|
|
391
391
|
${n.desc ? `<div class="ndesc">${esc(n.desc)}</div>` : ''}
|
|
392
392
|
${fileList}
|
|
393
|
-
<div class="
|
|
393
|
+
<div class="eval-list">${proof}</div>
|
|
394
394
|
</article>`
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
-
export function
|
|
397
|
+
export function renderExportHtml(m: ExportModel): string {
|
|
398
398
|
const idShort = m.id.slice(0, 8)
|
|
399
399
|
const ribbon = [
|
|
400
400
|
...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
|
|
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>`,
|
|
402
402
|
].join('')
|
|
403
403
|
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
404
|
const otherBlock = m.otherFiles.length
|
|
@@ -409,12 +409,12 @@ export function renderProofHtml(m: ProofModel): string {
|
|
|
409
409
|
<html lang="en"><head>
|
|
410
410
|
<meta charset="utf-8">
|
|
411
411
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
412
|
-
<title>
|
|
412
|
+
<title>evals · ${esc(m.title)}</title>
|
|
413
413
|
<style>${STYLE}</style>
|
|
414
414
|
</head><body>
|
|
415
|
-
<main class="
|
|
415
|
+
<main class="evals">
|
|
416
416
|
<header class="masthead">
|
|
417
|
-
<div class="eyebrow">SpexCode ·
|
|
417
|
+
<div class="eyebrow">SpexCode · session eval export</div>
|
|
418
418
|
<h1 class="claim">${esc(m.title)}</h1>
|
|
419
419
|
<div class="meta">session <code>${esc(idShort)}</code>${m.branch ? ` · <code>${esc(m.branch)}</code>` : ''}${m.node ? ` · node <code>${esc(m.node)}</code>` : ''} · ${m.ahead} commit(s) · ${m.nodes.length} node(s) · <span class="ts">${esc(m.generatedAt)}</span></div>
|
|
420
420
|
<div class="ribbon">${ribbon}</div>
|
|
@@ -428,12 +428,12 @@ export function renderProofHtml(m: ProofModel): string {
|
|
|
428
428
|
<h2>Merge gates</h2>
|
|
429
429
|
<ul class="gates">${gates}</ul>
|
|
430
430
|
</section>
|
|
431
|
-
<footer>Generated by <code>spex
|
|
431
|
+
<footer>Generated by <code>spex eval ls --session ${esc(idShort)} --export</code> — the optimizer's measured loss, presented for the merge decision. A spec is the loss; commits are the optimizer; eval keeps the score.</footer>
|
|
432
432
|
</main>
|
|
433
433
|
</body></html>`
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
// the document's inline stylesheet — dark, matching the board's palette and the
|
|
436
|
+
// the document's inline stylesheet — dark, matching the board's palette and the eval score colours, so the
|
|
437
437
|
// proof reads as one surface with the dashboard. Self-contained (no external font/asset).
|
|
438
438
|
const STYLE = `
|
|
439
439
|
:root{--bg:#0b0e14;--panel:#11161f;--panel2:#0e131b;--ink:#c9d4e3;--dim:#7c899c;--line:#1e2733;--accent:#4cc2a0;--green:#3fb950;--red:#f85149;--grey:#6e7b8c;--amber:#d29922}
|
|
@@ -441,7 +441,7 @@ const STYLE = `
|
|
|
441
441
|
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}
|
|
442
442
|
code,pre,.mono{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace}
|
|
443
443
|
code{background:#0006;padding:.05em .35em;border-radius:4px;font-size:.88em;color:#aee1d2}
|
|
444
|
-
.
|
|
444
|
+
.evals{max-width:920px;margin:0 auto;padding:40px 24px 80px}
|
|
445
445
|
.masthead{padding:30px 30px 26px;border:1px solid var(--line);border-radius:16px;background:radial-gradient(1200px 240px at 0% 0%,#16352c66,transparent),linear-gradient(160deg,#141b26,#0d121a);box-shadow:0 20px 60px -30px #000}
|
|
446
446
|
.eyebrow{font:600 11px/1 ui-monospace,monospace;letter-spacing:.18em;text-transform:uppercase;color:var(--accent)}
|
|
447
447
|
.claim{margin:14px 0 10px;font-size:30px;line-height:1.2;font-weight:700;color:#eef3fa;letter-spacing:-.01em}
|
|
@@ -485,8 +485,8 @@ h2{margin:42px 0 16px;font-size:14px;letter-spacing:.14em;text-transform:upperca
|
|
|
485
485
|
.side-h{font:600 10px/1 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);padding:0 0 6px}
|
|
486
486
|
.side pre{margin:0;padding:10px 12px;background:#05080d;border:1px solid var(--line);border-radius:8px;max-height:440px;overflow:auto;font:11px/1.5 ui-monospace,monospace;color:#aebccd;white-space:pre}
|
|
487
487
|
.abs{color:var(--dim);font-style:italic}
|
|
488
|
-
.
|
|
489
|
-
.
|
|
488
|
+
.eval-list{margin-top:8px}
|
|
489
|
+
.eval-entry{margin-top:14px;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2)}
|
|
490
490
|
.rhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
491
491
|
.scenario{font-weight:600;color:#dde7f1}
|
|
492
492
|
.rmeta{margin-left:auto;font:11px/1 ui-monospace,monospace;color:var(--dim)}
|
|
@@ -515,10 +515,10 @@ h2{margin:42px 0 16px;font-size:14px;letter-spacing:.14em;text-transform:upperca
|
|
|
515
515
|
footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;text-align:center}
|
|
516
516
|
`
|
|
517
517
|
|
|
518
|
-
// ---- the session EVAL model ([[
|
|
519
|
-
// The lean, TIERED counterpart of
|
|
518
|
+
// ---- the session EVAL model ([[session-eval]]'s interactive face) ----
|
|
519
|
+
// The lean, TIERED counterpart of buildExportModel: the same worktree-rooted marshaling, but rows only —
|
|
520
520
|
// no diff enrichment, no inlined evidence bytes (the dashboard's Eval tab rides the shared eval
|
|
521
|
-
// components: blobs stream lazily from /api/
|
|
521
|
+
// components: blobs stream lazily from /api/evidence on open). Each reading carries `inSession`
|
|
522
522
|
// (this session filed it, or its codeSha is one of the branch's own commits — a diagnostic session
|
|
523
523
|
// that changed no code still owns the readings it filed at the merge-base) so the tab can lead with
|
|
524
524
|
// what THIS session measured, ✦-marked, over the inherited baseline.
|
|
@@ -527,7 +527,7 @@ export type SessionEvalNode = {
|
|
|
527
527
|
title: string
|
|
528
528
|
hue: number
|
|
529
529
|
desc: string
|
|
530
|
-
|
|
530
|
+
hasEvalFile: boolean
|
|
531
531
|
uncoveredFrontend: boolean
|
|
532
532
|
scenarios: { name: string; expected: string; tags?: string[] }[]
|
|
533
533
|
// each reading carries the trunk eval-concern thread for its (node, scenario) ([[remark-teeth]] / directive
|
|
@@ -543,7 +543,7 @@ export type SessionEvals = {
|
|
|
543
543
|
title: string
|
|
544
544
|
ahead: number
|
|
545
545
|
dirtyNonRuntime: number
|
|
546
|
-
gates:
|
|
546
|
+
gates: ExportGate[]
|
|
547
547
|
nodes: SessionEvalNode[]
|
|
548
548
|
}
|
|
549
549
|
|
|
@@ -572,8 +572,8 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
|
|
|
572
572
|
title: spec?.title ?? nid,
|
|
573
573
|
hue: spec?.hue ?? 210,
|
|
574
574
|
desc: spec?.desc ?? '',
|
|
575
|
-
|
|
576
|
-
uncoveredFrontend: !tl.
|
|
575
|
+
hasEvalFile: tl.hasEvalFile,
|
|
576
|
+
uncoveredFrontend: !tl.hasEvalFile && (spec?.code ?? []).some(isUiPath),
|
|
577
577
|
scenarios: tl.scenarios,
|
|
578
578
|
// the per-scenario trunk thread rides each reading as `EvalEntry.thread` (evalTimeline's overlay), so
|
|
579
579
|
// the event detail has the comment/remark track inline — no extra join here. A reading is the
|
|
@@ -27,6 +27,12 @@ export type Evidence = { hash: string; kind: EvidenceKind }
|
|
|
27
27
|
export type Reading = {
|
|
28
28
|
scenario: string
|
|
29
29
|
codeSha: string
|
|
30
|
+
// content hash of the scenario's semantic text (description+expected, normalized — scenarios.ts
|
|
31
|
+
// scenarioHash) as DECLARED at filing time: the record of which contract this measurement was taken
|
|
32
|
+
// against. When present it alone decides the scenario freshness axis (a pure compare against the current
|
|
33
|
+
// declaration's hash); a legacy reading without it is decided by the git-derived rule instead — one
|
|
34
|
+
// track per reading, never both ([[eval-core]]).
|
|
35
|
+
scenarioHash?: string
|
|
30
36
|
evidence?: Evidence[]
|
|
31
37
|
// legacy scalar evidence — read for old readings, never written by new filings.
|
|
32
38
|
blob?: string | null
|
|
@@ -66,18 +72,28 @@ export function isJsonBlob(b: Buffer): boolean {
|
|
|
66
72
|
|
|
67
73
|
// a RETRACTION is the sanctioned inverse of a filing — itself an appended event, never a deleted line
|
|
68
74
|
// (the sidecar stays append-only; git shows who retracted what, when). `retracts` is the target reading's
|
|
69
|
-
// `ts` within `scenario` (its natural key). The
|
|
70
|
-
// carries `retracts`, a reading carries `codeSha
|
|
71
|
-
// is the retracting session; `note` says why (a botched e2e
|
|
75
|
+
// `ts` within `scenario` (its natural key). The event kinds are told apart POSITIVELY — a retraction
|
|
76
|
+
// carries `retracts`, a reading carries `codeSha`, a human-ok carries `kind: 'human-ok'`; none is ever
|
|
77
|
+
// recognized by another field's absence. `by` is the retracting session; `note` says why (a botched e2e
|
|
78
|
+
// filing, a wrong verdict).
|
|
72
79
|
export type Retraction = { retracts: string; scenario: string; note?: string; by?: string; ts: string }
|
|
73
80
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
81
|
+
// a HUMAN-OK ([[human-ok]]) is the human's sign-off on ONE immutable reading — an appended event like the
|
|
82
|
+
// others, never a mutation. `okTs`+`okSha` anchor the blessed reading (its ts is the natural key within
|
|
83
|
+
// `scenario`, exactly retraction's join; the sha rides for the human reader). The ok is MONOTONIC — there
|
|
84
|
+
// is no un-ok event: a newer reading is a different object the ok never transfers to, and staleness is
|
|
85
|
+
// computed live, so both automatically bring the scenario back. A pre-human-ok toolchain skips these lines
|
|
86
|
+
// silently (no top-level `codeSha`, so its reading parse never claims them).
|
|
87
|
+
export type HumanOk = { kind: 'human-ok'; scenario: string; okTs: string; okSha: string; by: string; ts: string }
|
|
88
|
+
|
|
89
|
+
// parse the sidecar RAW: one event per non-blank line — a Reading, a Retraction (a line carrying a string
|
|
90
|
+
// `retracts`), or a HumanOk (kind 'human-ok'). A malformed line is skipped (the file is append-only and
|
|
91
|
+
// git-tracked, so a partial write or a hand-edit shouldn't sink the whole read) — fail soft per line.
|
|
92
|
+
export function readSidecar(sidecarPath: string): { readings: Reading[]; retractions: Retraction[]; oks: HumanOk[] } {
|
|
78
93
|
const readings: Reading[] = []
|
|
79
94
|
const retractions: Retraction[] = []
|
|
80
|
-
|
|
95
|
+
const oks: HumanOk[] = []
|
|
96
|
+
if (!existsSync(sidecarPath)) return { readings, retractions, oks }
|
|
81
97
|
for (const line of readFileSync(sidecarPath, 'utf8').split('\n')) {
|
|
82
98
|
const t = line.trim()
|
|
83
99
|
if (!t) continue
|
|
@@ -85,10 +101,11 @@ export function readSidecar(sidecarPath: string): { readings: Reading[]; retract
|
|
|
85
101
|
const r = JSON.parse(t)
|
|
86
102
|
if (!r || typeof r.scenario !== 'string') continue
|
|
87
103
|
if (typeof r.retracts === 'string') retractions.push(r as Retraction)
|
|
104
|
+
else if (r.kind === 'human-ok' && typeof r.okTs === 'string') oks.push(r as HumanOk)
|
|
88
105
|
else if (typeof r.codeSha === 'string') readings.push(r as Reading)
|
|
89
106
|
} catch { /* skip a malformed line */ }
|
|
90
107
|
}
|
|
91
|
-
return { readings, retractions }
|
|
108
|
+
return { readings, retractions, oks }
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
// the retraction join, shared by every effective-view reader: drop each reading a retraction targets by
|
|
@@ -103,7 +120,7 @@ export function applyRetractions(readings: Reading[], retractions: Retraction[])
|
|
|
103
120
|
// the EFFECTIVE readings — what the scoreboard sees: every reading minus the retracted. Every score
|
|
104
121
|
// consumer (freshness, scan, clean's referenced set, the eval tab, the proof) reads through here, so a
|
|
105
122
|
// retract undoes a botched filing on ALL of them at once — the previous reading becomes the latest again,
|
|
106
|
-
// or the scenario honestly returns to
|
|
123
|
+
// or the scenario honestly returns to eval-missing.
|
|
107
124
|
export function readReadings(sidecarPath: string): Reading[] {
|
|
108
125
|
const { readings, retractions } = readSidecar(sidecarPath)
|
|
109
126
|
return applyRetractions(readings, retractions)
|
|
@@ -121,6 +138,21 @@ export function appendRetraction(sidecarPath: string, r: Retraction): void {
|
|
|
121
138
|
appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
|
|
122
139
|
}
|
|
123
140
|
|
|
141
|
+
// append ONE human-ok as a JSON line — the sign-off writes through the same append-only surface; the
|
|
142
|
+
// blessed reading stays untouched, the ok binds to it by (scenario, okTs).
|
|
143
|
+
export function appendHumanOk(sidecarPath: string, r: HumanOk): void {
|
|
144
|
+
appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// the ok that binds to a reading — the LAST ok row targeting (scenario, ts), or null. An ok anchored to a
|
|
148
|
+
// retracted/superseded reading is inert history: it binds to nothing current, so the join is by exact
|
|
149
|
+
// (scenario, okTs) against whichever readings the caller passes.
|
|
150
|
+
export function humanOkFor(oks: HumanOk[], scenario: string, readingTs: string): HumanOk | null {
|
|
151
|
+
let hit: HumanOk | null = null
|
|
152
|
+
for (const o of oks) if (o.scenario === scenario && o.okTs === readingTs) hit = o
|
|
153
|
+
return hit
|
|
154
|
+
}
|
|
155
|
+
|
|
124
156
|
// the latest reading per scenario (the file is chronological, so the LAST line for a name wins). clean's
|
|
125
157
|
// --keep-latest uses it to decide which blob to keep.
|
|
126
158
|
export function latestPerScenario(readings: Reading[]): Map<string, Reading> {
|
|
@@ -22,7 +22,7 @@ const V2_EVENT_KEYS = new Set(['at', 'step', 'node'])
|
|
|
22
22
|
const V1_EVENT_KEYS = new Set(['tMs', 'step', 'node'])
|
|
23
23
|
|
|
24
24
|
// validate LOUD — every violation named, [] when well-formed. Both schema versions are accepted: v1 (legacy
|
|
25
|
-
// time axis, `tMs`) and v2 (axis-tagged, `at`). The key set is closed per version (like the
|
|
25
|
+
// time axis, `tMs`) and v2 (axis-tagged, `at`). The key set is closed per version (like the eval.md
|
|
26
26
|
// scenario schema): a malformed timeline is rejected at filing time, never silently reshaped. The `axis`
|
|
27
27
|
// string itself is open — only its ABSENCE is an error, never an unrecognized value.
|
|
28
28
|
export function validateTimeline(raw: unknown): string[] {
|