spexcode 0.5.8 → 0.5.9

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 (103) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +144 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/doctor.ts +0 -16
  15. package/spec-cli/src/gateway-auth.ts +0 -13
  16. package/spec-cli/src/gateway.ts +0 -22
  17. package/spec-cli/src/git.ts +151 -48
  18. package/spec-cli/src/graph.ts +28 -7
  19. package/spec-cli/src/graphCache.ts +253 -72
  20. package/spec-cli/src/graphDelta.ts +0 -13
  21. package/spec-cli/src/graphStream.ts +70 -37
  22. package/spec-cli/src/guide.ts +29 -6
  23. package/spec-cli/src/harness.ts +232 -248
  24. package/spec-cli/src/help.ts +27 -26
  25. package/spec-cli/src/host-resources.ts +11 -2
  26. package/spec-cli/src/index.ts +70 -95
  27. package/spec-cli/src/issues-cli.ts +276 -0
  28. package/spec-cli/src/issues.ts +15 -197
  29. package/spec-cli/src/layout.ts +78 -2
  30. package/spec-cli/src/lint.ts +5 -3
  31. package/spec-cli/src/localIssues.ts +22 -185
  32. package/spec-cli/src/loop-in.ts +78 -0
  33. package/spec-cli/src/materialize.ts +0 -42
  34. package/spec-cli/src/mentions.ts +49 -193
  35. package/spec-cli/src/opencode-headless.ts +7 -7
  36. package/spec-cli/src/opencode.ts +2 -5
  37. package/spec-cli/src/pi-headless.ts +5 -5
  38. package/spec-cli/src/pty-bridge.ts +4 -5
  39. package/spec-cli/src/reviewSnapshot.ts +1 -0
  40. package/spec-cli/src/reviews.ts +6 -1
  41. package/spec-cli/src/root-lru.ts +54 -0
  42. package/spec-cli/src/runtime-guard.ts +0 -9
  43. package/spec-cli/src/session-cursors.ts +97 -0
  44. package/spec-cli/src/session-follow.ts +182 -0
  45. package/spec-cli/src/session-timeline.ts +55 -127
  46. package/spec-cli/src/sessions.ts +207 -1022
  47. package/spec-cli/src/shim-runtime.ts +9 -16
  48. package/spec-cli/src/specs.ts +33 -10
  49. package/spec-cli/src/uninstall.ts +0 -9
  50. package/spec-cli/src/uploads.ts +240 -18
  51. package/spec-cli/src/worktree-sources.ts +0 -16
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  56. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  57. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  58. package/spec-cli/templates/spexcode.json +12 -0
  59. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  60. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  61. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  62. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  63. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  64. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  65. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  66. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  68. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  69. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  70. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  71. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  74. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  75. package/spec-dashboard/dist/index.html +2 -2
  76. package/spec-dashboard/src/session.js +1 -28
  77. package/spec-eval/src/cache.ts +0 -14
  78. package/spec-eval/src/cli.ts +26 -5
  79. package/spec-eval/src/evaltab.ts +67 -108
  80. package/spec-eval/src/filing.ts +0 -18
  81. package/spec-eval/src/freshness.ts +78 -32
  82. package/spec-eval/src/humanok.ts +0 -15
  83. package/spec-eval/src/scenariofresh.ts +8 -80
  84. package/spec-eval/src/scenarios.ts +16 -67
  85. package/spec-eval/src/sessioneval.ts +157 -78
  86. package/spec-eval/src/sidecar.ts +1 -70
  87. package/spec-eval/src/timeline.ts +0 -23
  88. package/spec-forge/src/cache.ts +34 -12
  89. package/spec-forge/src/cli.ts +0 -6
  90. package/spec-forge/src/drivers/github.ts +1 -12
  91. package/spec-forge/src/drivers/gitlab.ts +4 -19
  92. package/spec-forge/src/drivers.ts +0 -18
  93. package/spec-forge/src/links.ts +0 -4
  94. package/spec-forge/src/needs-eval.ts +0 -1
  95. package/spec-forge/src/port.ts +0 -12
  96. package/spec-forge/src/resident.ts +4 -20
  97. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  98. package/spec-cli/src/session-maintenance.ts +0 -751
  99. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  100. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  101. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  102. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  103. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -1,9 +1,10 @@
1
1
  import { readFileSync } from 'node:fs'
2
+ import { createHash } from 'node:crypto'
2
3
  import { join, resolve } from 'node:path'
3
- import { gitA, gitTry, headSha, currentGitBuildAbortSignal, gitAbortError, ancestorsOf, inAncestors, commitReachable, pathEvents, type DriftIndex, type DriftPathEvent } from '../../spec-cli/src/git.js'
4
- import { anchorHitCommits, extOf, extractorFor, extractors, resolveAnchor, type Extractor, type RelationEntry } from '../../spec-cli/src/anchors.js'
4
+ import { gitA, gitTry, headSha, currentGitBuildAbortSignal, gitAbortError, ancestorsOf, inAncestors, commitReachable, pathEvents, type DriftIndex, type DriftPathEvent, eventsSince } from '../../spec-cli/src/git.js'
5
+ import { anchorHitExists, extOf, extractorFor, extractors, resolveAnchor, resolveSelectors, type AnchorHitQuery, type Extractor, type RelationEntry, type Unit } from '../../spec-cli/src/anchors.js'
5
6
  import type { Reading } from './sidecar.js'
6
- import { scenarioCodeAxis, scenarioHash, type Scenario } from './scenarios.js'
7
+ import { scenarioCodeAxis, scenarioHash, type Scenario, type ScenarioCodeAxisSource } from './scenarios.js'
7
8
  import { scenarioChangeCommits, scenarioBlocksAt, primeScenarioBlocksAt, type ScenarioIndex } from './scenariofresh.js'
8
9
 
9
10
  // the CODE axis is touch-based (DriftIndex), so a code-file rename is out of scope — the same blind spot lint's code-drift has
