spexcode 0.2.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +56 -44
  2. package/package.json +3 -3
  3. package/spec-cli/bin/spex.mjs +2 -2
  4. package/spec-cli/hooks/dispatch.sh +1 -1
  5. package/spec-cli/hooks/harness.sh +26 -6
  6. package/spec-cli/src/anchors.ts +300 -0
  7. package/spec-cli/src/attach.ts +2 -2
  8. package/spec-cli/src/cli.ts +691 -536
  9. package/spec-cli/src/client.ts +31 -30
  10. package/spec-cli/src/contract-filter.ts +1 -1
  11. package/spec-cli/src/doctor.ts +40 -13
  12. package/spec-cli/src/gateway.ts +11 -7
  13. package/spec-cli/src/git.ts +23 -15
  14. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  15. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  16. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  17. package/spec-cli/src/graphStream.ts +288 -0
  18. package/spec-cli/src/guide.ts +142 -98
  19. package/spec-cli/src/harness-select.ts +2 -2
  20. package/spec-cli/src/harness.ts +30 -14
  21. package/spec-cli/src/help.ts +300 -385
  22. package/spec-cli/src/hooks.ts +1 -1
  23. package/spec-cli/src/index.ts +144 -103
  24. package/spec-cli/src/init.ts +14 -13
  25. package/spec-cli/src/issues.ts +89 -33
  26. package/spec-cli/src/layout.ts +5 -5
  27. package/spec-cli/src/lint.ts +128 -30
  28. package/spec-cli/src/localIssues.ts +61 -60
  29. package/spec-cli/src/materialize.ts +1 -1
  30. package/spec-cli/src/mentions.ts +15 -15
  31. package/spec-cli/src/migrate-table.ts +398 -0
  32. package/spec-cli/src/migrate.ts +386 -0
  33. package/spec-cli/src/ranker.ts +30 -4
  34. package/spec-cli/src/reaper.ts +117 -0
  35. package/spec-cli/src/search.bench.mjs +12 -12
  36. package/spec-cli/src/search.ts +1 -1
  37. package/spec-cli/src/sessions.ts +244 -138
  38. package/spec-cli/src/specs.ts +40 -25
  39. package/spec-cli/src/supervise.ts +2 -2
  40. package/spec-cli/src/tree.ts +5 -5
  41. package/spec-cli/src/uninstall.ts +4 -4
  42. package/spec-cli/templates/hooks/post-checkout +1 -1
  43. package/spec-cli/templates/hooks/post-merge +3 -3
  44. package/spec-cli/templates/hooks/pre-commit +9 -9
  45. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  51. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  52. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
  56. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  59. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  60. package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
  61. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  62. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  63. package/spec-cli/templates/spec/project/spec.md +1 -1
  64. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
  65. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
  66. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
  67. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
  68. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
  69. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
  70. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
  71. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
  74. package/spec-dashboard/dist/index.html +2 -2
  75. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  76. package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
  77. package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
  78. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  79. package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
  80. package/spec-eval/src/humanok.ts +43 -0
  81. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  82. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  83. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  84. package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
  85. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  86. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  87. package/spec-forge/src/cli.ts +14 -13
  88. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  89. package/spec-cli/src/boardStream.ts +0 -179
  90. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
  91. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  92. package/spec-cli/templates/spec/project/.config/spec.md +0 -15
  93. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  94. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  98. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  99. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  100. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  101. /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
