spexcode 0.5.7 → 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 +199 -60
  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 +42 -30
  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
@@ -36,7 +36,7 @@ import {
36
36
  type RelationEntry,
37
37
  type Unit,
38
38
  } from '../../spec-cli/src/anchors.js'
39
- import { evalTimeline, evalContext, readBlobByHash, type EvalEntry, type EvalTimeline, type ScenarioInfo } from './evaltab.js'
39
+ import { evalTimelines, evalContext, readBlobByHash, type EvalEntry, type EvalTimeline, type ScenarioInfo } from './evaltab.js'
40
40
  import { isUiPath } from './cli.js'
41
41
  import { readReadings } from './sidecar.js'
42
42
  import { parseScenarios, scenarioCodeAxis, scenarioHash, type Scenario } from './scenarios.js'
@@ -101,7 +101,7 @@ export type SessionImpactOverlay = {
101
101
  }
102
102
 
103
103
  export type SessionImpactSpecSnapshot = Pick<LoadedSpec,
104
- 'id' | 'path' | 'code' | 'codeScoped' | 'related' | 'relatedScoped' | 'relationProblems'>
104
+ 'id' | 'path' | 'code' | 'codeEntries' | 'codeScoped' | 'related' | 'relatedEntries' | 'relatedScoped' | 'relationProblems'>
105
105
 
106
106
  export class SessionEvalUnavailableError extends Error {
107
107
  override name = 'SessionEvalUnavailableError'
@@ -166,12 +166,12 @@ async function revisionFile(root: string, revision: string, path: string): Promi
166
166
  return impactGit(root, ['show', `${revision}:${path}`], `read ${revision}:${path}`)
167
167
  }
168
168
 