@@ -259,8 +260,12 @@ export type AnchorProbe = {
259
260
  // did any commit in sinceSha..HEAD touch one of THESE anchored units?
260
261
  // null = cannot testify (unprimed, off-history, no usable extractor) — callers stay conservatively stale.
261
262
  hit(sinceSha: string, path: string, selectors: readonly string[]): boolean | null
262
- prime?(sinceSha: string, entries: readonly RelationEntry[]): Promise<void>
263
+ // The demand set is PLURAL because the engine underneath is: every window's Git images and ordinary hunks
264
+ // are immutable, so one call owns them once for the whole read. A caller that primes one reading at a time
265
+ // re-forks that batch per reading, which on this corpus was ~2.5k children for ~800 verdicts.
266
+ prime?(demands: readonly AnchorDemand[]): Promise<void>
263
267
  }
268
+ export type AnchorDemand = { sinceSha: string; entries: readonly RelationEntry[] }
264
269
 
265
270
  // a verdict answers ONE selector set, so the set is part of its identity: several scenarios anchoring
266
271
  // DIFFERENT units of one shared file is the whole point of narrowing, and keying only by (sha, path) would
@@ -268,13 +273,29 @@ export type AnchorProbe = {
268
273
  const anchorKey = (sinceSha: string, path: string, selectors: readonly string[]) =>
269
274
  `${sinceSha}\x1f${path}\x1f${[...selectors].sort().join('\x1e')}`
270
275
 
271
- // the eval code window: commits touching `path` in sinceSha..HEAD by the same true ancestry `changedSince`
272
- // uses, from the same index source so the anchor check can only narrow the very set the file question just
273
- // answered `true` for. null = ancestry cannot testify (off-history anchor) the caller stays conservative.
274
- function evalWindowCommits(idx: DriftIndex, sinceSha: string, path: string): DriftPathEvent[] | null {
275
- const anc = ancestorsOf(idx, sinceSha)
276
- if (!anc) return null
277
- return pathEvents(idx, path).filter((event) => !inAncestors(idx, anc, event.commit))
276
+ // @@@ one parse per CONTENT, not per selector entry - a node's `code:` entries are asked one at a time, and
277
+ // the same working-tree file backs many of them, so the unmemoized read parsed 46 distinct files 922 times
278
+ // per build (40.4 MB through the TypeScript parser) and did it again on every rebuild. `extract` is a pure
279
+ // function of (text, path, extractor), so its result is reusable exactly as far as the CONTENT is unchanged.
280
+ // The key is a content digest and never mtime/size: this gate decides whether a reading may testify, so a
281
+ // stale unit list would let a dead selector read as alive — the precise failure the comment below warns
282
+ // about. Digesting is ~10x cheaper than parsing, so the read stays and only the parse is saved. Bounded like
283
+ // the historical-revision memo it mirrors ([[code-anchor]]), and it caches the extractor's REJECTION too, so
284
+ // an unparseable file does not re-parse once per entry.
285
+ const CURRENT_TREE_MEMO_MAX = 4096
286
+ const currentTreeUnitMemo = new Map<string, { units: Unit[] } | { failed: string }>()
287
+ function currentTreeUnits(root: string, x: Extractor, path: string): Unit[] {
288
+ const source = readFileSync(join(root, path), 'utf8')
289
+ const key = `${x.memoKey(path)}\0${createHash('sha1').update(source).digest('hex')}`
290
+ const hit = currentTreeUnitMemo.get(key)
291
+ if (hit) { if ('failed' in hit) throw new Error(hit.failed); return hit.units }
292
+ let entry: { units: Unit[] } | { failed: string }
293
+ try { entry = { units: x.extract(source, path) } }
294
+ catch (err: any) { entry = { failed: err?.message ?? String(err) } }
295
+ if (currentTreeUnitMemo.size >= CURRENT_TREE_MEMO_MAX) currentTreeUnitMemo.clear()
296
+ currentTreeUnitMemo.set(key, entry)
297
+ if ('failed' in entry) throw new Error(entry.failed)
298
+ return entry.units
278
299
  }
279
300
 
280
301
  // every selector of one entry resolves to exactly one unit in the CURRENT tree, or the entry cannot testify.
@@ -288,32 +309,55 @@ function entryUnverifiable(root: string, regs: Extractor[], entry: RelationEntry
288
309
  const ready = x.ready()
289
310
  if (ready !== true) return `\`code\` anchors on ${entry.path} are unverified: ${ready}`
290
311
  let units
291
- try { units = x.extract(readFileSync(join(root, entry.path), 'utf8'), entry.path) }
312
+ try { units = currentTreeUnits(root, x, entry.path) }
292
313
  catch (err: any) { return `\`code\` anchors on ${entry.path} are unverified: ${err?.message ?? String(err)}` }
293
- for (const sym of entry.selectors) {
294
- const r = resolveAnchor(units, sym)
295
- if ('dead' in r) return `\`code\` selector \`${entry.path}#${sym}\` names no unit in that file — follow the rename or drop the selector (evals stay stale until then)`
296
- if ('ambiguous' in r) return `\`code\` selector \`${entry.path}#${sym}\` is ambiguous${r.ambiguous} units share that name; pin a unique one`
314
+ // the SAME classifier the gate uses ([[code-anchor]]'s resolveSelectors) — only the wording is ours, so a
315
+ // selector can never be verifiable to one reader and dead to the other.
316
+ for (const r of resolveSelectors(units, entry.selectors)) {
317
+ if ('dead' in r) return `\`code\` selector \`${entry.path}#${r.selector}\` names no unit in that file follow the rename or drop the selector (evals stay stale until then)`
318
+ if ('ambiguous' in r) return `\`code\` selector \`${entry.path}#${r.selector}\` is ambiguous — ${r.ambiguous} units share that name; pin a unique one`
297
319
  }
298
320
  return null
299
321
  }
300
322
 
323
+ // @@@ the verify sweep must reach the MACROTASK queue - resolving every reading's `code:` selectors against
324
+ // the working tree is a doubly-nested SYNCHRONOUS sweep (4,384 demands on this corpus), and nothing in it
325
+ // awaits, so it ran as one uninterruptible stretch: measured, it held the loop for 1,104ms, which is the
326
+ // `/health` p99 this gate is judged by. The bound defended here is a LIVENESS signal, not a latency taste —
327
+ // a probe that cannot answer is indistinguishable from a dead backend, and the CLI allows it 600ms while the
328
+ // supervisor allows 1,000ms before it keeps the old child. `setImmediate` is what actually returns to the
329
+ // loop's I/O phase (awaiting a synchronous-bodied async fn only drains microtasks). A time budget rather
330
+ // than every-iteration keeps the common cheap iteration from paying a turn: the sweep's own longest single
331
+ // step is one file parse, so the worst hold stays near budget + that step.
332
+ const VERIFY_YIELD_BUDGET_MS = Number(process.env.SPEXCODE_VERIFY_YIELD_BUDGET_MS || 50)
333
+ const yieldToEventLoop = (): Promise<void> => new Promise<void>((resolve) => { setImmediate(resolve) })
334
+
301
335
  export function anchorProbeFor(root: string, idx: DriftIndex): AnchorProbe {
302
336
  const regs = extractors(root)
303
337
  const verdicts = new Map<string, boolean>()
304
338
  return {
305
- async prime(sinceSha, entries) {
306
- for (const e of entries) {
339
+ async prime(demands) {
340
+ const keys: string[] = []
341
+ const queries: AnchorHitQuery[] = []
342
+ const queued = new Set<string>()
343
+ let sinceYield = Date.now()
344
+ for (const { sinceSha, entries } of demands) for (const e of entries) {
345
+ if (Date.now() - sinceYield >= VERIFY_YIELD_BUDGET_MS) { await yieldToEventLoop(); sinceYield = Date.now() }
307
346
  if (!e.selectors.length) continue
308
347
  const key = anchorKey(sinceSha, e.path, e.selectors)
309
- if (verdicts.has(key)) continue
348
+ if (verdicts.has(key) || queued.has(key)) continue
310
349
  if (entryUnverifiable(root, regs, e)) continue // no verdict → conservative stale (lint says why)
311
- const win = evalWindowCommits(idx, sinceSha, e.path)
350
+ const win = eventsSince(idx, sinceSha, e.path)
312
351
  if (win === null) continue
313
352
  if (!win.length) { verdicts.set(key, false); continue }
314
- const hits = await anchorHitCommits(root, win, [...e.selectors], regs)
315
- verdicts.set(key, hits.length > 0)
353
+ queued.add(key)
354
+ keys.push(key)
355
+ queries.push({ win, symbols: [...e.selectors] })
316
356
  }
357
+
358
+ if (!queries.length) return
359
+ const results = await anchorHitExists(root, queries, regs)
360
+ results.forEach((hit, index) => verdicts.set(keys[index], hit))
317
361
  },
318
362
  hit(sinceSha, path, selectors) {
319
363
  return verdicts.get(anchorKey(sinceSha, path, selectors)) ?? null
@@ -355,8 +399,10 @@ export function remarkStale(reading: { ts: string }, remarks: RemarkSignal[]): b
355
399
  // ContentProbe above); without a probe — or when the anchor object is gone — freshness can't be proven
356
400
  // from HEAD's history, so it reads stale rather than silently pass.
357
401
  export function changedSince(idx: DriftIndex, sinceSha: string, path: string, probe?: ContentProbe): boolean {
358
- const anc = ancestorsOf(idx, sinceSha)
359
- if (anc) return pathEvents(idx, path).some((event) => !inAncestors(idx, anc, event.commit))
402
+ const events = eventsSince(idx, sinceSha, path)
403
+ // null = ancestry cannot testify for this anchor; only then does content get a say ([[root-lru]]'s sibling
404
+ // rule: one meaning of changed-since, each layer's own fallback on top).
405
+ if (events) return events.length > 0
360
406
  return probe?.changed(sinceSha, path) ?? true
361
407
  }
362
408
 
@@ -366,16 +412,16 @@ export function changedSince(idx: DriftIndex, sinceSha: string, path: string, pr
366
412
  // an off-history sinceSha reports through the same content fallback (only files whose content differs, counted
367
413
  // by rev-list); with no probe or a gone anchor it counts every touch (conservative, matching changedSince).
368
414
  // Reporting only — it never decides freshness (staleAxes does); it explains a decision already made.
369
- export function codeDrift(idx: DriftIndex, sinceSha: string, codeAxis: string[], probe?: ContentProbe): { file: string; behind: number }[] {
415
+ export function codeDrift(idx: DriftIndex, sinceSha: string, codeAxis: ScenarioCodeAxisSource, probe?: ContentProbe): { file: string; behind: number }[] {
370
416
  // an entry may be anchored (`path#symbol`); drift is reported per BASE FILE — a raw selector string names
371
417
  // no real path, so counting commits against it would silently report nothing.
372
- const codeFiles = scenarioCodeAxis(codeAxis).paths
373
- const anc = ancestorsOf(idx, sinceSha)
418
+ const codeFiles = scenarioCodeAxis(undefined, codeAxis).paths
374
419
  const out: { file: string; behind: number }[] = []
375
420
  for (const f of codeFiles) {
421
+ const since = eventsSince(idx, sinceSha, f)
376
422
  const events = pathEvents(idx, f)
377
- const differs = anc ? undefined : probe?.changed(sinceSha, f)
378
- const behind = anc ? new Set(events.filter((event) => !inAncestors(idx, anc, event.commit)).map((event) => event.commit)).size
423
+ const differs = since ? undefined : probe?.changed(sinceSha, f)
424
+ const behind = since ? new Set(since.map((event) => event.commit)).size
379
425
  : differs === true ? probe!.behind(sinceSha, f)
380
426
  : differs === false ? 0
381
427
  : new Set(events.map((event) => event.commit)).size
@@ -428,7 +474,7 @@ function entryMoved(idx: DriftIndex, sinceSha: string, entry: RelationEntry, pro
428
474
 
429
475
  export function staleAxes(
430
476
  reading: Reading,
431
- codeAxis: string[],
477
+ codeAxis: ScenarioCodeAxisSource,
432
478
  evalPath: string,
433
479
  didx: DriftIndex,
434
480
  scIdx: ScenarioIndex,
@@ -445,7 +491,7 @@ export function staleAxes(
445
491
  axes.push('anchor')
446
492
  if (byHash) axes.push('scenario')
447
493
  } else {
448
- if (scenarioCodeAxis(codeAxis).entries.some((e) => entryMoved(didx, reading.codeSha, e, probe, anchors))) axes.push('code')
494
+ if (scenarioCodeAxis(undefined, codeAxis).entries.some((e) => entryMoved(didx, reading.codeSha, e, probe, anchors))) axes.push('code')
449
495
  if (byHash ?? scenarioMoved(scIdx, didx, reading.codeSha, evalPath, reading.scenario, probe)) axes.push('scenario')
450
496
  }
451
497
  if (remarkStale(reading, remarks)) axes.push('remark')
@@ -454,7 +500,7 @@ export function staleAxes(
454
500
 
455
501
  export function isStale(
456
502
  reading: Reading,
457
- codeAxis: string[],
503
+ codeAxis: ScenarioCodeAxisSource,
458
504
  evalPath: string,
459
505
  didx: DriftIndex,
460
506
  scIdx: ScenarioIndex,
@@ -4,36 +4,21 @@ import { commitTrunkData } from '../../spec-cli/src/localIssues.js'
4
4
  import { evalNodes, resolveEvalNode } from './scenarios.js'
5
5
  import { readReadings, readSidecar, appendHumanOk, humanOkFor, type HumanOk } from './sidecar.js'
6
6
 
7
- // @@@ human-ok - the human sign-off on an eval reading ([[human-ok]]). One write, both surfaces: the CLI
8
- // (`spex eval ok`) and the HTTP route (`POST /api/specs/:id/evals/ok`, identity server-derived 'human')
9
- // call THIS. The ok binds to ONE immutable reading — the scenario's latest effective reading at ok-time,
10
- // anchored by its (ts, codeSha) — and is MONOTONIC: no un-ok verb exists, because a newer reading is a
11
- // different object the ok never transfers to, and staleness is computed live; both bring the scenario back
12
- // on their own. Durability follows the checkout: on the trunk checkout the append is committed straight to
13
- // trunk (`--no-verify`, path-scoped, under the shared store lock — the [[local-issues]] discipline,
14
- // commitTrunkData); on a linked worktree the append stays in that tree and the session's own ritual commit
15
- // carries it, exactly like every other sidecar write.
16
7
  export type OkResult =
17
8
  | { ok: true; humanOk: HumanOk; already: boolean; landed: 'committed' | 'uncommitted' }
18
9
  | { ok: false; error: string }
19
10
 
20
11
  export function fileHumanOk(nodeId: string, scenario: string, by: string): OkResult {
21
12
  const root = repoRoot()
22
- // the same loud resolution every eval verb applies ([[eval-core]]): exact canonical id, else a unique
23
- // bare leaf; an ambiguous leaf returns the candidate list instead of blessing an arbitrary node.
24
13
  const res = resolveEvalNode(evalNodes(root), nodeId)
25
14
  if (!res.ok) return { ok: false, error: res.error }
26
15
  const node = res.node
27
16
  if (!node.scenarios.some((s) => s.name === scenario) &&
28
17
  !readSidecar(node.sidecarPath).readings.some((r) => r.scenario === scenario))
29
18
  return { ok: false, error: `'${node.id}' has no scenario '${scenario}'` }
30
- // the ok's one possible target: the latest EFFECTIVE reading — an ok is a judgment on a measurement that
31
- // exists and currently counts; an unmeasured (or fully-retracted) scenario has nothing to bless.
32
19
  const forScenario = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
33
20
  if (!forScenario.length) return { ok: false, error: `'${node.id}' scenario '${scenario}' has no effective eval — nothing to ok` }
34
21
  const latest = forScenario[forScenario.length - 1]
35
- // a duplicate ok is idempotent success (the store already IS the requested state — the local-issue
36
- // close's `already` semantics), never an error and never a second appended row.
37
22
  const existing = humanOkFor(readSidecar(node.sidecarPath).oks, scenario, latest.ts)
38
23
  if (existing) return { ok: true, humanOk: existing, already: true, landed: 'committed' }
39
24
  const row: HumanOk = { kind: 'human-ok', scenario, okTs: latest.ts, okSha: latest.codeSha, by, ts: new Date().toISOString() }
@@ -1,31 +1,12 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { git, gitA, gitTry, headSha } from '../../spec-cli/src/git.js'
3
3
  import { parseScenarios } from './scenarios.js'
4
-
5
- // @@@ per-scenario content freshness — the SCENARIO axis, sub-file
6
- // An eval.md holds many scenarios, but a reading measures ONE. eval-core's contract says "a scenario is the
7
- // unit of measurement, so its freshness is its OWN — two scenarios stale independently." Git has no sub-file
8
- // history, so we build it: for each scenario NAME in an eval.md, the commits where THAT scenario's SEMANTIC
9
- // block content (description + expected — see blockContent) changed (added / removed / edited),
10
- // rename-followed. `scenarioMoved` then reads exactly like the code axis's `changedSince` — a pure ancestry
11
- // lookup over this per-scenario commit list — so editing one scenario never re-stales its siblings (the
12
- // file-granular bug this replaces), and retagging one never re-stales itself.
4
+ import { rootSlots, touchRoot as touchRootLru } from '../../spec-cli/src/root-lru.js'
13
5
 
14
6
  const RS = '\x1e'
15
7
 
16
- // evalPath (head path) -> scenario name -> commit hashes that changed that scenario's block (newest-first)
17
8
  export type ScenarioIndex = Map<string, Map<string, string[]>>
18
9
 
19
- // the block content that stales a reading: the scenario's SEMANTIC fields only — description (what to
20
- // measure) + expected (what zero loss looks like), the contract a measurement is taken AGAINST. The name is
21
- // the join key (a renamed scenario is a remove+add, surfaced as a change-commit on each name), and the
22
- // metadata fields are OUT: tags route a scenario to a measuring hand, test/code/related point at files —
23
- // none of them changes what an already-taken reading proved, so a tags sweep or a coverage retune must not
24
- // re-stale every reading in the tree. Both freshness paths (the in-history change-commits and the
25
- // off-history scenarioDiffers probe) read THIS one projection — narrowing it here narrows both at once, and
26
- // because freshness is derived live from git, historical metadata-only commits stop registering as scenario
27
- // changes with no migration. parseScenarios already folds YAML block scalars, so a pure prose re-wrap
28
- // yields the same string and does NOT stale.
29
10
  function blockContent(src: string): Map<string, string> {
30
11
  const m = new Map<string, string>()
31
12
  for (const s of parseScenarios(src)) {
@@ -34,30 +15,14 @@ function blockContent(src: string): Map<string, string> {
34
15
  return m
35
16
  }
36
17
 
37
- const ZERO = '0'.repeat(40) // the null OID a delete/add carries on its absent side
18
+ const ZERO = '0'.repeat(40)
38
19
  const EMPTY: Map<string, string> = new Map()
39
20
 
40
- // content-addressed: a blob OID -> its canonical per-scenario block map. Git objects are IMMUTABLE, so this
41
- // memo never needs invalidation and is shared across every root — a per-worktree build reuses the blocks main
42
- // already parsed, which is what lets a worktree's freshness build stay cheap instead of re-reading everything.
43
21
  const blockByOid = new Map<string, Map<string, string>>()
44
22
 
45
- // ONE `git log --raw` walk over all eval.md → per head-path version chain [{hash, oid}], newest-first,
46
- // rename-followed via the `alias` idiom (git.ts buildIndex): the newest sighting of a path IS its head path;
47
- // an `R` row remaps the older `from` path onto that head. The raw row carries the new blob OID directly, so
48
- // no per-version path resolution and no `git show`. --full-history is REQUIRED: default pathspec
49
- // history-simplification prunes commits off HEAD's first-parent chain, but in this repo every spec edit lands
50
- // via a --no-ff merge of a node branch, so those pruned commits ARE the scenario edits — dropping them would
51
- // under-report staleness (a stale reading judged fresh). Merge commits emit no raw diff row, so they add no
52
- // version (exactly as the old `--follow` did).
53
23
  async function fileChains(root: string, wanted: Set<string>): Promise<Map<string, { hash: string; oid: string }[]>> {
54
24
  const chains = new Map<string, { hash: string; oid: string }[]>()
55
25
  const alias = new Map<string, string>()
56
- // DUAL pathspec — the live name AND the archived one ([[eval-core]]): this walk reads IMMUTABLE history,
57
- // and pre-rename commits touched files literally named yatsu.md, so an archive answers only to its
58
- // archive name. A single '*eval.md' pathspec truncates every chain at the yatsu.md→eval.md rename commit,
59
- // making the rename read as each scenario's birth — a change-commit newer than every pre-rename reading's
60
- // codeSha — and false-stales whole adopter corpora (the scenariofresh.test.ts regression pins this).
61
26
  const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log',
62
27
  '--raw', '--no-abbrev', '--full-history', '-M', `--format=${RS}%H`, '--', '*eval.md', '*yatsu.md']) // dead-words-ok: archived pathspec — immutable pre-rename history is read under its archived name
63
28
  for (const rec of out.split(RS)) {
@@ -72,22 +37,18 @@ async function fileChains(root: string, wanted: Set<string>): Promise<Map<string
72
37
  const meta = line.slice(1, tab).split(' ')
73
38
  const oid = meta[3], rename = meta[4][0] === 'R' || meta[4][0] === 'C'
74
39
  const paths = line.slice(tab + 1).split('\t')
75
- const to = rename ? paths[1] : paths[0] // the path on the newer side of this commit
40
+ const to = rename ? paths[1] : paths[0]
76
41
  let head = alias.get(to)
77
42
  if (head === undefined) { head = to; alias.set(to, to) }
78
43
  let arr = chains.get(head); if (!arr) { arr = []; chains.set(head, arr) }
79
44
  arr.push({ hash, oid })
80
- if (rename && paths[0] !== to) { alias.set(paths[0], head); alias.delete(to) } // older history calls it `from`
45
+ if (rename && paths[0] !== to) { alias.set(paths[0], head); alias.delete(to) }
81
46
  }
82
47
  }
83
- for (const k of [...chains.keys()]) if (!wanted.has(k)) chains.delete(k) // keep only the head paths asked for
48
+ for (const k of [...chains.keys()]) if (!wanted.has(k)) chains.delete(k)
84
49
  return chains
85
50
  }
86
51
 
87
- // per-scenario change-commits for one file's version chain: newest->oldest, attribute a commit to every
88
- // scenario whose canonical block differs from the next-older version (undefined either side = add/remove = a
89
- // change). A ZERO-oid (delete) version is dropped — it carries no readable content, matching the old
90
- // `git show ''` skip; a pure rename (R100, oid == the older version's oid) diffs to no change for free.
91
52
  function scenarioCommits(chain: { hash: string; oid: string }[]): Map<string, string[]> {
92
53
  const commits = new Map<string, string[]>()
93
54
  const push = (name: string, hash: string) => { const a = commits.get(name); if (a) a.push(hash); else commits.set(name, [hash]) }
@@ -142,8 +103,6 @@ function catFileBatch(root: string, oids: string[]): Promise<Map<string, string>
142
103
  })
143
104
  }
144
105
 
145
- // TWO git subprocesses for the whole index (was F logs + V shows): one `--raw` log for the rename-followed
146
- // chains, one `cat-file --batch` for every distinct blob not already memoized. Then a pure in-memory diff.
147
106
  async function build(root: string, evalPaths: string[]): Promise<ScenarioIndex> {
148
107
  const chains = await fileChains(root, new Set(evalPaths))
149
108
  const need = new Set<string>()
@@ -153,35 +112,16 @@ async function build(root: string, evalPaths: string[]): Promise<ScenarioIndex>
153
112
  for (const [oid, src] of blobs) blockByOid.set(oid, blockContent(src))
154
113
  }
155
114
  const idx: ScenarioIndex = new Map()
156
- for (const p of evalPaths) idx.set(p, scenarioCommits(chains.get(p) ?? [])) // every asked path gets an entry (empty if untracked)
115
+ for (const p of evalPaths) idx.set(p, scenarioCommits(chains.get(p) ?? []))
157
116
  return idx
158
117
  }
159
118
 
160
- // HEAD-keyed LRU, mirroring historyIndex/driftIndex in git.ts: same head ⇒ same per-scenario history,
161
- // whatever the caller. Holds the in-flight promise so concurrent board builds share one build.
162
- const SLOTS = 16
119
+ const SLOTS = rootSlots(process.env.SPEXCODE_SCENARIO_CACHE_ROOTS, 16)
163
120
  const cache = new Map<string, Promise<ScenarioIndex>>()
164
121
  const roots = new Map<string, string>()
165
122
 
166
- // A checkout moving from HEAD A to HEAD B no longer needs A's full scenario chains. Keep an old index only
167
- // while another checkout still points at that immutable HEAD; otherwise a sequence of successful rebuilds
168
- // retains one whole history-shaped index per commit until the broad LRU fills.
169
123
  function touchRoot(root: string, head: string): void {
170
- const previous = roots.get(root)
171
- if (previous === head) {
172
- roots.delete(root)
173
- roots.set(root, head)
174
- return
175
- }
176
- roots.set(root, head)
177
- if (previous && ![...roots.values()].includes(previous)) cache.delete(previous)
178
- while (roots.size > SLOTS) {
179
- const oldest = roots.keys().next().value as string | undefined
180
- if (oldest === undefined) break
181
- const oldHead = roots.get(oldest)
182
- roots.delete(oldest)
183
- if (oldHead && ![...roots.values()].includes(oldHead)) cache.delete(oldHead)
184
- }
124
+ touchRootLru(roots, cache, root, head, SLOTS)
185
125
  }
186
126
 
187
127
  export function scenarioIndex(root: string, evalPaths: string[]): Promise<ScenarioIndex> {
@@ -205,13 +145,6 @@ export function scenarioChangeCommits(idx: ScenarioIndex, evalPath: string, scen
205
145
  return idx.get(evalPath)?.get(scenario) ?? []
206
146
  }
207
147
 
208
- // (root, sha, path) -> the blob oid at that commit ('' = unresolvable: path absent there, or the commit
209
- // object gone). A FULL sha names an immutable tree, so entries never invalidate (freshness.ts's diffMemo
210
- // reasoning); the LRU only bounds memory, sized above the largest adopter reading corpus — one entry per
211
- // (reading, path) worst case — so a repeat board build never thrashes back into forking. This memo is what
212
- // keeps a fully off-history corpus (an adopter history rewrite) cheap on REPEAT builds: without it every
213
- // scenarioDiffers call re-forked `git rev-parse` for BOTH sides, per reading, per build (spexcode#39). A
214
- // symbolic rev (HEAD, a branch) moves, so it resolves live and is never cached.
215
148
  const FULL_SHA = /^[0-9a-f]{40}$/
216
149
  const oidMemo = new Map<string, string>()
217
150
  function oidAt(root: string, rev: string, path: string): string {
@@ -248,11 +181,6 @@ export async function primeScenarioBlocksAt(root: string, revs: string[], path:
248
181
  }
249
182
  }
250
183
 
251
- // canonical per-scenario SEMANTIC blocks of `rev:path` (the blockContent projection), for the off-history
252
- // content fallback ([[eval-core]]'s ContentProbe): resolve the blob oid first (memoized above for a full
253
- // sha) — oids are content-addressed, so an unchanged file usually hits blockByOid straight from the index
254
- // build — and parse only on a genuine miss. null = the path is unreadable at that rev (absent, renamed
255
- // since, or the rev itself is gone).
256
184
  export function scenarioBlocksAt(root: string, rev: string, path: string): Map<string, string> | null {
257
185
  const oid = oidAt(root, rev, path)
258
186
  if (!oid) return null
@@ -24,10 +24,6 @@ export type Scenario = {
24
24
  related?: string[]
25
25
  }
26
26
 
27
- // The scenario index is a declaration projection, not a reading view. Keep its three identity layers
28
- // explicit: scenarioHash is the measurement-contract hash, semanticIndexHash is the canonical declaration
29
- // index, and fullIndexHash adds only the measuring-hand test mapping. Git provenance is supplied by the CLI
30
- // outside both hashes, so a mode/type change cannot masquerade as a scenario-content change.
31
27
  export const SCENARIO_PROJECTION = 'spex.eval.scenario-index'
32
28
  export const SCENARIO_SCHEMA_VERSION = 1
33
29
  export type ScenarioSemanticRow = {
@@ -56,13 +52,11 @@ export type ScenarioProjection = {
56
52
  }
57
53
 
58
54
  export type EvalNode = {
59
- id: string // the node's CANONICAL spec id (leaf name, '_'-disambiguated on a leaf collision)
60
- dir: string // absolute node directory
61
- evalPath: string // repo-relative path to eval.md — the SCENARIO freshness axis
62
- sidecarPath: string // absolute path to evals.ndjson
55
+ id: string
56
+ dir: string
57
+ evalPath: string
58
+ sidecarPath: string
63
59
  scenarios: Scenario[]
64
- // The exact declaration bytes used to build this node, present for real filesystem and fixed-tree walks.
65
- // Synthetic EvalNode values in narrow unit tests may omit it.
66
60
  evalSource?: string
67
61
  }
68
62
 
@@ -81,8 +75,6 @@ type RawTestObject = {
81
75
 
82
76
  type RawFieldLocation = { startLine: number; endLine: number; indent: string }
83
77
 
84
- // a raw scenario item straight off the frontmatter walk: the known fields it set, plus any UNKNOWN keys it
85
- // carried — kept (not dropped) so the validator can name a typo'd field instead of silently swallowing it.
86
78
  type RawItem = {
87
79
  fields: Partial<Record<ScenarioKey, string>>
88
80
  testObject?: RawTestObject
@@ -95,7 +87,7 @@ type RawItem = {
95
87
 
96
88
  const leadingIndent = (line: string): string => line.match(/^[ \t]*/)?.[0] ?? ''
97
89
 
98
- // 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"
90
+ // Keep parsing and validation on the same structural walk.
99
91
  function walkScenarios(src: string): { hasFrontmatter: boolean; hasKey: boolean; items: RawItem[]; malformed: string[] } {
100
92
  const normalized = src.replace(/\r\n?/g, '\n')
101
93
  const m = normalized.match(/^---\n([\s\S]*?)\n---(?:\n|$)/)
@@ -120,7 +112,6 @@ function walkScenarios(src: string): { hasFrontmatter: boolean; hasKey: boolean;
120
112
  const trimmed = line.trim()
121
113
  const dash = trimmed.startsWith('- ') || trimmed === '-'
122
114
  if (dash && (itemIndent < 0 || indent <= itemIndent)) {
123
- // a new scenario item. start fresh; the `- ` may carry the first field inline.
124
115
  cur = {
125
116
  fields: {}, unknownKeys: [], duplicateKeys: [], malformed: [], locations: {},
126
117
  ...(trimmed.slice(1).trim() ? { fieldIndent: `${prefix} ` } : {}),
@@ -146,11 +137,6 @@ function walkScenarios(src: string): { hasFrontmatter: boolean; hasKey: boolean;
146
137
  return { hasFrontmatter: true, hasKey: true, items, malformed }
147
138
  }
148
139
 
149
- // assign a `key: value` field to the current item. When the value is a block-scalar indicator (`|`
150
- // literal / `>` folded), consume the following more-indented lines as the value and return the index of
151
- // the LAST consumed line (the for-loop's ++ then moves past it); otherwise return `idx` unchanged. A key
152
- // outside the schema is recorded under unknownKeys (still consuming its block, so the body isn't misread as
153
- // new items) rather than dropped — validateScenarios needs to see it to reject the typo.
154
140
  function assignField(cur: RawItem, kv: string, lines: string[], idx: number, keyIndent: number, inline = false): number {
155
141
  const f = kv.match(/^([A-Za-z_][\w-]*):\s*(.*)$/)
156
142
  if (!f) {
@@ -200,7 +186,6 @@ function assignField(cur: RawItem, kv: string, lines: string[], idx: number, key
200
186
  return finish(idx)
201
187
  }
202
188
  }
203
- // a list field (`code:`/`related:`) may be a YAML block sequence (`- item` lines); the scalar reader can't see those, so collect them here into the comma form parseCodeList expects
204
189
  if ((LIST_KEYS as readonly string[]).includes(key) && f[2].trim() === '') {
205
190
  const items: string[] = []
206
191
  let lastItem = idx
@@ -234,8 +219,7 @@ function assignField(cur: RawItem, kv: string, lines: string[], idx: number, key
234
219
  const tabInIndent = tabBeforeContent && spaces.length < requiredIndent
235
220
  if (tabInIndent) cur.malformed.push(`tab indentation is not valid in block scalar \`${key}\``)
236
221
  if (!l.trim() && !tabBeforeContent) { body.push(''); continue }
237
- // YAML indentation is spaces-only. Once the line is deeper than its key, a leading TAB belongs to the
238
- // scalar content and must survive parsing; before the block's required depth it is illegal indentation.
222
+ // A tab after the required indent is scalar content, not indentation.
239
223
  const ind = tabInIndent ? leadingIndent(l).length : spaces.length
240
224
  if (ind <= keyIndent) break // dedented to a sibling field / next item → the block is done
241
225
  if (base < 0) base = ind
@@ -275,8 +259,7 @@ function assignTestField(obj: RawTestObject, entry: string): void {
275
259
  obj.fields[key as TestKey] = testValue(f[2], key === 'name')
276
260
  }
277
261
 
278
- // A flow mapping is accepted beside the more readable block form. Split only on commas outside quotes so
279
- // an opaque case name such as "allows admin, user" survives byte-for-byte after YAML quote removal.
262
+ // Test names may contain commas inside quotes.
280
263
  function parseFlowTestObject(raw: string): RawTestObject {
281
264
  const obj = emptyTestObject()
282
265
  if (!raw.startsWith('{') || !raw.endsWith('}')) {
@@ -317,36 +300,23 @@ function normalizedTest(it: RawItem): ScenarioTestReference | undefined {
317
300
  return path ? { path } : undefined
318
301
  }
319
302
 
320
- // @@@scenario contract hash - the deterministic content hash of a scenario's SEMANTIC text, stamped on each
321
- // reading at filing time ([[eval-core]]'s scenario freshness axis). Hashes ONLY the measurement contract —
322
- // description (what to measure) + expected (what zero loss looks like) — never name/tags/test/code/related.
323
- // Normalization (spec'd, don't drift): each field independently collapses every whitespace run (space, tab,
324
- // CR, LF) to a single space and trims its ends, so a prose re-wrap, an indent shift, CRLF churn or trailing
325
- // whitespace never changes the hash; the two normalized fields join with a single '\n' (neither can contain
326
- // one after normalization, so the join is unambiguous) and sha256-hex the UTF-8 bytes. The hash is pure text
327
- // over the parsed declaration — no git, no file position — so it is identical wherever and however the same
328
- // contract text is read.
329
303
  const normSemantic = (s: string) => s.replace(/\s+/g, ' ').trim()
330
304
  export function scenarioHash(s: Pick<Scenario, 'description' | 'expected'>): string {
331
305
  return createHash('sha256').update(`${normSemantic(s.description)}\n${normSemantic(s.expected)}`, 'utf8').digest('hex')
332
306
  }
333
307
 
334
- // @@@scenario code axis - the ONE resolution of a scenario's code freshness axis, so a declaration can never
335
- // mean two things to two consumers. A scenario's own `code:` narrows the node's list; absent, it inherits it
336
- // whole ([[eval-core]]). Each entry may carry [[code-anchor]]'s `path#symbol` selectors, folded per base file
337
- // by the SAME structural parser spec relations use (several selectors on one file OR together; duplicates,
338
- // bare+scoped mixing and a selector on a glob come back as `problems` for lint to report).
339
- // `paths` is what every PATH consumer must read — changed-scan selection, session impact, drift display,
340
- // the ghost-path check — because a raw `path#symbol` string matches no real file and would silently drop the
341
- // scenario out of those sets instead of narrowing it. `entries` is what the freshness code axis narrows with.
342
308
  export type ScenarioCodeAxis = { entries: RelationEntry[]; paths: string[]; problems: string[] }
343
- export function scenarioCodeAxis(scenarioCode: readonly string[] | undefined, nodeCode: readonly string[] = []): ScenarioCodeAxis {
344
- const { entries, problems } = parseRelation([...(scenarioCode?.length ? scenarioCode : nodeCode)], 'code')
309
+ export type ScenarioCodeAxisSource = readonly string[] | readonly RelationEntry[]
310
+ export function scenarioCodeAxis(scenarioCode: readonly string[] | undefined, nodeCode: ScenarioCodeAxisSource = []): ScenarioCodeAxis {
311
+ const parsed = scenarioCode?.length
312
+ ? parseRelation([...scenarioCode], 'code')
313
+ : nodeCode.length && typeof nodeCode[0] !== 'string'
314
+ ? { entries: (nodeCode as readonly RelationEntry[]).map((e) => ({ path: e.path, selectors: [...e.selectors] })), problems: [] }
315
+ : parseRelation([...(nodeCode as readonly string[])], 'code')
316
+ const { entries, problems } = parsed
345
317
  return { entries, paths: entries.map((e) => e.path), problems }
346
318
  }
347
319
 
348
- // a scenario's optional list field (`code:`/`related:`) is a comma-separated path list (a YAML flow list
349
- // `[a, b]` or bare `a, b`, or a single path) — the tiny parser stays scalar-only, so it is split here.
350
320
  function parseCodeList(raw: string): string[] {
351
321
  return raw.replace(/^\[|\]$/g, '').split(',').map((s) => unquote(s.trim())).filter(Boolean)
352
322
  }
@@ -368,7 +338,7 @@ export function parseScenarios(src: string): Scenario[] {
368
338
  ...(related.length ? { related } : {}),
369
339
  }
370
340
  })
371
- .filter((s) => s.name) // a scenario with no name is malformed — drop it (validateScenarios reports it)
341
+ .filter((s) => s.name)
372
342
  }
373
343
 
374
344
  const compareStable = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0
@@ -380,8 +350,6 @@ const hashProjection = (value: unknown): string =>
380
350
 
381
351
  const semanticOnly = (row: ScenarioProjectionRow): ScenarioSemanticRow => row.semantic
382
352
 
383
- // Build one canonical declaration index from the already-parsed scenarios. No eval sidecar is touched here;
384
- // callers can safely use this projection to compare fixed trees without importing the reading/freshness path.
385
353
  export function scenarioProjection(
386
354
  nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource'>[],
387
355
  provenance: Partial<ScenarioProjectionProvenance> = {},
@@ -426,10 +394,6 @@ export function scenarioProjection(
426
394
  }
427
395
  }
428
396
 
429
- // `tagLibrary` is the closed vocabulary a scenario's `tags:` must draw from (config's `lint.scenarioTags`).
430
- // Every scenario needs ≥1 tag; each tag must be IN the library — an out-of-library tag is rejected LOUD with
431
- // the repair the user owns: pick an existing tag, or extend the library. An empty library (none configured)
432
- // disables only the membership check, never the ≥1-tag requirement.
433
397
  export function validateScenarios(src: string, tagLibrary: string[] = [], pathRoot?: string): string[] {
434
398
  const { hasFrontmatter, hasKey, items, malformed } = walkScenarios(src)
435
399
  if (!hasFrontmatter) return ['no frontmatter block — an eval.md must declare a `scenarios:` list']
@@ -528,9 +492,6 @@ function malformedDeclaration(errors: string[]): Error {
528
492
  return new Error(`malformed eval.md:\n${errors.map((error) => ` - ${error}`).join('\n')}`)
529
493
  }
530
494
 
531
- // Canonical write half of the declaration identity. The caller supplies authoritative bytes and one closed
532
- // semantic mutation; source locations come only from the same structural walk parseScenarios/validation use.
533
- // Untouched lines are never serialized, which is what makes insert -> delete a byte-exact inverse.
534
495
  export function writeScenarioMeasurementMetadata(source: string, request: unknown): string {
535
496
  const mutation = parseMetadataMutation(request)
536
497
  const beforeErrors = validateScenarios(source)
@@ -582,12 +543,6 @@ export function writeScenarioMeasurementMetadata(source: string, request: unknow
582
543
  return proposed
583
544
  }
584
545
 
585
- // walk `.spec` for every dir holding an eval.md; the node id is its CANONICAL spec id ([[id-url-safe]]) —
586
- // minted by the SAME rule as specs.ts's loader (mintIds: the leaf dir name, or on a leaf collision the
587
- // shortest globally-unique '_'-joined trailing suffix) over the SAME universe (every dir holding a spec.md,
588
- // not just the eval subset — a leaf that collides among spec nodes is disambiguated even when only one of
589
- // them measures). So the id eval verbs answer to is exactly the id board/scan/search already print — never a
590
- // second, diverging bare-leaf scheme. An eval.md beside no spec.md keeps its leaf name (no spec id to align with).
591
546
  function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string }[]): EvalNode[] {
592
547
  const specBase = join(root, '.spec')
593
548
  const ids = mintIds(specDirs.map((d) => relative(specBase, d).split(/[/\\]/)))
@@ -620,8 +575,6 @@ export function evalNodes(root: string): EvalNode[] {
620
575
  return assembleNodes(root, specDirs, hits)
621
576
  }
622
577
 
623
- // Exact fixed-tree twin for the canonical JSON seam. It reads only eval.md/spec.md bytes from `tip`, then
624
- // reuses assembleNodes/parseScenarios; no working-tree declaration can be paired with that tree's provenance.
625
578
  export function evalNodesAt(root: string, tip: string): EvalNode[] {
626
579
  const files = treeTextFiles(root, tip, '.spec')
627
580
  const paths = [...files.keys()]
@@ -659,10 +612,6 @@ export async function evalNodesAsync(root: string): Promise<EvalNode[]> {
659
612
 
660
613
  export type EvalResolution<T> = { ok: true; node: T } | { ok: false; ambiguous: boolean; error: string }
661
614
 
662
- // resolve a user-supplied node ref against the measurable set: an EXACT canonical id always wins; a bare leaf
663
- // name stays the convenience it always was while it names exactly ONE measurable node; a leaf several nodes
664
- // share fails LOUD listing the candidate canonical ids — never an arbitrary first hit, so a reading can
665
- // only land on the node the caller actually named.
666
615
  export function resolveEvalNode<T extends Pick<EvalNode, 'id' | 'dir'>>(nodes: T[], ref: string): EvalResolution<T> {
667
616
  const exact = nodes.find((n) => n.id === ref)
668
617
  if (exact) return { ok: true, node: exact }