@@ -1,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 YATSU_FILE = 'yatsu.md'
7
- export const SIDECAR_FILE = 'yatsu.evals.ndjson'
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 YatsuNode = {
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
- yatsuPath: string // repo-relative path to yatsu.md — the SCENARIO freshness axis
23
- sidecarPath: string // absolute path to yatsu.evals.ndjson
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 yatsu.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
+ // 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 — a yatsu.md must declare a `scenarios:` list']
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 a yatsu.md`)
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 a yatsu.md; the node id is its CANONICAL spec id ([[id-url-safe]]) —
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 yatsu subset — a leaf that collides among spec nodes is disambiguated even when only one of
181
- // them measures). So the id yatsu answers to is exactly the id board/scan/search already print — never a
182
- // second, diverging bare-leaf scheme. A yatsu.md beside no spec.md keeps its leaf name (no spec id to align with).
183
- function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string }[]): YatsuNode[] {
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
- yatsuPath: relative(root, join(dir, YATSU_FILE)),
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 yatsuNodes(root: string): YatsuNode[] {
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, YATSU_FILE))) hits.push({ dir, src: readFileSync(join(dir, YATSU_FILE), 'utf8') })
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 yatsuNodes for the HOT board build ([[board-cache]]): reading each yatsu.md through
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 yatsuNodes; only buildBoard uses
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 yatsuNodesAsync(root: string): Promise<YatsuNode[]> {
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, YATSU_FILE))) hits.push({ dir, src: await readFile(join(dir, YATSU_FILE), 'utf8') })
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 YatsuResolution<T> = { ok: true; node: T } | { ok: false; ambiguous: boolean; error: string }
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 yatsu set: an EXACT canonical id always wins; a bare leaf
237
- // name stays the convenience it always was while it names exactly ONE yatsu node; a leaf several nodes
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 resolveYatsuNode<T extends Pick<YatsuNode, 'id' | 'dir'>>(nodes: T[], ref: string): YatsuResolution<T> {
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} yatsu nodes share that leaf name; use a canonical id: ${byLeaf.map((n) => n.id).sort().join(', ')}` }
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 yatsu node '${ref}' (a node needs a yatsu.md)` }
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 yatsu reading rendered for the proof: the latest measurement of one scenario, with its evidence
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 ProofReading = {
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 ProofFile = ReviewDiffFile & {
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 yatsu.md is an honest blind spot.
45
- export type ProofNode = {
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: ProofFile[]
50
+ files: ExportFile[]
51
51
  additions: number
52
52
  deletions: number
53
- hasYatsu: boolean
53
+ hasEvalFile: boolean
54
54
  uncoveredFrontend: boolean
55
55
  score: ScoreState
56
- readings: ProofReading[]
56
+ readings: ExportReading[]
57
57
  }
58
58
 
59
- export type ProofGate = { label: string; ok: boolean; detail: string }
59
+ export type ExportGate = { label: string; ok: boolean; detail: string }
60
60
 
61
- export type ProofModel = {
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: ProofGate[]
70
- score: { passed: number; total: number; fresh: number } // the yatsu summary across all nodes
71
- nodes: ProofNode[]
72
- otherFiles: ProofFile[] // changed files no spec node claims
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 buildProofModel(id: string): Promise<ProofModel | null> {
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, ProofFile>()
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, ProofFile[]>()
101
- const otherFiles: ProofFile[] = []
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: ProofNode[] = []
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(toProofReading))
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
- hasYatsu: tl.hasYatsu,
132
- uncoveredFrontend: !tl.hasYatsu && (spec?.code ?? []).some(isUiPath),
133
- score: nodeScore(tl.hasYatsu, latest),
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>>>): ProofGate[] {
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<ProofFile> {
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. yatsu owns the bytes
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 toProofReading(r: EvalEntry): Promise<ProofReading> {
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
- // yatsu.md is residual: the append-only sidecar still carries it, but it is not current loss, so it must not
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(hasYatsu: boolean, latest: EvalEntry[]): ScoreState {
250
- if (!hasYatsu) return null
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 / yatsu.md / sidecar)
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 yatsu scan --changed` uses). A shared file is
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: ProofReading): string {
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 the reading">stale</span>`
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="reading">
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: ProofFile): string {
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: ProofNode): string {
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 yatsu.md — no loss signal measured. Give it a scenario so this change can be proven.</div>`
381
- else if (n.hasYatsu) proof = `<div class="blindspot">declares scenarios but has no reading yet — measure with <code>spex yatsu eval ${esc(n.id)}</code></div>`
382
- else proof = `<div class="noev">no measurable surface (no yatsu.md)</div>`
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="readings">${proof}</div>
393
+ <div class="eval-list">${proof}</div>
394
394
  </article>`
395
395
  }
396
396
 
397
- export function renderProofHtml(m: ProofModel): string {
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 yatsu readings on the changed nodes">★ no measured loss</span>`,
401
+ m.score.total ? `<span class="chip ${m.score.passed === m.score.total ? 'ok' : 'warn'}" title="scenarios fresh-passing (of those measured); ${m.score.fresh}/${m.score.total} fresh">★ ${m.score.passed}/${m.score.total} passing</span>` : `<span class="chip warn" title="no evals filed on the changed nodes">★ no measured loss</span>`,
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>proof · ${esc(m.title)}</title>
412
+ <title>evals · ${esc(m.title)}</title>
413
413
  <style>${STYLE}</style>
414
414
  </head><body>
415
- <main class="proof">
415
+ <main class="evals">
416
416
  <header class="masthead">
417
- <div class="eyebrow">SpexCode · review proof</div>
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 review proof ${esc(idShort)}</code> — the optimizer's measured loss, presented for the merge decision. A spec is the loss; commits are the optimizer; yatsu is the evaluation.</footer>
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 yatsu score colours, so 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
- .proof{max-width:920px;margin:0 auto;padding:40px 24px 80px}
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
- .readings{margin-top:8px}
489
- .reading{margin-top:14px;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2)}
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 ([[review-proof]]'s interactive face) ----
519
- // The lean, TIERED counterpart of buildProofModel: the same worktree-rooted marshaling, but rows only —
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/yatsu/blob on open). Each reading carries `inSession`
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
- hasYatsu: boolean
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: ProofGate[]
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
- hasYatsu: tl.hasYatsu,
576
- uncoveredFrontend: !tl.hasYatsu && (spec?.code ?? []).some(isUiPath),
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 two event kinds are told apart POSITIVELY — a retraction
70
- // carries `retracts`, a reading carries `codeSha`; neither is recognized by another field's absence. `by`
71
- // is the retracting session; `note` says why (a botched e2e filing, a wrong verdict).
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
- // parse the sidecar RAW: one event per non-blank line a Reading, or a Retraction (a line carrying a
75
- // string `retracts`). A malformed line is skipped (the file is append-only and git-tracked, so a partial
76
- // write or a hand-edit shouldn't sink the whole read) fail soft per line.
77
- export function readSidecar(sidecarPath: string): { readings: Reading[]; retractions: Retraction[] } {
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
- if (!existsSync(sidecarPath)) return { readings, retractions }
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 yatsu-missing.
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 yatsu.md
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[] {