169
- function loadedRelationRows(spec: SessionImpactSpecSnapshot | undefined, relation: 'code' | 'related'): string[] {
169
+ // the snapshot already carries parsed entries; a caller wanting one relation just picks it. This used to mint
170
+ // `path#selector` STRINGS from the split path/scoped pair and hand them back to the parser to be turned into
171
+ // the entries the loader had all along — a serialize/reparse round-trip through a form nobody stored.
172
+ function loadedRelationEntries(spec: SessionImpactSpecSnapshot | undefined, relation: 'code' | 'related'): readonly RelationEntry[] {
170
173
  if (!spec) return []
171
- const paths = relation === 'code' ? spec.code : spec.related
172
- const scoped = relation === 'code' ? spec.codeScoped : spec.relatedScoped
173
- const selectors = new Map(scoped.map((entry) => [entry.path, entry.selectors]))
174
- return paths.flatMap((path) => selectors.get(path)?.map((selector) => `${path}#${selector}`) ?? [path])
174
+ return relation === 'code' ? spec.codeEntries : spec.relatedEntries
175
175
  }
176
176
 
177
177
  function scenarioMetadata(scenario: Scenario, effectiveCode: readonly RelationEntry[]): string {
@@ -265,8 +265,8 @@ function selectorEntriesForSnapshot(
265
265
  ): RelationEntry[] {
266
266
  const entries: RelationEntry[] = []
267
267
  for (const spec of specs) {
268
- entries.push(...parsedRelation(loadedRelationRows(spec, 'code'), 'code', `node '${spec.id}'`))
269
- entries.push(...parsedRelation(loadedRelationRows(spec, 'related'), 'related', `node '${spec.id}'`))
268
+ entries.push(...loadedRelationEntries(spec, 'code'))
269
+ entries.push(...loadedRelationEntries(spec, 'related'))
270
270
  for (const scenario of scenariosById.get(spec.id) ?? []) {
271
271
  entries.push(...parsedRelation(scenario.code ?? [], 'code', `scenario '${spec.id} · ${scenario.name}'`))
272
272
  entries.push(...parsedRelation(scenario.related ?? [], 'related', `scenario '${spec.id} · ${scenario.name}'`))
@@ -277,20 +277,20 @@ function selectorEntriesForSnapshot(
277
277
  return [...unique.values()]
278
278
  }
279
279
 
280
- function primeSelectorSources(
280
+ async function primeSelectorSources(
281
281
  context: ImpactReadContext,
282
282
  revision: string,
283
283
  paths: readonly string[],
284
284
  sourceView: 'base' | 'head',
285
- ): void {
285
+ ): Promise<void> {
286
286
  const exactPaths = [...new Set(paths)].filter((path) => (
287
287
  !context.sources.has(`${revision}\0${path}`)
288
288
  && !(sourceView === 'head' && context.overlay && Object.hasOwn(context.overlay.files, path))
289
289
  )).sort()
290
290
  if (!exactPaths.length) return
291
291
  try {
292
- const oids = batchRevisionOids(context.root, exactPaths.map((path) => `${revision}:${path}`))
293
- const blobs = batchBlobTexts(context.root, oids.filter((oid): oid is string => !!oid))
292
+ const oids = await batchRevisionOids(context.root, exactPaths.map((path) => `${revision}:${path}`))
293
+ const blobs = await batchBlobTexts(context.root, oids.filter((oid): oid is string => !!oid))
294
294
  for (let index = 0; index < exactPaths.length; index++) {
295
295
  const path = exactPaths[index]
296
296
  const oid = oids[index]
@@ -497,8 +497,8 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
497
497
  const headScenariosById = new Map(headSpecs.map((spec) => [spec.id, scenariosAt('head', spec)]))
498
498
  const baseSelectorEntries = selectorEntriesForSnapshot(baseSpecs, baseScenariosById)
499
499
  const headSelectorEntries = selectorEntriesForSnapshot(headSpecs, headScenariosById)
500
- primeSelectorSources(context, base, baseSelectorEntries.map((entry) => entry.path), 'base')
501
- primeSelectorSources(context, head, headSelectorEntries.map((entry) => entry.path), 'head')
500
+ await primeSelectorSources(context, base, baseSelectorEntries.map((entry) => entry.path), 'base')
501
+ await primeSelectorSources(context, head, headSelectorEntries.map((entry) => entry.path), 'head')
502
502
  await Promise.all([
503
503
  ...baseSelectorEntries.map((entry) => validateSelectorEntry(context, base, entry, 'base')),
504
504
  ...headSelectorEntries.map((entry) => validateSelectorEntry(context, head, entry, 'head')),
@@ -529,23 +529,18 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
529
529
  const evalChanged = [...changedPaths].filter((path) => evalPaths.some((claim) => codeClaims([claim], path)))
530
530
  if (evalChanged.length) pushNodeCause(causes, 'eval', evalChanged)
531
531
 
532
- const baseNodeCodeRows = loadedRelationRows(baseSpec, 'code')
533
- const headNodeCodeRows = loadedRelationRows(headSpec, 'code')
534
- const baseNodeCode = scenarioCodeAxis(undefined, baseNodeCodeRows)
535
- const headNodeCode = scenarioCodeAxis(undefined, headNodeCodeRows)
536
- if (baseNodeCode.problems.length || headNodeCode.problems.length) {
537
- throw new SessionImpactUnavailableError(`session impact node '${id}' has invalid code: ${[
538
- ...baseNodeCode.problems, ...headNodeCode.problems,
539
- ].join('; ')}`)
540
- }
532
+ // no second validity gate here: relationProblems above already threw for either side, and it is the
533
+ // SAME parse a re-parse of rows minted from those entries cannot surface a problem the loader did not.
534
+ const baseNodeCodeEntries = loadedRelationEntries(baseSpec, 'code')
535
+ const headNodeCodeEntries = loadedRelationEntries(headSpec, 'code')
541
536
  const nodeCodeMoved = !!baseSpec && !!headSpec
542
- && JSON.stringify(baseNodeCode.entries) !== JSON.stringify(headNodeCode.entries)
537
+ && JSON.stringify(baseNodeCodeEntries) !== JSON.stringify(headNodeCodeEntries)
543
538
  const nodeCodeReads = nodeCodeMoved
544
539
  ? await Promise.all([
545
- impactForEntries(context, baseNodeCode.entries, { validateUnchanged: true, validationRevision: base, validationSide: 'base' }),
546
- impactForEntries(context, headNodeCode.entries, { validateUnchanged: true, validationRevision: head, validationSide: 'head' }),
540
+ impactForEntries(context, baseNodeCodeEntries, { validateUnchanged: true, validationRevision: base, validationSide: 'base' }),
541
+ impactForEntries(context, headNodeCodeEntries, { validateUnchanged: true, validationRevision: head, validationSide: 'head' }),
547
542
  ])
548
- : [await impactForEntries(context, (headSpec ? headNodeCode : baseNodeCode).entries, {
543
+ : [await impactForEntries(context, headSpec ? headNodeCodeEntries : baseNodeCodeEntries, {
549
544
  validateUnchanged: !baseSpec || !headSpec,
550
545
  validationRevision: headSpec ? head : base,
551
546
  validationSide: headSpec ? 'head' : 'base',
@@ -560,7 +555,7 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
560
555
  // parsing one synthetic concatenated relation would turn two legitimate owners naming the same path into
561
556
  // a fake duplicate/mixed-form schema error.
562
557
  const relatedFor = (spec: SessionImpactSpecSnapshot | undefined, scenarios: readonly Scenario[]) => [
563
- ...parsedRelation(loadedRelationRows(spec, 'related'), 'related', `node '${id}'`),
558
+ ...loadedRelationEntries(spec, 'related'),
564
559
  ...scenarios.flatMap((scenario) => (
565
560
  parsedRelation(scenario.related ?? [], 'related', `scenario '${id} · ${scenario.name}'`)
566
561
  )),
@@ -597,8 +592,8 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
597
592
  const baseScenarioHash = before ? scenarioHash(before) : null
598
593
  const headScenarioHash = after ? scenarioHash(after) : null
599
594
  const semantic = !before || !after || baseScenarioHash !== headScenarioHash
600
- const baseAxis = before ? scenarioCodeAxis(before.code, loadedRelationRows(baseSpec, 'code')) : { entries: [], paths: [], problems: [] }
601
- const headAxis = after ? scenarioCodeAxis(after.code, loadedRelationRows(headSpec, 'code')) : { entries: [], paths: [], problems: [] }
595
+ const baseAxis = before ? scenarioCodeAxis(before.code, loadedRelationEntries(baseSpec, 'code')) : { entries: [], paths: [], problems: [] }
596
+ const headAxis = after ? scenarioCodeAxis(after.code, loadedRelationEntries(headSpec, 'code')) : { entries: [], paths: [], problems: [] }
602
597
  if (baseAxis.problems.length || headAxis.problems.length) {
603
598
  throw new SessionImpactUnavailableError(`session impact scenario '${id} · ${name}' has invalid code: ${[
604
599
  ...baseAxis.problems, ...headAxis.problems,
@@ -1107,8 +1102,10 @@ export async function sessionImpactOverlay(
1107
1102
  id: spec.id,
1108
1103
  path: spec.path,
1109
1104
  code: spec.code,
1105
+ codeEntries: spec.codeEntries,
1110
1106
  codeScoped: spec.codeScoped,
1111
1107
  related: spec.related,
1108
+ relatedEntries: spec.relatedEntries,
1112
1109
  relatedScoped: spec.relatedScoped,
1113
1110
  relationProblems: spec.relationProblems,
1114
1111
  }))
@@ -1458,6 +1455,14 @@ async function sessionScopeNodes(
1458
1455
  const specById = new Map(ctx.specs.map((spec) => [spec.id, spec]))
1459
1456
  const nodes: SessionEvalNode[] = []
1460
1457
 
1458
+ // @@@one prime pass for the whole scope - evalTimelines unions the off-history content probes and the
1459
+ // anchor probes across every id it is given, so asking it once per scope issues one child per probe kind
1460
+ // instead of one per node. Reading the timelines inside the loop instead cost 74% of a warm open.
1461
+ const timelineIds = impact.nodes
1462
+ .filter((projected) => specById.has(projected.id) && evalById.has(projected.id))
1463
+ .map((projected) => projected.id)
1464
+ const timelineById = new Map((await evalTimelines(timelineIds, ctx)).map((timeline, i) => [timelineIds[i], timeline]))
1465
+
1461
1466
  for (const projected of impact.nodes) {
1462
1467
  const spec = specById.get(projected.id)
1463
1468
  if (!spec) continue // removed nodes remain fully explained by impact.nodes; they have no live eval rows.
@@ -1482,7 +1487,7 @@ async function sessionScopeNodes(
1482
1487
  continue
1483
1488
  }
1484
1489
 
1485
- const timeline = await evalTimeline(spec.id, ctx)
1490
+ const timeline = timelineById.get(spec.id)!
1486
1491
  // A reading is this session's own when the session filed it OR its anchor is a branch commit. This is
1487
1492
  // the same marker the UI and CLI render; measurement impact consumes that marker instead of inventing
1488
1493
  // another attribution rule.
@@ -1663,9 +1668,18 @@ type ProjectionEntry = {
1663
1668
  current?: { generation: number; revision: string; value: SessionEvalSummary }
1664
1669
  scheduled: number | null
1665
1670
  running: number | null
1671
+ demandCancelledGeneration: number | null
1666
1672
  observerHolds: Set<string>
1667
1673
  }
1668
1674
 
1675
+ type StagedProjection = {
1676
+ entry: ProjectionEntry
1677
+ generation: number
1678
+ result: SummaryBuildResult
1679
+ }
1680
+
1681
+ type ProjectionCohortRow = Pick<StagedProjection, 'entry' | 'generation'>
1682
+
1669
1683
  type ProjectionTarget = 'all' | { id?: string; path?: string }
1670
1684
 
1671
1685
  // Summary builds touch a session worktree's diff, history, and eval sidecars. Running one job per row
@@ -1721,6 +1735,7 @@ export class SessionEvalProjectionCache {
1721
1735
 
1722
1736
  private authorize(entry: ProjectionEntry): void {
1723
1737
  if (!this.precompute || entry.liveness === 'offline' || entry.observerHolds.size) return
1738
+ if (entry.demandCancelledGeneration === entry.generation) return
1724
1739
  if ((entry.phase === 'loading' || entry.phase === 'updating')
1725
1740
  && entry.running !== entry.generation && entry.scheduled !== entry.generation)
1726
1741
  entry.scheduled = entry.generation
@@ -1740,6 +1755,7 @@ export class SessionEvalProjectionCache {
1740
1755
  phase: 'loading',
1741
1756
  scheduled: null,
1742
1757
  running: null,
1758
+ demandCancelledGeneration: null,
1743
1759
  observerHolds: new Set(),
1744
1760
  }
1745
1761
  this.entries.set(id, entry)
@@ -1762,6 +1778,7 @@ export class SessionEvalProjectionCache {
1762
1778
  phase: 'loading',
1763
1779
  scheduled: null,
1764
1780
  running: null,
1781
+ demandCancelledGeneration: null,
1765
1782
  observerHolds: new Set(),
1766
1783
  }
1767
1784
  this.entries.set(session.id, entry)
@@ -1791,6 +1808,7 @@ export class SessionEvalProjectionCache {
1791
1808
  entry.generation++
1792
1809
  entry.phase = 'updating'
1793
1810
  entry.scheduled = null
1811
+ entry.demandCancelledGeneration = null
1794
1812
  changed++
1795
1813
  }
1796
1814
  return changed
@@ -1805,6 +1823,7 @@ export class SessionEvalProjectionCache {
1805
1823
  entry.generation++
1806
1824
  entry.phase = 'updating'
1807
1825
  entry.scheduled = null
1826
+ entry.demandCancelledGeneration = null
1808
1827
  }
1809
1828
  return true
1810
1829
  }
@@ -1816,6 +1835,7 @@ export class SessionEvalProjectionCache {
1816
1835
  entry.generation++
1817
1836
  entry.phase = 'updating'
1818
1837
  entry.scheduled = null
1838
+ entry.demandCancelledGeneration = null
1819
1839
  }
1820
1840
  for (const check of [...this.observerWaiters]) check()
1821
1841
  return true
@@ -1855,9 +1875,13 @@ export class SessionEvalProjectionCache {
1855
1875
  const existing = this.demands.get(id)
1856
1876
  if (existing) return existing as Promise<T>
1857
1877
  const entry = this.ensureEntry(id, path)
1858
- // A queued summary for this same generation is superseded by the full demand build. A running
1859
- // summary is left alone; the priority job waits for it to settle before taking the slot.
1860
- if (entry.running == null) entry.scheduled = null
1878
+ // A queued summary for this same generation is superseded by the full demand build. If that demand
1879
+ // rejects, snapshots must not recreate the cancelled eager work until a later invalidation advances g.
1880
+ // A running summary is left alone; the priority job waits for it to settle before taking the slot.
1881
+ if (entry.running == null && entry.scheduled === entry.generation) {
1882
+ entry.scheduled = null
1883
+ entry.demandCancelledGeneration = entry.generation
1884
+ }
1861
1885
  let resolve!: (value: T) => void
1862
1886
  let reject!: (error: unknown) => void
1863
1887
  const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
@@ -1902,35 +1926,69 @@ export class SessionEvalProjectionCache {
1902
1926
  return target === 'all' || target.id === entry.id || target.path === entry.path
1903
1927
  }
1904
1928
 
1929
+ private hasPending(): boolean {
1930
+ return this.demandQueue.length > 0
1931
+ || [...this.entries.values()].some((entry) => entry.scheduled != null && entry.running == null)
1932
+ }
1933
+
1934
+ private publishCohort(staged: StagedProjection[]): boolean {
1935
+ let changed = false
1936
+ for (const { entry, generation, result } of staged) {
1937
+ if (this.entries.get(entry.id) !== entry || entry.generation !== generation || entry.observerHolds.size) continue
1938
+ if (result.kind === 'unstable') {
1939
+ entry.generation++
1940
+ entry.phase = 'updating'
1941
+ entry.scheduled = null
1942
+ entry.demandCancelledGeneration = null
1943
+ this.authorize(entry)
1944
+ changed = true
1945
+ continue
1946
+ }
1947
+ if (result.kind === 'missing') {
1948
+ changed = changed || entry.phase !== 'error'
1949
+ entry.phase = 'error'
1950
+ entry.scheduled = null
1951
+ continue
1952
+ }
1953
+ changed = changed || entry.phase !== 'ready' || entry.current?.revision !== result.revision
1954
+ entry.current = { generation, revision: result.revision, value: result.summary }
1955
+ entry.phase = 'ready'
1956
+ entry.scheduled = null
1957
+ }
1958
+ return changed
1959
+ }
1960
+
1905
1961
  private startBatch(): void {
1906
1962
  if (this.batch) return
1907
- const hasPending = () => this.demandQueue.length > 0
1908
- || [...this.entries.values()].some((entry) => entry.scheduled != null && entry.running == null)
1909
- if (!hasPending()) return
1963
+ if (!this.hasPending()) return
1910
1964
  this.batch = (async () => {
1911
- let publish = false
1912
- while (hasPending()) {
1913
- const demand = this.demandQueue.shift()
1914
- if (demand) {
1915
- try { demand.resolve(await demand.run()) }
1916
- catch (error) { demand.reject(error) }
1917
- continue
1918
- }
1919
- const jobs = [...this.entries.values()]
1920
- .filter((entry) => entry.scheduled != null && entry.running == null)
1921
- .slice(0, PROJECTION_CONCURRENCY)
1922
- const changed = await Promise.all(jobs.map((entry) => this.runEntry(entry)))
1923
- publish = publish || changed.some(Boolean)
1965
+ const demand = this.demandQueue.shift()
1966
+ if (demand) {
1967
+ try { demand.resolve(await demand.run()) }
1968
+ catch (error) { demand.reject(error) }
1969
+ return
1970
+ }
1971
+ // Freeze this finite cohort. Inputs that arrive later remain scheduled for the next batch, so a
1972
+ // busy stream cannot keep a completed cohort unpublished forever.
1973
+ const cohort = [...this.entries.values()]
1974
+ .filter((entry) => entry.scheduled != null && entry.running == null)
1975
+ .map((entry) => ({ entry, generation: entry.scheduled! }))
1976
+ const staged: StagedProjection[] = []
1977
+ for (let offset = 0; offset < cohort.length && !this.demandQueue.length; offset += PROJECTION_CONCURRENCY) {
1978
+ const chunk = cohort.slice(offset, offset + PROJECTION_CONCURRENCY)
1979
+ const results = await Promise.all(chunk.map((row) => this.runEntry(row)))
1980
+ for (const result of results) if (result) staged.push(result)
1924
1981
  }
1925
- if (publish) this.notify()
1982
+ if (this.publishCohort(staged)) this.notify()
1926
1983
  })().finally(() => {
1927
1984
  this.batch = null
1928
- if (hasPending()) this.startBatch()
1985
+ if (this.hasPending()) this.startBatch()
1929
1986
  })
1930
1987
  }
1931
1988
 
1932
- private async runEntry(entry: ProjectionEntry): Promise<boolean> {
1933
- const generation = entry.scheduled!
1989
+ private async runEntry({ entry, generation }: ProjectionCohortRow): Promise<StagedProjection | null> {
1990
+ if (this.entries.get(entry.id) !== entry || entry.generation !== generation
1991
+ || entry.scheduled !== generation || entry.running != null) return null
1934
1992
  entry.scheduled = null
1935
1993
  entry.running = generation
1936
1994
  let result: SummaryBuildResult
@@ -1940,21 +1998,8 @@ export class SessionEvalProjectionCache {
1940
1998
  result = { kind: 'missing' }
1941
1999
  }
1942
2000
  entry.running = null
1943
- if (this.entries.get(entry.id) !== entry || entry.generation !== generation) return false
1944
- if (result.kind === 'unstable') {
1945
- entry.generation++
1946
- entry.phase = 'updating'
1947
- entry.scheduled = null
1948
- this.notify()
1949
- return false
1950
- }
1951
- if (result.kind === 'missing') {
1952
- entry.phase = 'error'
1953
- return true
1954
- }
1955
- entry.current = { generation, revision: result.revision, value: result.summary }
1956
- entry.phase = 'ready'
1957
- return true
2001
+ if (this.entries.get(entry.id) !== entry || entry.generation !== generation || entry.scheduled !== null) return null
2002
+ return { entry, generation, result }
1958
2003
  }
1959
2004
  }
1960
2005
 
@@ -1969,20 +2014,37 @@ async function buildSummaryAttempt(id: string, _path: string): Promise<SummaryBu
1969
2014
  if (cached) {
1970
2015
  const after = await sessionEvalContentRevision(ctxPath)
1971
2016
  return before === after
1972
- ? { kind: 'stable', revision: after, summary: cached }
2017
+ ? { kind: 'stable', revision: after, summary: cached.summary }
1973
2018
  : { kind: 'unstable' }
1974
2019
  }
1975
2020
  const model = await buildSessionEvalModel(id, payload, wtPath, true)
1976
2021
  const after = await sessionEvalContentRevision(ctxPath)
1977
2022
  if (before !== after) return { kind: 'unstable' }
1978
2023
  const summary = sessionEvalSummary(model.nodes)
1979
- // Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse.
1980
- for (const key of summaryByContent.keys()) if (key.startsWith(`${id}\0`)) summaryByContent.delete(key)
1981
- summaryByContent.set(cacheKey, summary)
2024
+ // Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse. This
2025
+ // fold is latestOnly, so it deposits no model — it only carries any model an earlier demand left here.
2026
+ depositStableCut(id, after, { summary })
1982
2027
  return { kind: 'stable', revision: after, summary }
1983
2028
  }
1984
2029
 
1985
- const summaryByContent = new Map<string, SessionEvalSummary>()
2030
+ // ONE content-addressed cut per session, keyed by id + content revision. It carries the summary the graph
2031
+ // reads and — only when the demand path built it — the derived full model a repeat open replays.
2032
+ // @@@the two builders do not produce the same model - buildSummaryAttempt folds latestOnly=true (latest
2033
+ // reading per scenario, enough for counts) while the demand path needs the complete A/B history. Only the
2034
+ // demand path may deposit `model`, and only a demand read may consume it; serving the summary path's fold
2035
+ // to a demand would silently truncate every scenario's history.
2036
+ type StableCut = { summary: SessionEvalSummary; model?: SessionEvalModel }
2037
+ const summaryByContent = new Map<string, StableCut>()
2038
+
2039
+ // Content addressing IS the invalidation: a moved input yields a different key. Keeping only the newest key
2040
+ // per session bounds the map at one cut per session and drops summary and model together, so the two can
2041
+ // never disagree about which revision they describe.
2042
+ function depositStableCut(id: string, revision: string, cut: StableCut): void {
2043
+ const key = `${id}\0${revision}`
2044
+ const existing = summaryByContent.get(key)
2045
+ for (const other of [...summaryByContent.keys()]) if (other.startsWith(`${id}\0`)) summaryByContent.delete(other)
2046
+ summaryByContent.set(key, { summary: cut.summary, model: cut.model ?? existing?.model })
2047
+ }
1986
2048
  const projectionCache = new SessionEvalProjectionCache(buildSummaryAttempt, () => {}, randomUUID(), false)
1987
2049
  const OBSERVER_RECOVERY_TIMEOUT_MS = 10_000
1988
2050
 
@@ -2029,6 +2091,23 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
2029
2091
  const generation = known?.generation ?? 0
2030
2092
  await awaitObservableInputs(id, ctxPath)
2031
2093
  const before = await sessionEvalContentRevision(ctxPath)
2094
+ // A repeat open at an unmoved revision replays the cut this same owner already published, instead of
2095
+ // re-deriving it from Git. The stability, observer and generation fences below still decide whether the
2096
+ // answer may be published, so a replay is certified exactly like a fresh fold.
2097
+ // @@@a replay cannot hide a dead selector - the validation that raises 503 reads the declarations and
2098
+ // trees the content revision already covers, so an identical revision has an identical verdict; and a
2099
+ // failing fold deposits nothing, so an unavailable projection always re-derives and re-raises.
2100
+ const replay = summaryByContent.get(`${id}\0${before}`)
2101
+ if (replay?.model) {
2102
+ const settled = await sessionEvalContentRevision(ctxPath)
2103
+ const live = projectionCache.get(id)
2104
+ if (before === settled && !projectionCache.isObserverHeld(id, ctxPath)
2105
+ && !(live && live.generation !== generation)) {
2106
+ return { kind: 'ready' as const, model: replay.model, summary: replay.summary, generation, revision: settled }
2107
+ }
2108
+ if (before !== settled) projectionCache.invalidate({ id })
2109
+ return { kind: 'retry' as const }
2110
+ }
2032
2111
  const model = await buildSessionEvalModel(id, payload, wtPath, false)
2033
2112
  const after = await sessionEvalContentRevision(ctxPath)
2034
2113
  const current = projectionCache.get(id)
@@ -2038,9 +2117,9 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
2038
2117
  return { kind: 'retry' as const }
2039
2118
  }
2040
2119
  const summary = sessionEvalSummary(model.nodes)
2041
- const cacheKey = `${id}\0${after}`
2042
- for (const key of summaryByContent.keys()) if (key.startsWith(`${id}\0`)) summaryByContent.delete(key)
2043
- summaryByContent.set(cacheKey, summary)
2120
+ // Only a settled fold deposits, and only here does a model enter the cut — a thrown build reaches
2121
+ // neither line, so a failure can never poison the entry.
2122
+ depositStableCut(id, after, { summary, model })
2044
2123
  if (current) projectionCache.accept(id, generation, after, summary)
2045
2124
  return { kind: 'ready' as const, model, summary, generation, revision: after }
2046
2125
  })
@@ -1,66 +1,30 @@
1
1
  import { readFileSync, appendFileSync, existsSync } from 'node:fs'
2
2
 
3
- // the verdict is pass | fail; `note` is an OPTIONAL one-line annotation on either (why it failed, how far
4
- // a pass is from ideal) — not a third status. (Legacy readings filed when `note` was its own status survive
5
- // on disk with status:'note'; render stays tolerant of them, the CLI no longer mints them.)
6
3
  export type Verdict = { status: 'pass' | 'fail'; note?: string }
7
4
 
8
- // The evidence-kind taxonomy ([[evidence-kind-taxonomy]]) is a MEDIA/RENDER type — how a blob's bytes are
9
- // shown — kept ORTHOGONAL to the step-map AXIS (which is derived from the kind, not welded to it): `image`
10
- // (a still), `transcript` (free-form text), `video` (a screenshot with a time axis), `data` (a structured
11
- // machine export — a JSON/metrics dump — rendered as a validatable data block, not flattened into scrolling
12
- // transcript text). A `data` reading is honest about being structured: it can be parsed and checked, and it
13
- // is derived from CONTENT (isJsonBlob), never from which filing flag was used.
14
5
  export type EvidenceKind = 'image' | 'transcript' | 'video' | 'data'
15
- // one piece of a reading's evidence: a content-addressed blob (`hash`) tagged by `kind`.
16
6
  export type Evidence = { hash: string; kind: EvidenceKind }
17
7
 
18
- // A reading's evidence is a LIST of typed entries — N images and/or a video (with its step-timeline) and/or
19
- // a transcript, in filing order. New filings write `evidence`; the legacy scalar shape (`blob` + `blobKind`,
20
- // absent kind → image) is still READ and normalized to a one-entry list by `evidenceOf`, so old readings
21
- // still render. A video reading may carry `timelineBlob`: the content hash of its step-timeline sidecar
22
- // (timeline.ts) mapping clip moments to named steps — it anchors the reading's VIDEO evidence entry.
23
- // `by` is the SESSION that filed this reading (the filer, from envSessionId) — the ORIGINATOR an eval-comment
24
- // thread loops in on a reply ([[mentions]] implicit loop-in). Pure additive: a legacy reading without it simply
25
- // has no originator → silent. WHO measured is deliberately NOT a schema axis — the agent is the measuring
26
- // hand; the retired `evaluator` tag survives on old lines only, read-tolerated like the scalar blob.
27
8
  export type Reading = {
28
9
  scenario: string
29
10
  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
11
  scenarioHash?: string
36
12
  evidence?: Evidence[]
37
- // legacy scalar evidence — read for old readings, never written by new filings.
38
13
  blob?: string | null
39
14
  blobKind?: EvidenceKind
40
15
  timelineBlob?: string
41
- // legacy instrument tag (always 'manual@1') — read for old readings, never written by new filings.
42
16
  evaluator?: string
43
17
  by?: string
44
18
  verdict?: Verdict
45
19
  ts: string
46
20
  }
47
21
 
48
- // the one scalar→list bridge every evidence consumer passes through: the `evidence` list when present, else
49
- // the legacy scalar (blob + blobKind, absent kind → image) as a one-entry list, else empty.
50
22
  export function evidenceOf(r: { evidence?: Evidence[]; blob?: string | null; blobKind?: EvidenceKind }): Evidence[] {
51
23
  if (r.evidence?.length) return r.evidence
52
24
  if (r.blob) return [{ hash: r.blob, kind: r.blobKind ?? 'image' }]
53
25
  return []
54
26
  }
55
27
 
56
- // Is this blob STRUCTURED DATA (a machine export — a JSON object/array) rather than free-form transcript
57
- // text? The `data` evidence kind ([[evidence-kind-taxonomy]]) is derived from CONTENT, never from which
58
- // filing flag was used: a hyperfine `--export-json`, an API payload, a metrics dump is data, and flattening
59
- // it into a scrolling transcript loses that it can be structurally validated and rendered as a data block.
60
- // Sniffed cheaply and self-contained (no deps): a text blob (no NUL) whose trimmed body brackets as an
61
- // object/array AND parses to one; anything else (plain logs, terminal text) is not data. This is the ONE
62
- // predicate both the blob MIME sniff (application/json) and the CLI `--result` kind derive from, so the
63
- // stored kind and the served Content-Type always agree.
64
28
  export function isJsonBlob(b: Buffer): boolean {
65
29
  if (!b.length || b.includes(0)) return false // empty or binary → not JSON text
66
30
  if (b.length > 4_000_000) return false // don't parse an unbounded blob just to sniff a type
@@ -70,25 +34,10 @@ export function isJsonBlob(b: Buffer): boolean {
70
34
  try { const v = JSON.parse(s); return v !== null && typeof v === 'object' } catch { return false }
71
35
  }
72
36
 
73
- // a RETRACTION is the sanctioned inverse of a filing — itself an appended event, never a deleted line
74
- // (the sidecar stays append-only; git shows who retracted what, when). `retracts` is the target reading's
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).
79
37
  export type Retraction = { retracts: string; scenario: string; note?: string; by?: string; ts: string }
80
38
 
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
39
  export type HumanOk = { kind: 'human-ok'; scenario: string; okTs: string; okSha: string; by: string; ts: string }
88
40
 
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
41
  export function readSidecar(sidecarPath: string): { readings: Reading[]; retractions: Retraction[]; oks: HumanOk[] } {
93
42
  const readings: Reading[] = []
94
43
  const retractions: Retraction[] = []
@@ -103,58 +52,40 @@ export function readSidecar(sidecarPath: string): { readings: Reading[]; retract
103
52
  if (typeof r.retracts === 'string') retractions.push(r as Retraction)
104
53
  else if (r.kind === 'human-ok' && typeof r.okTs === 'string') oks.push(r as HumanOk)
105
54
  else if (typeof r.codeSha === 'string') readings.push(r as Reading)
106
- } catch { /* skip a malformed line */ }
55
+ } catch { /* Keep earlier events readable after an incomplete append. */ }
107
56
  }
108
57
  return { readings, retractions, oks }
109
58
  }
110
59
 
111
- // the retraction join, shared by every effective-view reader: drop each reading a retraction targets by
112
- // (scenario, ts) — NUL-joined, since a scenario name may contain spaces. A retraction matching nothing is
113
- // inert: it excludes no reading and harms no read.
114
60
  export function applyRetractions(readings: Reading[], retractions: Retraction[]): Reading[] {
115
61
  if (!retractions.length) return readings
116
62
  const gone = new Set(retractions.map((x) => `${x.scenario}\0${x.retracts}`))
117
63
  return readings.filter((r) => !gone.has(`${r.scenario}\0${r.ts}`))
118
64
  }
119
65
 
120
- // the EFFECTIVE readings — what the scoreboard sees: every reading minus the retracted. Every score
121
- // consumer (freshness, scan, clean's referenced set, the eval tab, the proof) reads through here, so a
122
- // retract undoes a botched filing on ALL of them at once — the previous reading becomes the latest again,
123
- // or the scenario honestly returns to eval-missing.
124
66
  export function readReadings(sidecarPath: string): Reading[] {
125
67
  const { readings, retractions } = readSidecar(sidecarPath)
126
68
  return applyRetractions(readings, retractions)
127
69
  }
128
70
 
129
- // append ONE reading as a JSON line — the only mutation eval performs (a reading is an event, never an
130
- // overwrite; superseding readings are newer lines, freshness picks the latest per scenario).
131
71
  export function appendReading(sidecarPath: string, r: Reading): void {
132
72
  appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
133
73
  }
134
74
 
135
- // append ONE retraction as a JSON line — the sanctioned undo writes through the same append-only surface
136
- // that filed the reading; the target line stays in place as history.
137
75
  export function appendRetraction(sidecarPath: string, r: Retraction): void {
138
76
  appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
139
77
  }
140
78
 
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
79
  export function appendHumanOk(sidecarPath: string, r: HumanOk): void {
144
80
  appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
145
81
  }
146
82
 
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
83
  export function humanOkFor(oks: HumanOk[], scenario: string, readingTs: string): HumanOk | null {
151
84
  let hit: HumanOk | null = null
152
85
  for (const o of oks) if (o.scenario === scenario && o.okTs === readingTs) hit = o
153
86
  return hit
154
87
  }
155
88
 
156
- // the latest reading per scenario (the file is chronological, so the LAST line for a name wins). clean's
157
- // --keep-latest uses it to decide which blob to keep.
158
89
  export function latestPerScenario(readings: Reading[]): Map<string, Reading> {
159
90
  const m = new Map<string, Reading>()
160
91
  for (const r of readings) m.set(r.scenario, r) // later lines overwrite earlier → last wins
@@ -1,30 +1,12 @@
1
- // step-timeline — the map from a POSITION on a piece of evidence's own axis to a named step. SpexCode owns
2
- // only this FORMAT (a tiny data contract any userland emitter satisfies — a Playwright reporter, a WebDriver
3
- // listener, a computer-use hand narrating as it drives, a CLI run stamping line numbers); aligning the
4
- // emitter's positions to the evidence is the emitter's own job. The map rides as a second content-addressed
5
- // blob on the reading, never a new ndjson column beyond the one hash.
6
- //
7
- // The step is anchored to the evidence's OWN axis, tagged by `axis`: `time` (ms, a video), `frame` (a still
8
- // SEQUENCE by index), `line` (a transcript by line number), `index` (a bare action ordinal). The set is
9
- // OPEN by convention — an unknown axis is legal and a reader renders its positions as bare numbers. `stepAt`
10
- // (last step at or before a position) is axis-agnostic and unchanged.
11
-
12
1
  export type TimelineEvent = { at: number; step: string; node?: string }
13
2
  export type StepTimeline = { v: 2; axis: string; events: TimelineEvent[] }
14
3
 
15
- // legacy v1 is the TIME axis with `tMs` as the position — read losslessly, normalized to the axis-tagged
16
- // shape (`axis: 'time'`, `at: tMs`). Kept forever: an emitter that only knew `{ v: 1, events: [{ tMs }] }`
17
- // still files a valid video step-map.
18
4
  export type LegacyTimelineEvent = { tMs: number; step: string; node?: string }
19
5
  export type LegacyStepTimeline = { v: 1; events: LegacyTimelineEvent[] }
20
6
 
21
7
  const V2_EVENT_KEYS = new Set(['at', 'step', 'node'])
22
8
  const V1_EVENT_KEYS = new Set(['tMs', 'step', 'node'])
23
9
 
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 eval.md
26
- // scenario schema): a malformed timeline is rejected at filing time, never silently reshaped. The `axis`
27
- // string itself is open — only its ABSENCE is an error, never an unrecognized value.
28
10
  export function validateTimeline(raw: unknown): string[] {
29
11
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return ['timeline must be a JSON object { v, axis, events }']
30
12
  const o = raw as Record<string, unknown>
@@ -55,9 +37,6 @@ export function validateTimeline(raw: unknown): string[] {
55
37
  return errs
56
38
  }
57
39
 
58
- // normalize any VALID timeline (v1 or v2) to the axis-tagged shape every reader uses: v1 IS the time axis
59
- // with `tMs` as the position. Call only on input `validateTimeline` accepted. This is the whole of the
60
- // lossless back-compat: an old v1 blob and a new `{ v: 2, axis: 'time' }` render identically.
61
40
  export function normalizeTimeline(raw: unknown): { axis: string; events: TimelineEvent[] } {
62
41
  const o = (raw ?? {}) as Record<string, any>
63
42
  const events: any[] = Array.isArray(o.events) ? o.events : []
@@ -65,8 +44,6 @@ export function normalizeTimeline(raw: unknown): { axis: string; events: Timelin
65
44
  return { axis: typeof o.axis === 'string' ? o.axis : 'time', events: events.map((e) => ({ at: e.at, step: e.step, ...(e.node ? { node: e.node } : {}) })) }
66
45
  }
67
46
 
68
- // the whole of "which step is this position": the last event at or before `pos`; null before the first event
69
- // (a plain moment, no step to name — graceful, never an error). Axis-agnostic — `pos` is on the events' axis.
70
47
  export function stepAt(events: TimelineEvent[], pos: number): TimelineEvent | null {
71
48
  let hit: TimelineEvent | null = null
72
49
  for (const e of events) {