spexcode 0.6.0 → 0.6.2

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 (88) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. package/spec-eval/src/matrix.ts +0 -693
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
2
  import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
- import { dirname, join, relative } from 'node:path'
4
+ import { dirname, join, relative, resolve as resolvePath } from 'node:path'
5
5
  import {
6
6
  batchBlobTexts,
7
7
  batchRevisionOids,
@@ -13,13 +13,16 @@ import {
13
13
  driftIndex,
14
14
  historyIndex,
15
15
  treeTextFiles,
16
+ withEventLedgerBuild,
17
+ withEventLedgerDemand,
16
18
  type DriftIndex,
17
19
  type DriftPathEvent,
18
20
  type ReviewDiffFile,
19
21
  } from '../../spec-cli/src/git.js'
22
+ import { touchRoot } from '../../spec-cli/src/root-lru.js'
20
23
  import { loadSpecs } from '../../spec-cli/src/specs.js'
21
24
  import { mainBranch } from '../../spec-cli/src/layout.js'
22
- import { reviewPayload } from '../../spec-cli/src/sessions.js'
25
+ import { reviewIdentity, reviewPayload, type ReviewIdentity } from '../../spec-cli/src/sessions.js'
23
26
  import { loadEvalRemarkTracks } from '../../spec-cli/src/issues.js'
24
27
  import {
25
28
  anchorHitCommits,
@@ -120,7 +123,7 @@ type LoadedSpec = Awaited<ReturnType<typeof loadSpecs>>[number]
120
123
  async function impactGit(root: string, args: string[], operation: string): Promise<string> {
121
124
  const result = await gitTry(['-C', root, ...args])
122
125
  if (!result.ok) {
123
- const detail = result.stderr.trim() || `${result.failure ?? 'git'} failure`
126
+ const detail = result.failure ? `${result.failure} failure${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}` : result.stderr.trim() || 'git failure'
124
127
  throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`)
125
128
  }
126
129
  return result.stdout
@@ -129,7 +132,7 @@ async function impactGit(root: string, args: string[], operation: string): Promi
129
132
  async function impactIndexGit(root: string, indexFile: string, args: string[], operation: string): Promise<string> {
130
133
  const result = await gitTry(['-C', root, ...args], { indexFile })
131
134
  if (!result.ok) {
132
- const detail = result.stderr.trim() || `${result.failure ?? 'git'} failure`
135
+ const detail = result.failure ? `${result.failure} failure${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}` : result.stderr.trim() || 'git failure'
133
136
  throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`)
134
137
  }
135
138
  return result.stdout
@@ -400,11 +403,45 @@ function parsedRelation(raw: readonly string[], relation: 'code' | 'related', ow
400
403
  // Public, exact-revision impact projection. It reads both spec trees and all scenario declarations from
401
404
  // immutable Git objects, reuses the canonical relation/anchor engine, then re-resolves the caller's selectors
402
405
  // before publication. Callers may pass branch names, but never receive a projection spanning two ref states.
406
+ // @@@ the projection is the PERMANENT half ([[taste]] 19) - what commits between two immutable trees changed
407
+ // which paths, and what each side declared, are facts about those commits: they cannot change, so a repeat
408
+ // projection over the same inputs is pure recomputation. It used to be deliberately build-local, which was
409
+ // right while it was cheap; on a branch 840 commits from its base it is 2.4s, and a detail open pays it
410
+ // EVERY time because a focused build deposits no cut. Every input that can move the result is in the key —
411
+ // the two resolved commit oids, the live overlay's own content revision, and the measurement axis — so a hit
412
+ // answers the identical question. The promise is cached, not the value, so concurrent openers join one
413
+ // projection; a rejection is never retained, keeping an unavailable selector loud on every read.
414
+ const impactSlots = Math.max(4, Number(process.env.SPEXCODE_IMPACT_SLOTS || 32))
415
+ const impactRoots = new Map<string, string>()
416
+ const impactMemo = new Map<string, Promise<SessionImpactProjection>>()
417
+ function impactKey(root: string, base: string, head: string, options: SessionImpactOptions): string {
418
+ const measurements = Object.entries(options.measurements ?? {})
419
+ .map(([node, names]) => `${node}:${[...names].sort().join(',')}`).sort().join('\u001f')
420
+ return [resolvePath(root), base, head, options.overlay?.revision ?? '', createHash('sha256').update(measurements).digest('hex')].join('\0')
421
+ }
422
+
403
423
  export async function projectSessionImpact(root: string, options: SessionImpactOptions): Promise<SessionImpactProjection> {
404
424
  const [base, head] = await Promise.all([
405
425
  impactCommit(root, options.base),
406
426
  impactCommit(root, options.head),
407
427
  ])
428
+ const memoKey = impactKey(root, base, head, options)
429
+ const memoHit = impactMemo.get(memoKey)
430
+ if (memoHit) return memoHit
431
+ const flight = projectSessionImpactUncached(root, options, base, head)
432
+ impactMemo.set(memoKey, flight)
433
+ flight.catch(() => { if (impactMemo.get(memoKey) === flight) impactMemo.delete(memoKey) })
434
+ touchRoot(impactRoots, impactMemo, resolvePath(root), memoKey, impactSlots)
435
+ return flight
436
+ }
437
+
438
+ // the slow, obviously-correct recompute — kept whole as the specification the cached path must equal
439
+ export async function projectSessionImpactUncached(
440
+ root: string,
441
+ options: SessionImpactOptions,
442
+ base: string,
443
+ head: string,
444
+ ): Promise<SessionImpactProjection> {
408
445
  const ancestry = await gitTry(['-C', root, 'merge-base', '--is-ancestor', base, head])
409
446
  if (!ancestry.ok) {
410
447
  if (ancestry.failure === 'exit') {
@@ -413,7 +450,7 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
413
450
  throw new SessionImpactUnavailableError(`session impact cannot verify base/head ancestry: ${ancestry.stderr.trim() || ancestry.failure}`)
414
451
  }
415
452
  // One immutable .spec tree read per distinct revision supplies BOTH spec relations and eval declarations.
416
- // This is deliberately build-local: exact projections do not need another resident cache or generation.
453
+ // Build-local by design: the memo above owns reuse ACROSS builds, this map owns it within one.
417
454
  const treeByRevision = new Map<string, ReadonlyMap<string, string>>()
418
455
  const readTree = (revision: string): ReadonlyMap<string, string> => {
419
456
  const cached = treeByRevision.get(revision)
@@ -763,14 +800,23 @@ export type ExportModel = {
763
800
 
764
801
  // null when no session has that id (route → 404).
765
802
  export async function buildExportModel(id: string): Promise<ExportModel | null> {
766
- const payload = await reviewPayload(id)
767
- if (!payload) return null
803
+ const identity = reviewIdentity(id)
804
+ if (!identity) return null
768
805
  // root EVERYTHING at the SESSION's worktree — readings, freshness, AND the spec tree itself. The
769
806
  // worktree's .spec is the branch's pending proposal ([[source-of-truth]]): a node the branch ADDED
770
807
  // exists only there, so a trunk-rooted loadSpecs would silently drop it from the model (the 0fca
771
808
  // family's node-existence layer). No worktree → the backend checkout, unchanged.
772
- const wtPath = worktreePathForBranch(payload.branch)
809
+ const wtPath = worktreePathForBranch(identity.branch)
773
810
  const ctxRoot = wtPath ?? repoRoot()
811
+ return withEventLedgerDemand(ctxRoot, async () => {
812
+ const payload = await reviewPayload(id)
813
+ return payload
814
+ ? withEventLedgerBuild(ctxRoot, () => buildExportModelInLedger(id, payload, wtPath, ctxRoot))
815
+ : null
816
+ })
817
+ }
818
+
819
+ async function buildExportModelInLedger(id: string, payload: ReviewPayloadValue, wtPath: string | null, ctxRoot: string): Promise<ExportModel> {
774
820
  const specs = await loadSpecs(ctxRoot)
775
821
  const specById = new Map(specs.map((s) => [s.id, s]))
776
822
  const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
@@ -1383,13 +1429,18 @@ export type SessionEvals = {
1383
1429
  node: string | null
1384
1430
  branch: string | null
1385
1431
  title: string
1386
- ahead: number
1387
- dirtyNonRuntime: number
1388
- gates: ExportGate[]
1389
1432
  nodes: SessionEvalNode[]
1390
1433
  impact: SessionImpactProjection
1391
- summary: SessionEvalSummary
1434
+ // the whole scope's seven counts. A FOCUSED build cannot honestly produce them — it measured six rows,
1435
+ // not the scope — so it carries the summary only when the cut already holds one, and otherwise omits it
1436
+ // rather than publishing a fold over the window. `evalRevision.content` is the stronger identity anyway:
1437
+ // equal content revision IS the same evaluation cut, so a consumer fencing on it needs no counts.
1438
+ summary?: SessionEvalSummary
1392
1439
  evalRevision: SessionEvalRevision
1440
+ // present ONLY on a focused build: the whole population's identity+sequence facts, because `nodes` then
1441
+ // holds just the few the response will render. Its presence is exactly what marks a model as PARTIAL —
1442
+ // such a model may never enter the shared cut, or the list page would read the scope as those few nodes.
1443
+ order?: SessionEvalOrderRow[]
1393
1444
  }
1394
1445
 
1395
1446
  export type SessionEvalSummary = {
@@ -1449,7 +1500,7 @@ async function sessionScopeNodes(
1449
1500
  ctx: Awaited<ReturnType<typeof evalContext>>,
1450
1501
  impact: SessionImpactProjection,
1451
1502
  shas: ReadonlySet<string>,
1452
- latestOnly = false,
1503
+ freshness: { order?: boolean; only?: ReadonlySet<string> } = {},
1453
1504
  ): Promise<SessionEvalNode[]> {
1454
1505
  const evalById = new Map(ctx.ynodes.map((node) => [node.id, node]))
1455
1506
  const specById = new Map(ctx.specs.map((spec) => [spec.id, spec]))
@@ -1460,10 +1511,13 @@ async function sessionScopeNodes(
1460
1511
  // instead of one per node. Reading the timelines inside the loop instead cost 74% of a warm open.
1461
1512
  const timelineIds = impact.nodes
1462
1513
  .filter((projected) => specById.has(projected.id) && evalById.has(projected.id))
1514
+ .filter((projected) => !freshness.only || freshness.only.has(projected.id))
1463
1515
  .map((projected) => projected.id)
1464
- const timelineById = new Map((await evalTimelines(timelineIds, ctx)).map((timeline, i) => [timelineIds[i], timeline]))
1516
+ const timelineById = new Map((await evalTimelines(timelineIds, ctx, { order: freshness.order }))
1517
+ .map((timeline, i) => [timelineIds[i], timeline]))
1465
1518
 
1466
1519
  for (const projected of impact.nodes) {
1520
+ if (freshness.only && !freshness.only.has(projected.id)) continue
1467
1521
  const spec = specById.get(projected.id)
1468
1522
  if (!spec) continue // removed nodes remain fully explained by impact.nodes; they have no live eval rows.
1469
1523
  const evalNode = evalById.get(spec.id)
@@ -1507,7 +1561,7 @@ async function sessionScopeNodes(
1507
1561
  scenarios: scoped.scenarios,
1508
1562
  // Preserve the whole A/B history for selected scenarios. Fresh, stale, legacy and missing remain
1509
1563
  // honest downstream states; impact selection never removes a row because its reading is stale.
1510
- evals: latestOnly ? latestPerScenario(scoped.evals) as (EvalEntry & { inSession: boolean })[] : scoped.evals,
1564
+ evals: scoped.evals,
1511
1565
  })
1512
1566
  }
1513
1567
 
@@ -1570,35 +1624,71 @@ type SessionEvalModel = Omit<SessionEvals, 'summary' | 'evalRevision'>
1570
1624
 
1571
1625
  async function buildSessionEvalModel(
1572
1626
  id: string,
1573
- payload: ReviewPayloadValue,
1627
+ identity: ReviewIdentity,
1574
1628
  wtPath: string | null,
1575
- latestOnly: boolean,
1629
+ pick?: SessionEvalFocus,
1576
1630
  ): Promise<SessionEvalModel> {
1577
1631
  // spec tree from the session worktree, same root as readings/indexes — a branch-NEW node must exist
1578
1632
  // in this model or the Eval tab/deep link can never reach its readings (see buildExportModel above).
1579
1633
  const ctxRoot = wtPath ?? repoRoot()
1634
+ return withEventLedgerBuild(ctxRoot, () => buildSessionEvalModelInLedger(id, identity, wtPath, pick, ctxRoot))
1635
+ }
1636
+
1637
+ async function buildSessionEvalModelInLedger(
1638
+ id: string,
1639
+ identity: ReviewIdentity,
1640
+ wtPath: string | null,
1641
+ pick: SessionEvalFocus | undefined,
1642
+ ctxRoot: string,
1643
+ ): Promise<SessionEvalModel> {
1580
1644
  const specs = await loadSpecs(ctxRoot)
1581
1645
  const specById = new Map(specs.map((s) => [s.id, s]))
1582
1646
  const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
1583
1647
  const ctx = await evalContext(ctxRoot, specs, didx, hidx)
1584
1648
  const { impact, shas } = await sessionImpactForContext(id, ctx, wtPath)
1585
- const nodes = await sessionScopeNodes(id, ctx, impact, shas, latestOnly)
1649
+ // @@@ TWO passes, ONE context - a detail open renders one scenario and at most five neighbours, but it
1650
+ // still owes the full population's sequence (its `index` and `total`). Sequence is freshness-free, so the
1651
+ // first pass reads every node's rows with no probes at all (~0.4s of the 25s), the caller names the few
1652
+ // nodes whose verdicts will actually be published, and only those pay the freshness pass. The context and
1653
+ // the impact projection are built once and shared, so the cheap pass adds no second projection.
1654
+ let order: SessionEvalOrderRow[] | undefined
1655
+ let only: ReadonlySet<string> | undefined
1656
+ if (pick) {
1657
+ const draft = await sessionScopeNodes(id, ctx, impact, shas, { order: true })
1658
+ order = orderRowsOf(draft)
1659
+ only = new Set(pick(order))
1660
+ }
1661
+ const nodes = await sessionScopeNodes(id, ctx, impact, shas, only ? { only } : {})
1586
1662
  // nodes with in-session measurements lead, then the most-measured — the session's own evidence first.
1587
1663
  nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length)
1588
1664
  || (b.scenarios.length - a.scenarios.length) || (b.unknownCoverage.length - a.unknownCoverage.length))
1589
1665
 
1590
- const primary = payload.node && specById.has(payload.node) ? specById.get(payload.node)!.title : null
1666
+ const primary = identity.node && specById.has(identity.node) ? specById.get(identity.node)!.title : null
1591
1667
  return {
1592
1668
  id,
1593
- node: payload.node,
1594
- branch: payload.branch,
1595
- title: primary || payload.node || payload.branch || id.slice(0, 8),
1596
- ahead: payload.ahead,
1597
- dirtyNonRuntime: payload.dirtyNonRuntime,
1598
- gates: gateRows(payload),
1669
+ node: identity.node,
1670
+ branch: identity.branch,
1671
+ title: primary || identity.node || identity.branch || id.slice(0, 8),
1599
1672
  nodes,
1600
1673
  impact,
1674
+ ...(order ? { order } : {}),
1675
+ }
1676
+ }
1677
+
1678
+ // the identity+sequence facts a detail open needs about scenarios it will NOT render: which exist, whether
1679
+ // they carry a filed reading, and when. Deliberately not an EvalEntry — there is no verdict here to leak.
1680
+ export type SessionEvalOrderRow = { node: string; scenario: string; ts: string | null }
1681
+ export type SessionEvalFocus = (order: SessionEvalOrderRow[]) => readonly string[]
1682
+
1683
+ export function orderRowsOf(nodes: SessionEvalNode[]): SessionEvalOrderRow[] {
1684
+ const rows: SessionEvalOrderRow[] = []
1685
+ for (const node of nodes) {
1686
+ const latest = new Map<string, string>()
1687
+ for (const reading of node.evals) if (!latest.has(reading.scenario)) latest.set(reading.scenario, reading.ts)
1688
+ for (const scenario of node.scenarios)
1689
+ rows.push({ node: node.id, scenario: scenario.name, ts: latest.get(scenario.name) ?? null })
1601
1690
  }
1691
+ return rows
1602
1692
  }
1603
1693
 
1604
1694
  function untrackedPaths(status: string): string[] {
@@ -1652,13 +1742,6 @@ type SummaryBuildResult =
1652
1742
 
1653
1743
  export type SessionEvalSummaryBuilder = (id: string, path: string) => Promise<SummaryBuildResult>
1654
1744
 
1655
- type DemandJob<T> = {
1656
- id: string
1657
- run: () => Promise<T>
1658
- resolve: (value: T) => void
1659
- reject: (error: unknown) => void
1660
- }
1661
-
1662
1745
  type ProjectionEntry = {
1663
1746
  id: string
1664
1747
  path: string
@@ -1668,6 +1751,7 @@ type ProjectionEntry = {
1668
1751
  current?: { generation: number; revision: string; value: SessionEvalSummary }
1669
1752
  scheduled: number | null
1670
1753
  running: number | null
1754
+ flight: Promise<void> | null
1671
1755
  demandCancelledGeneration: number | null
1672
1756
  observerHolds: Set<string>
1673
1757
  }
@@ -1682,27 +1766,16 @@ type ProjectionCohortRow = Pick<StagedProjection, 'entry' | 'generation'>
1682
1766
 
1683
1767
  type ProjectionTarget = 'all' | { id?: string; path?: string }
1684
1768
 
1685
- // Summary builds touch a session worktree's diff, history, and eval sidecars. Running one job per row
1686
- // multiplies those git children and their parsed indexes by the retained session count, so the shared
1687
- // projection queue has one bounded capacity for every project. A deployment can tune the capacity, but the
1688
- // default is deliberately serial: board assembly remains responsive and memory has a natural settle point.
1689
- const configuredProjectionConcurrency = Number(process.env.SPEXCODE_SESSION_EVAL_CONCURRENCY || 1)
1690
- const PROJECTION_CONCURRENCY = Number.isFinite(configuredProjectionConcurrency)
1691
- ? Math.max(1, Math.floor(configuredProjectionConcurrency))
1692
- : 1
1693
-
1694
- // Pure generation coordinator around an injected stable builder. Snapshot construction only serializes
1695
- // entries and authorizes the newest dirty generations; the async batch runs after that snapshot has captured
1696
- // `updating(lastKnown)`, then emits one completion nudge for all stable/error results in the batch.
1769
+ // Pure generation coordinator around an injected stable builder. Sessions have independent evaluation cuts,
1770
+ // so an entry launches and publishes independently; generation/revision fences remain per entry.
1697
1771
  export class SessionEvalProjectionCache {
1698
1772
  readonly epoch: string
1699
1773
  private readonly entries = new Map<string, ProjectionEntry>()
1700
1774
  private readonly observerHolds = new Map<string, ProjectionTarget>()
1701
1775
  private readonly observerWaiters = new Set<() => void>()
1702
- private batch: Promise<void> | null = null
1776
+ private readonly flights = new Set<Promise<void>>()
1703
1777
  private notify: () => void
1704
1778
  private precompute: boolean
1705
- private readonly demandQueue: DemandJob<any>[] = []
1706
1779
  private readonly demands = new Map<string, Promise<unknown>>()
1707
1780
 
1708
1781
  constructor(
@@ -1730,7 +1803,7 @@ export class SessionEvalProjectionCache {
1730
1803
  }
1731
1804
  this.authorize(entry)
1732
1805
  }
1733
- if (enabled) queueMicrotask(() => this.startBatch())
1806
+ if (enabled) queueMicrotask(() => this.startScheduled())
1734
1807
  }
1735
1808
 
1736
1809
  private authorize(entry: ProjectionEntry): void {
@@ -1755,6 +1828,7 @@ export class SessionEvalProjectionCache {
1755
1828
  phase: 'loading',
1756
1829
  scheduled: null,
1757
1830
  running: null,
1831
+ flight: null,
1758
1832
  demandCancelledGeneration: null,
1759
1833
  observerHolds: new Set(),
1760
1834
  }
@@ -1778,6 +1852,7 @@ export class SessionEvalProjectionCache {
1778
1852
  phase: 'loading',
1779
1853
  scheduled: null,
1780
1854
  running: null,
1855
+ flight: null,
1781
1856
  demandCancelledGeneration: null,
1782
1857
  observerHolds: new Set(),
1783
1858
  }
@@ -1797,7 +1872,7 @@ export class SessionEvalProjectionCache {
1797
1872
  this.authorize(entry)
1798
1873
  out.set(session.id, this.project(entry))
1799
1874
  }
1800
- queueMicrotask(() => this.startBatch())
1875
+ queueMicrotask(() => this.startScheduled())
1801
1876
  return out
1802
1877
  }
1803
1878
 
@@ -1875,28 +1950,31 @@ export class SessionEvalProjectionCache {
1875
1950
  const existing = this.demands.get(id)
1876
1951
  if (existing) return existing as Promise<T>
1877
1952
  const entry = this.ensureEntry(id, path)
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.
1953
+ // An unstarted summary is superseded by the full demand build. A running summary stays the one flight for
1954
+ // this entry; demand waits for it only, never for unrelated session work.
1881
1955
  if (entry.running == null && entry.scheduled === entry.generation) {
1882
1956
  entry.scheduled = null
1883
1957
  entry.demandCancelledGeneration = entry.generation
1884
1958
  }
1885
- let resolve!: (value: T) => void
1886
- let reject!: (error: unknown) => void
1887
- const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
1959
+ const priorFlight = entry.flight
1960
+ const promise = (async () => {
1961
+ if (priorFlight) await priorFlight
1962
+ return run()
1963
+ })()
1888
1964
  this.demands.set(id, promise)
1889
- this.demandQueue.push({ id, run, resolve, reject })
1890
1965
  promise.finally(() => {
1891
1966
  if (this.demands.get(id) === promise) this.demands.delete(id)
1892
1967
  }).catch(() => {})
1893
- queueMicrotask(() => this.startBatch())
1894
1968
  return promise
1895
1969
  }
1896
1970
 
1897
1971
  async idle(): Promise<void> {
1898
- await Promise.resolve()
1899
- while (this.batch) await this.batch
1972
+ for (;;) {
1973
+ await Promise.resolve()
1974
+ const pending = [...this.flights, ...this.demands.values()]
1975
+ if (!pending.length) return
1976
+ await Promise.allSettled(pending)
1977
+ }
1900
1978
  }
1901
1979
 
1902
1980
  accept(id: string, generation: number, revision: string, value: SessionEvalSummary): boolean {
@@ -1926,64 +2004,57 @@ export class SessionEvalProjectionCache {
1926
2004
  return target === 'all' || target.id === entry.id || target.path === entry.path
1927
2005
  }
1928
2006
 
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 {
2007
+ private publish(staged: StagedProjection): boolean {
1935
2008
  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'
2009
+ const { entry, generation, result } = staged
2010
+ if (this.entries.get(entry.id) !== entry || entry.generation !== generation || entry.observerHolds.size) return false
2011
+ if (result.kind === 'unstable') {
2012
+ entry.generation++
2013
+ entry.phase = 'updating'
1956
2014
  entry.scheduled = null
2015
+ entry.demandCancelledGeneration = null
2016
+ this.authorize(entry)
2017
+ return true
1957
2018
  }
2019
+ if (result.kind === 'missing') {
2020
+ changed = entry.phase !== 'error'
2021
+ entry.phase = 'error'
2022
+ entry.scheduled = null
2023
+ return changed
2024
+ }
2025
+ changed = entry.phase !== 'ready' || entry.current?.revision !== result.revision
2026
+ entry.current = { generation, revision: result.revision, value: result.summary }
2027
+ entry.phase = 'ready'
2028
+ entry.scheduled = null
1958
2029
  return changed
1959
2030
  }
1960
2031
 
1961
- private startBatch(): void {
1962
- if (this.batch) return
1963
- if (!this.hasPending()) return
1964
- this.batch = (async () => {
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)
1981
- }
1982
- if (this.publishCohort(staged)) this.notify()
1983
- })().finally(() => {
1984
- this.batch = null
1985
- if (this.hasPending()) this.startBatch()
2032
+ private startScheduled(): void {
2033
+ for (const entry of this.entries.values()) {
2034
+ if (entry.scheduled == null || entry.running != null) continue
2035
+ this.startEntry({ entry, generation: entry.scheduled })
2036
+ }
2037
+ }
2038
+
2039
+ private startEntry(row: ProjectionCohortRow): void {
2040
+ const flight = this.runEntry(row).then((staged) => {
2041
+ if (staged && this.publish(staged)) this.notify()
1986
2042
  })
2043
+ row.entry.flight = flight
2044
+ this.flights.add(flight)
2045
+ void flight.then(
2046
+ () => {
2047
+ this.flights.delete(flight)
2048
+ if (row.entry.flight === flight) row.entry.flight = null
2049
+ queueMicrotask(() => this.startScheduled())
2050
+ },
2051
+ (error) => {
2052
+ this.flights.delete(flight)
2053
+ if (row.entry.flight === flight) row.entry.flight = null
2054
+ console.warn(`spec-eval: session summary runner failed for ${row.entry.id}: ${error instanceof Error ? error.message : String(error)}`)
2055
+ queueMicrotask(() => this.startScheduled())
2056
+ },
2057
+ )
1987
2058
  }
1988
2059
 
1989
2060
  private async runEntry({ entry, generation }: ProjectionCohortRow): Promise<StagedProjection | null> {
@@ -2004,35 +2075,41 @@ export class SessionEvalProjectionCache {
2004
2075
  }
2005
2076
 
2006
2077
  async function buildSummaryAttempt(id: string, _path: string): Promise<SummaryBuildResult> {
2007
- const payload = await reviewPayload(id)
2008
- if (!payload) return { kind: 'missing' }
2009
- const wtPath = worktreePathForBranch(payload.branch)
2078
+ const identity = reviewIdentity(id)
2079
+ if (!identity) return { kind: 'missing' }
2080
+ const wtPath = worktreePathForBranch(identity.branch)
2010
2081
  const ctxPath = wtPath ?? repoRoot()
2011
- const before = await sessionEvalContentRevision(ctxPath)
2012
- const cacheKey = `${id}\0${before}`
2013
- const cached = summaryByContent.get(cacheKey)
2014
- if (cached) {
2082
+ return withEventLedgerDemand(ctxPath, async () => {
2083
+ const before = await sessionEvalContentRevision(ctxPath)
2084
+ const cacheKey = `${id}\0${before}`
2085
+ const cached = summaryByContent.get(cacheKey)
2086
+ if (cached) {
2087
+ const after = await sessionEvalContentRevision(ctxPath)
2088
+ return before === after
2089
+ ? { kind: 'stable', revision: after, summary: cached.summary }
2090
+ : { kind: 'unstable' }
2091
+ }
2092
+ const model = await buildSessionEvalModel(id, identity, wtPath)
2015
2093
  const after = await sessionEvalContentRevision(ctxPath)
2016
- return before === after
2017
- ? { kind: 'stable', revision: after, summary: cached.summary }
2018
- : { kind: 'unstable' }
2019
- }
2020
- const model = await buildSessionEvalModel(id, payload, wtPath, true)
2021
- const after = await sessionEvalContentRevision(ctxPath)
2022
- if (before !== after) return { kind: 'unstable' }
2023
- const summary = sessionEvalSummary(model.nodes)
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 })
2027
- return { kind: 'stable', revision: after, summary }
2028
- }
2029
-
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.
2094
+ if (before !== after) return { kind: 'unstable' }
2095
+ const summary = sessionEvalSummary(model.nodes)
2096
+ // Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse.
2097
+ // This fold IS the demand's fold, so it deposits the model too and a later open replays it.
2098
+ depositStableCut(id, after, { summary, model })
2099
+ return { kind: 'stable', revision: after, summary }
2100
+ })
2101
+ }
2102
+
2103
+ // ONE content-addressed cut per session, keyed by id + content revision, carrying the summary the graph
2104
+ // reads and the full model a repeat open replays.
2105
+ // @@@one builder, not two - these used to be separate folds: the graph's built latestOnly (latest reading
2106
+ // per scenario) and deposited counts alone, so the demand path re-derived the WHOLE thing to get history
2107
+ // the graph fold had held one line earlier and thrown away. The trim never made the fold cheaper — the
2108
+ // expensive part is the freshness pass over every node in scope, and it ran identically either way — and
2109
+ // it never mattered to the counts, because sessionEvalSummary folds latestPerScenario itself. So the trim
2110
+ // bought nothing and cost a second full build per session per revision. The price of dropping it is
2111
+ // MEMORY: a cut now retains complete A/B history rather than latest-per-scenario, bounded by the one-cut-
2112
+ // per-session rule below.
2036
2113
  type StableCut = { summary: SessionEvalSummary; model?: SessionEvalModel }
2037
2114
  const summaryByContent = new Map<string, StableCut>()
2038
2115
 
@@ -2078,15 +2155,19 @@ export function releaseSessionEvalProjectionObserver(observer: string): boolean
2078
2155
  }
2079
2156
  export async function awaitSessionEvalProjectionIdle(): Promise<void> { await projectionCache.idle() }
2080
2157
 
2081
- export async function buildSessionEvals(id: string): Promise<SessionEvals | null> {
2082
- // A full model is demand-only, but it still runs through the projection queue. This gives a selected session
2083
- // priority over unrelated queued summaries without opening a second git/build lane.
2158
+ // `pick` makes this a FOCUSED build: the caller names, from the population's sequence, the few nodes whose
2159
+ // verdicts its response will publish, and only those pay the freshness pass. A focused build is PARTIAL, so
2160
+ // it never deposits but it still prefers a cached FULL model when one exists, because a complete answer
2161
+ // already paid for beats a cheap incomplete one.
2162
+ export async function buildSessionEvals(id: string, pick?: SessionEvalFocus): Promise<SessionEvals | null> {
2163
+ // A full model is demand-only. It joins its session's current summary flight, but unrelated summaries never
2164
+ // gate this demand; a cached stable cut then replays without re-deriving Git state.
2165
+ const identity = reviewIdentity(id)
2166
+ if (!identity) return null
2167
+ const wtPath = worktreePathForBranch(identity.branch)
2168
+ const ctxPath = wtPath ?? repoRoot()
2084
2169
  for (;;) {
2085
- const attempt = await projectionCache.demand(id, '', async () => {
2086
- const payload = await reviewPayload(id)
2087
- if (!payload) return { kind: 'missing' as const }
2088
- const wtPath = worktreePathForBranch(payload.branch)
2089
- const ctxPath = wtPath ?? repoRoot()
2170
+ const attempt = await projectionCache.demand(id, '', () => withEventLedgerDemand(ctxPath, async () => {
2090
2171
  const known = projectionCache.get(id)
2091
2172
  const generation = known?.generation ?? 0
2092
2173
  await awaitObservableInputs(id, ctxPath)
@@ -2108,7 +2189,7 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
2108
2189
  if (before !== settled) projectionCache.invalidate({ id })
2109
2190
  return { kind: 'retry' as const }
2110
2191
  }
2111
- const model = await buildSessionEvalModel(id, payload, wtPath, false)
2192
+ const model = await buildSessionEvalModel(id, identity, wtPath, pick)
2112
2193
  const after = await sessionEvalContentRevision(ctxPath)
2113
2194
  const current = projectionCache.get(id)
2114
2195
  if (before !== after || projectionCache.isObserverHeld(id, ctxPath)
@@ -2116,18 +2197,23 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
2116
2197
  if (before !== after) projectionCache.invalidate({ id })
2117
2198
  return { kind: 'retry' as const }
2118
2199
  }
2200
+ // A focused model holds only the nodes it was asked to publish, so its counts are NOT the scope's and
2201
+ // its nodes are NOT the scope's. Neither may be deposited or accepted: doing so would tell the list
2202
+ // page and the graph that the session's whole evaluation is those few nodes.
2203
+ if (model.order)
2204
+ return { kind: 'ready' as const, model, summary: known?.value, generation, revision: after }
2119
2205
  const summary = sessionEvalSummary(model.nodes)
2120
2206
  // Only a settled fold deposits, and only here does a model enter the cut — a thrown build reaches
2121
2207
  // neither line, so a failure can never poison the entry.
2122
2208
  depositStableCut(id, after, { summary, model })
2123
2209
  if (current) projectionCache.accept(id, generation, after, summary)
2124
2210
  return { kind: 'ready' as const, model, summary, generation, revision: after }
2125
- })
2126
- if (attempt.kind === 'missing') return null
2211
+ }))
2127
2212
  if (attempt.kind === 'retry') continue
2128
2213
  return {
2129
2214
  ...attempt.model,
2130
- summary: attempt.summary,
2215
+ // absent on a focused build whose cut holds no summary — never a fold over the rendered window
2216
+ ...(attempt.summary ? { summary: attempt.summary } : {}),
2131
2217
  evalRevision: { epoch: projectionCache.epoch, generation: attempt.generation, content: attempt.revision },
2132
2218
  }
2133
2219
  }