spexcode 0.6.0 → 0.6.1

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 (53) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/cli.ts +68 -47
  3. package/spec-cli/src/client.ts +1 -1
  4. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  5. package/spec-cli/src/commit-surgery.ts +2 -1
  6. package/spec-cli/src/contract-filter.ts +58 -42
  7. package/spec-cli/src/doctor.ts +2 -1
  8. package/spec-cli/src/file-write.ts +22 -0
  9. package/spec-cli/src/git.ts +30 -8
  10. package/spec-cli/src/graphStream.ts +79 -11
  11. package/spec-cli/src/harness.ts +11 -9
  12. package/spec-cli/src/help.ts +8 -13
  13. package/spec-cli/src/index.ts +8 -1
  14. package/spec-cli/src/init.ts +8 -17
  15. package/spec-cli/src/layout.ts +35 -7
  16. package/spec-cli/src/materialize.ts +150 -107
  17. package/spec-cli/src/plugin-harness.ts +19 -8
  18. package/spec-cli/src/process-identity.ts +13 -0
  19. package/spec-cli/src/reviews.ts +55 -11
  20. package/spec-cli/src/sessions.ts +166 -9
  21. package/spec-cli/src/uninstall.ts +2 -1
  22. package/spec-cli/templates/hooks/post-checkout +3 -0
  23. package/spec-cli/templates/hooks/post-merge +1 -0
  24. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  25. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  26. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  27. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
  28. package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
  29. package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
  30. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
  31. package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
  32. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
  33. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
  34. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
  35. package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
  36. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
  37. package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
  38. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
  39. package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
  40. package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
  41. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
  42. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
  43. package/spec-dashboard/dist/index.html +2 -2
  44. package/spec-dashboard/src/reviewFilters.js +5 -0
  45. package/spec-dashboard/src/session.js +5 -1
  46. package/spec-eval/src/cli.ts +7 -8
  47. package/spec-eval/src/evaltab.ts +25 -5
  48. package/spec-eval/src/freshness.ts +52 -8
  49. package/spec-eval/src/scenariofresh.ts +58 -8
  50. package/spec-eval/src/scenarios.ts +60 -12
  51. package/spec-eval/src/sessioneval.ts +122 -30
  52. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  53. package/spec-eval/src/matrix.ts +0 -693
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
2
  import { readFile, readdir } from 'node:fs/promises'
3
3
  import { createHash } from 'node:crypto'
4
4
  import { join, relative, basename } from 'node:path'
5
- import { mintIds } from '../../spec-cli/src/specs.js'
5
+ import { mintIds, parseFrontmatter } from '../../spec-cli/src/specs.js'
6
6
  import { parseRelation, type RelationEntry } from '../../spec-cli/src/anchors.js'
7
7
  import { treeTextFiles } from '../../spec-cli/src/git.js'
8
8
 
@@ -41,13 +41,20 @@ export type ScenarioProjectionRow = {
41
41
  semantic: ScenarioSemanticRow
42
42
  measurement: ScenarioMeasurementRow
43
43
  }
44
+ export type ScenarioProjectionNode = {
45
+ id: string
46
+ code: RelationEntry[]
47
+ related: RelationEntry[]
48
+ }
44
49
  export type ScenarioProjectionProvenance = { head: string | null; treeSha: string | null }
45
50
  export type ScenarioProjection = {
46
51
  projection: typeof SCENARIO_PROJECTION
47
52
  schemaVersion: typeof SCENARIO_SCHEMA_VERSION
48
53
  provenance: ScenarioProjectionProvenance
54
+ nodes: ScenarioProjectionNode[]
49
55
  semanticIndexHash: string
50
56
  fullIndexHash: string
57
+ planningIndexHash: string
51
58
  rows: ScenarioProjectionRow[]
52
59
  }
53
60
 
@@ -58,6 +65,7 @@ export type EvalNode = {
58
65
  sidecarPath: string
59
66
  scenarios: Scenario[]
60
67
  evalSource?: string
68
+ specSource?: string
61
69
  }
62
70
 
63
71
  const SCENARIO_KEYS = ['name', 'description', 'expected', 'tags', 'test', 'code', 'related'] as const
@@ -349,11 +357,29 @@ const hashProjection = (value: unknown): string =>
349
357
  createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex')
350
358
 
351
359
  const semanticOnly = (row: ScenarioProjectionRow): ScenarioSemanticRow => row.semantic
360
+ const frontmatterList = (value: string | string[] | undefined): string[] =>
361
+ Array.isArray(value) ? value : value ? [value] : []
362
+
363
+ function projectionNode(node: Pick<EvalNode, 'id' | 'specSource'>): ScenarioProjectionNode {
364
+ const fm = node.specSource ? parseFrontmatter(node.specSource).fm : {}
365
+ const code = parseRelation(frontmatterList(fm.code), 'code')
366
+ const related = parseRelation(frontmatterList(fm.related), 'related')
367
+ const problems = [...code.problems, ...related.problems]
368
+ if (problems.length) {
369
+ throw new Error(`node '${node.id}' has malformed spec relations:\n${problems.map((e) => ` - ${e}`).join('\n')}`)
370
+ }
371
+ return {
372
+ id: node.id,
373
+ code: code.entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] })),
374
+ related: related.entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] })),
375
+ }
376
+ }
352
377
 
353
378
  export function scenarioProjection(
354
- nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource'>[],
379
+ nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource' | 'specSource'>[],
355
380
  provenance: Partial<ScenarioProjectionProvenance> = {},
356
381
  ): ScenarioProjection {
382
+ const nodeRows = nodes.map(projectionNode).sort((a, b) => compareStable(a.id, b.id))
357
383
  const rows: ScenarioProjectionRow[] = []
358
384
  for (const node of nodes) {
359
385
  if ('evalSource' in node && node.evalSource !== undefined) {
@@ -388,8 +414,10 @@ export function scenarioProjection(
388
414
  projection: SCENARIO_PROJECTION,
389
415
  schemaVersion: SCENARIO_SCHEMA_VERSION,
390
416
  provenance: { head: provenance.head ?? null, treeSha: provenance.treeSha ?? null },
417
+ nodes: nodeRows,
391
418
  semanticIndexHash: hashProjection(semanticRows),
392
419
  fullIndexHash: hashProjection(rows),
420
+ planningIndexHash: hashProjection({ nodes: nodeRows, rows }),
393
421
  rows,
394
422
  }
395
423
  }
@@ -543,18 +571,19 @@ export function writeScenarioMeasurementMetadata(source: string, request: unknow
543
571
  return proposed
544
572
  }
545
573
 
546
- function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string }[]): EvalNode[] {
574
+ function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string; specSource?: string }[]): EvalNode[] {
547
575
  const specBase = join(root, '.spec')
548
576
  const ids = mintIds(specDirs.map((d) => relative(specBase, d).split(/[/\\]/)))
549
577
  const idByDir = new Map(specDirs.map((d, i) => [d, ids[i]]))
550
578
  return hits
551
- .map(({ dir, src }) => ({
579
+ .map(({ dir, src, specSource }) => ({
552
580
  id: idByDir.get(dir) ?? basename(dir),
553
581
  dir,
554
582
  evalPath: relative(root, join(dir, EVAL_FILE)),
555
583
  sidecarPath: join(dir, SIDECAR_FILE),
556
584
  scenarios: parseScenarios(src),
557
585
  evalSource: src,
586
+ ...(specSource !== undefined ? { specSource } : {}),
558
587
  }))
559
588
  .sort((a, b) => a.id.localeCompare(b.id))
560
589
  }
@@ -562,14 +591,21 @@ function assembleNodes(root: string, specDirs: string[], hits: { dir: string; sr
562
591
  export function evalNodes(root: string): EvalNode[] {
563
592
  const specDir = join(root, '.spec')
564
593
  const specDirs: string[] = []
565
- const hits: { dir: string; src: string }[] = []
594
+ const hits: { dir: string; src: string; specSource?: string }[] = []
566
595
  const stack = existsSync(specDir) ? [specDir] : []
567
596
  while (stack.length) {
568
597
  const dir = stack.pop()!
569
598
  let ents
570
599
  try { ents = readdirSync(dir, { withFileTypes: true }) } catch { continue }
571
600
  if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
572
- if (existsSync(join(dir, EVAL_FILE))) hits.push({ dir, src: readFileSync(join(dir, EVAL_FILE), 'utf8') })
601
+ if (existsSync(join(dir, EVAL_FILE))) {
602
+ const specPath = join(dir, 'spec.md')
603
+ hits.push({
604
+ dir,
605
+ src: readFileSync(join(dir, EVAL_FILE), 'utf8'),
606
+ ...(existsSync(specPath) ? { specSource: readFileSync(specPath, 'utf8') } : {}),
607
+ })
608
+ }
573
609
  for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
574
610
  }
575
611
  return assembleNodes(root, specDirs, hits)
@@ -583,10 +619,15 @@ export function evalNodesAt(root: string, tip: string): EvalNode[] {
583
619
  .map((path) => join(root, path.slice(0, -'/spec.md'.length)))
584
620
  const hits = paths
585
621
  .filter((path) => path.endsWith(`/${EVAL_FILE}`))
586
- .map((path) => ({
587
- dir: join(root, path.slice(0, -`/${EVAL_FILE}`.length)),
588
- src: files.get(path)!,
589
- }))
622
+ .map((path) => {
623
+ const relDir = path.slice(0, -`/${EVAL_FILE}`.length)
624
+ const specSource = files.get(`${relDir}/spec.md`)
625
+ return {
626
+ dir: join(root, relDir),
627
+ src: files.get(path)!,
628
+ ...(specSource !== undefined ? { specSource } : {}),
629
+ }
630
+ })
590
631
  return assembleNodes(root, specDirs, hits)
591
632
  }
592
633
 
@@ -597,14 +638,21 @@ export function evalNodesAt(root: string, tip: string): EvalNode[] {
597
638
  export async function evalNodesAsync(root: string): Promise<EvalNode[]> {
598
639
  const specDir = join(root, '.spec')
599
640
  const specDirs: string[] = []
600
- const hits: { dir: string; src: string }[] = []
641
+ const hits: { dir: string; src: string; specSource?: string }[] = []
601
642
  const stack = existsSync(specDir) ? [specDir] : []
602
643
  while (stack.length) {
603
644
  const dir = stack.pop()!
604
645
  let ents
605
646
  try { ents = await readdir(dir, { withFileTypes: true }) } catch { continue }
606
647
  if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
607
- if (existsSync(join(dir, EVAL_FILE))) hits.push({ dir, src: await readFile(join(dir, EVAL_FILE), 'utf8') })
648
+ if (existsSync(join(dir, EVAL_FILE))) {
649
+ const specPath = join(dir, 'spec.md')
650
+ hits.push({
651
+ dir,
652
+ src: await readFile(join(dir, EVAL_FILE), 'utf8'),
653
+ ...(existsSync(specPath) ? { specSource: await readFile(specPath, 'utf8') } : {}),
654
+ })
655
+ }
608
656
  for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
609
657
  }
610
658
  return assembleNodes(root, specDirs, hits)
@@ -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,
@@ -17,9 +17,10 @@ import {
17
17
  type DriftPathEvent,
18
18
  type ReviewDiffFile,
19
19
  } from '../../spec-cli/src/git.js'
20
+ import { touchRoot } from '../../spec-cli/src/root-lru.js'
20
21
  import { loadSpecs } from '../../spec-cli/src/specs.js'
21
22
  import { mainBranch } from '../../spec-cli/src/layout.js'
22
- import { reviewPayload } from '../../spec-cli/src/sessions.js'
23
+ import { reviewIdentity, reviewPayload, type ReviewIdentity } from '../../spec-cli/src/sessions.js'
23
24
  import { loadEvalRemarkTracks } from '../../spec-cli/src/issues.js'
24
25
  import {
25
26
  anchorHitCommits,
@@ -400,11 +401,45 @@ function parsedRelation(raw: readonly string[], relation: 'code' | 'related', ow
400
401
  // Public, exact-revision impact projection. It reads both spec trees and all scenario declarations from
401
402
  // immutable Git objects, reuses the canonical relation/anchor engine, then re-resolves the caller's selectors
402
403
  // before publication. Callers may pass branch names, but never receive a projection spanning two ref states.
404
+ // @@@ the projection is the PERMANENT half ([[taste]] 19) - what commits between two immutable trees changed
405
+ // which paths, and what each side declared, are facts about those commits: they cannot change, so a repeat
406
+ // projection over the same inputs is pure recomputation. It used to be deliberately build-local, which was
407
+ // right while it was cheap; on a branch 840 commits from its base it is 2.4s, and a detail open pays it
408
+ // EVERY time because a focused build deposits no cut. Every input that can move the result is in the key —
409
+ // the two resolved commit oids, the live overlay's own content revision, and the measurement axis — so a hit
410
+ // answers the identical question. The promise is cached, not the value, so concurrent openers join one
411
+ // projection; a rejection is never retained, keeping an unavailable selector loud on every read.
412
+ const impactSlots = Math.max(4, Number(process.env.SPEXCODE_IMPACT_SLOTS || 32))
413
+ const impactRoots = new Map<string, string>()
414
+ const impactMemo = new Map<string, Promise<SessionImpactProjection>>()
415
+ function impactKey(root: string, base: string, head: string, options: SessionImpactOptions): string {
416
+ const measurements = Object.entries(options.measurements ?? {})
417
+ .map(([node, names]) => `${node}:${[...names].sort().join(',')}`).sort().join('\u001f')
418
+ return [resolvePath(root), base, head, options.overlay?.revision ?? '', createHash('sha256').update(measurements).digest('hex')].join('\0')
419
+ }
420
+
403
421
  export async function projectSessionImpact(root: string, options: SessionImpactOptions): Promise<SessionImpactProjection> {
404
422
  const [base, head] = await Promise.all([
405
423
  impactCommit(root, options.base),
406
424
  impactCommit(root, options.head),
407
425
  ])
426
+ const memoKey = impactKey(root, base, head, options)
427
+ const memoHit = impactMemo.get(memoKey)
428
+ if (memoHit) return memoHit
429
+ const flight = projectSessionImpactUncached(root, options, base, head)
430
+ impactMemo.set(memoKey, flight)
431
+ flight.catch(() => { if (impactMemo.get(memoKey) === flight) impactMemo.delete(memoKey) })
432
+ touchRoot(impactRoots, impactMemo, resolvePath(root), memoKey, impactSlots)
433
+ return flight
434
+ }
435
+
436
+ // the slow, obviously-correct recompute — kept whole as the specification the cached path must equal
437
+ export async function projectSessionImpactUncached(
438
+ root: string,
439
+ options: SessionImpactOptions,
440
+ base: string,
441
+ head: string,
442
+ ): Promise<SessionImpactProjection> {
408
443
  const ancestry = await gitTry(['-C', root, 'merge-base', '--is-ancestor', base, head])
409
444
  if (!ancestry.ok) {
410
445
  if (ancestry.failure === 'exit') {
@@ -413,7 +448,7 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
413
448
  throw new SessionImpactUnavailableError(`session impact cannot verify base/head ancestry: ${ancestry.stderr.trim() || ancestry.failure}`)
414
449
  }
415
450
  // 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.
451
+ // Build-local by design: the memo above owns reuse ACROSS builds, this map owns it within one.
417
452
  const treeByRevision = new Map<string, ReadonlyMap<string, string>>()
418
453
  const readTree = (revision: string): ReadonlyMap<string, string> => {
419
454
  const cached = treeByRevision.get(revision)
@@ -1383,13 +1418,23 @@ export type SessionEvals = {
1383
1418
  node: string | null
1384
1419
  branch: string | null
1385
1420
  title: string
1386
- ahead: number
1387
- dirtyNonRuntime: number
1388
- gates: ExportGate[]
1389
1421
  nodes: SessionEvalNode[]
1390
1422
  impact: SessionImpactProjection
1391
- summary: SessionEvalSummary
1423
+ // the whole scope's seven counts. A FOCUSED build cannot honestly produce them — it measured six rows,
1424
+ // not the scope — so it carries the summary only when the cut already holds one, and otherwise omits it
1425
+ // rather than publishing a fold over the window. `evalRevision.content` is the stronger identity anyway:
1426
+ // equal content revision IS the same evaluation cut, so a consumer fencing on it needs no counts.
1427
+ summary?: SessionEvalSummary
1428
+ // the LIST page's chrome, and only its. A focused build renders no gates strip and no branch counters, so
1429
+ // it does not buy them — see `order` below for what marks such a model partial.
1430
+ gates?: ExportGate[]
1431
+ ahead?: number
1432
+ dirtyNonRuntime?: number
1392
1433
  evalRevision: SessionEvalRevision
1434
+ // present ONLY on a focused build: the whole population's identity+sequence facts, because `nodes` then
1435
+ // holds just the few the response will render. Its presence is exactly what marks a model as PARTIAL —
1436
+ // such a model may never enter the shared cut, or the list page would read the scope as those few nodes.
1437
+ order?: SessionEvalOrderRow[]
1393
1438
  }
1394
1439
 
1395
1440
  export type SessionEvalSummary = {
@@ -1449,7 +1494,7 @@ async function sessionScopeNodes(
1449
1494
  ctx: Awaited<ReturnType<typeof evalContext>>,
1450
1495
  impact: SessionImpactProjection,
1451
1496
  shas: ReadonlySet<string>,
1452
- latestOnly = false,
1497
+ freshness: { order?: boolean; only?: ReadonlySet<string> } = {},
1453
1498
  ): Promise<SessionEvalNode[]> {
1454
1499
  const evalById = new Map(ctx.ynodes.map((node) => [node.id, node]))
1455
1500
  const specById = new Map(ctx.specs.map((spec) => [spec.id, spec]))
@@ -1460,10 +1505,13 @@ async function sessionScopeNodes(
1460
1505
  // instead of one per node. Reading the timelines inside the loop instead cost 74% of a warm open.
1461
1506
  const timelineIds = impact.nodes
1462
1507
  .filter((projected) => specById.has(projected.id) && evalById.has(projected.id))
1508
+ .filter((projected) => !freshness.only || freshness.only.has(projected.id))
1463
1509
  .map((projected) => projected.id)
1464
- const timelineById = new Map((await evalTimelines(timelineIds, ctx)).map((timeline, i) => [timelineIds[i], timeline]))
1510
+ const timelineById = new Map((await evalTimelines(timelineIds, ctx, { order: freshness.order }))
1511
+ .map((timeline, i) => [timelineIds[i], timeline]))
1465
1512
 
1466
1513
  for (const projected of impact.nodes) {
1514
+ if (freshness.only && !freshness.only.has(projected.id)) continue
1467
1515
  const spec = specById.get(projected.id)
1468
1516
  if (!spec) continue // removed nodes remain fully explained by impact.nodes; they have no live eval rows.
1469
1517
  const evalNode = evalById.get(spec.id)
@@ -1507,7 +1555,7 @@ async function sessionScopeNodes(
1507
1555
  scenarios: scoped.scenarios,
1508
1556
  // Preserve the whole A/B history for selected scenarios. Fresh, stale, legacy and missing remain
1509
1557
  // 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,
1558
+ evals: scoped.evals,
1511
1559
  })
1512
1560
  }
1513
1561
 
@@ -1570,9 +1618,9 @@ type SessionEvalModel = Omit<SessionEvals, 'summary' | 'evalRevision'>
1570
1618
 
1571
1619
  async function buildSessionEvalModel(
1572
1620
  id: string,
1573
- payload: ReviewPayloadValue,
1621
+ payload: ReviewPayloadValue | ReviewIdentity,
1574
1622
  wtPath: string | null,
1575
- latestOnly: boolean,
1623
+ pick?: SessionEvalFocus,
1576
1624
  ): Promise<SessionEvalModel> {
1577
1625
  // spec tree from the session worktree, same root as readings/indexes — a branch-NEW node must exist
1578
1626
  // in this model or the Eval tab/deep link can never reach its readings (see buildExportModel above).
@@ -1582,23 +1630,51 @@ async function buildSessionEvalModel(
1582
1630
  const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
1583
1631
  const ctx = await evalContext(ctxRoot, specs, didx, hidx)
1584
1632
  const { impact, shas } = await sessionImpactForContext(id, ctx, wtPath)
1585
- const nodes = await sessionScopeNodes(id, ctx, impact, shas, latestOnly)
1633
+ // @@@ TWO passes, ONE context - a detail open renders one scenario and at most five neighbours, but it
1634
+ // still owes the full population's sequence (its `index` and `total`). Sequence is freshness-free, so the
1635
+ // first pass reads every node's rows with no probes at all (~0.4s of the 25s), the caller names the few
1636
+ // nodes whose verdicts will actually be published, and only those pay the freshness pass. The context and
1637
+ // the impact projection are built once and shared, so the cheap pass adds no second projection.
1638
+ let order: SessionEvalOrderRow[] | undefined
1639
+ let only: ReadonlySet<string> | undefined
1640
+ if (pick) {
1641
+ const draft = await sessionScopeNodes(id, ctx, impact, shas, { order: true })
1642
+ order = orderRowsOf(draft)
1643
+ only = new Set(pick(order))
1644
+ }
1645
+ const nodes = await sessionScopeNodes(id, ctx, impact, shas, only ? { only } : {})
1586
1646
  // nodes with in-session measurements lead, then the most-measured — the session's own evidence first.
1587
1647
  nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length)
1588
1648
  || (b.scenarios.length - a.scenarios.length) || (b.unknownCoverage.length - a.unknownCoverage.length))
1589
1649
 
1590
1650
  const primary = payload.node && specById.has(payload.node) ? specById.get(payload.node)!.title : null
1651
+ const full = 'gates' in payload ? payload : null
1591
1652
  return {
1592
1653
  id,
1593
1654
  node: payload.node,
1594
1655
  branch: payload.branch,
1595
1656
  title: primary || payload.node || payload.branch || id.slice(0, 8),
1596
- ahead: payload.ahead,
1597
- dirtyNonRuntime: payload.dirtyNonRuntime,
1598
- gates: gateRows(payload),
1657
+ ...(full ? { ahead: full.ahead, dirtyNonRuntime: full.dirtyNonRuntime, gates: gateRows(full) } : {}),
1599
1658
  nodes,
1600
1659
  impact,
1660
+ ...(order ? { order } : {}),
1661
+ }
1662
+ }
1663
+
1664
+ // the identity+sequence facts a detail open needs about scenarios it will NOT render: which exist, whether
1665
+ // they carry a filed reading, and when. Deliberately not an EvalEntry — there is no verdict here to leak.
1666
+ export type SessionEvalOrderRow = { node: string; scenario: string; ts: string | null }
1667
+ export type SessionEvalFocus = (order: SessionEvalOrderRow[]) => readonly string[]
1668
+
1669
+ export function orderRowsOf(nodes: SessionEvalNode[]): SessionEvalOrderRow[] {
1670
+ const rows: SessionEvalOrderRow[] = []
1671
+ for (const node of nodes) {
1672
+ const latest = new Map<string, string>()
1673
+ for (const reading of node.evals) if (!latest.has(reading.scenario)) latest.set(reading.scenario, reading.ts)
1674
+ for (const scenario of node.scenarios)
1675
+ rows.push({ node: node.id, scenario: scenario.name, ts: latest.get(scenario.name) ?? null })
1601
1676
  }
1677
+ return rows
1602
1678
  }
1603
1679
 
1604
1680
  function untrackedPaths(status: string): string[] {
@@ -2017,22 +2093,26 @@ async function buildSummaryAttempt(id: string, _path: string): Promise<SummaryBu
2017
2093
  ? { kind: 'stable', revision: after, summary: cached.summary }
2018
2094
  : { kind: 'unstable' }
2019
2095
  }
2020
- const model = await buildSessionEvalModel(id, payload, wtPath, true)
2096
+ const model = await buildSessionEvalModel(id, payload, wtPath)
2021
2097
  const after = await sessionEvalContentRevision(ctxPath)
2022
2098
  if (before !== after) return { kind: 'unstable' }
2023
2099
  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 })
2100
+ // Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse.
2101
+ // This fold IS the demand's fold, so it deposits the model too and a later open replays it.
2102
+ depositStableCut(id, after, { summary, model })
2027
2103
  return { kind: 'stable', revision: after, summary }
2028
2104
  }
2029
2105
 
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.
2106
+ // ONE content-addressed cut per session, keyed by id + content revision, carrying the summary the graph
2107
+ // reads and the full model a repeat open replays.
2108
+ // @@@one builder, not two - these used to be separate folds: the graph's built latestOnly (latest reading
2109
+ // per scenario) and deposited counts alone, so the demand path re-derived the WHOLE thing to get history
2110
+ // the graph fold had held one line earlier and thrown away. The trim never made the fold cheaper — the
2111
+ // expensive part is the freshness pass over every node in scope, and it ran identically either way — and
2112
+ // it never mattered to the counts, because sessionEvalSummary folds latestPerScenario itself. So the trim
2113
+ // bought nothing and cost a second full build per session per revision. The price of dropping it is
2114
+ // MEMORY: a cut now retains complete A/B history rather than latest-per-scenario, bounded by the one-cut-
2115
+ // per-session rule below.
2036
2116
  type StableCut = { summary: SessionEvalSummary; model?: SessionEvalModel }
2037
2117
  const summaryByContent = new Map<string, StableCut>()
2038
2118
 
@@ -2078,12 +2158,18 @@ export function releaseSessionEvalProjectionObserver(observer: string): boolean
2078
2158
  }
2079
2159
  export async function awaitSessionEvalProjectionIdle(): Promise<void> { await projectionCache.idle() }
2080
2160
 
2081
- export async function buildSessionEvals(id: string): Promise<SessionEvals | null> {
2161
+ // `pick` makes this a FOCUSED build: the caller names, from the population's sequence, the few nodes whose
2162
+ // verdicts its response will publish, and only those pay the freshness pass. A focused build is PARTIAL, so
2163
+ // it never deposits — but it still prefers a cached FULL model when one exists, because a complete answer
2164
+ // already paid for beats a cheap incomplete one.
2165
+ export async function buildSessionEvals(id: string, pick?: SessionEvalFocus): Promise<SessionEvals | null> {
2082
2166
  // A full model is demand-only, but it still runs through the projection queue. This gives a selected session
2083
2167
  // priority over unrelated queued summaries without opening a second git/build lane.
2084
2168
  for (;;) {
2085
2169
  const attempt = await projectionCache.demand(id, '', async () => {
2086
- const payload = await reviewPayload(id)
2170
+ // a focused open renders no gates strip, so it reads the session's IDENTITY (a free store read)
2171
+ // instead of its review payload (ahead count + dirty scan + merge-tree conflict probe).
2172
+ const payload = pick ? reviewIdentity(id) : await reviewPayload(id)
2087
2173
  if (!payload) return { kind: 'missing' as const }
2088
2174
  const wtPath = worktreePathForBranch(payload.branch)
2089
2175
  const ctxPath = wtPath ?? repoRoot()
@@ -2108,7 +2194,7 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
2108
2194
  if (before !== settled) projectionCache.invalidate({ id })
2109
2195
  return { kind: 'retry' as const }
2110
2196
  }
2111
- const model = await buildSessionEvalModel(id, payload, wtPath, false)
2197
+ const model = await buildSessionEvalModel(id, payload, wtPath, pick)
2112
2198
  const after = await sessionEvalContentRevision(ctxPath)
2113
2199
  const current = projectionCache.get(id)
2114
2200
  if (before !== after || projectionCache.isObserverHeld(id, ctxPath)
@@ -2116,6 +2202,11 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
2116
2202
  if (before !== after) projectionCache.invalidate({ id })
2117
2203
  return { kind: 'retry' as const }
2118
2204
  }
2205
+ // A focused model holds only the nodes it was asked to publish, so its counts are NOT the scope's and
2206
+ // its nodes are NOT the scope's. Neither may be deposited or accepted: doing so would tell the list
2207
+ // page and the graph that the session's whole evaluation is those few nodes.
2208
+ if (model.order)
2209
+ return { kind: 'ready' as const, model, summary: known?.value, generation, revision: after }
2119
2210
  const summary = sessionEvalSummary(model.nodes)
2120
2211
  // Only a settled fold deposits, and only here does a model enter the cut — a thrown build reaches
2121
2212
  // neither line, so a failure can never poison the entry.
@@ -2127,7 +2218,8 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
2127
2218
  if (attempt.kind === 'retry') continue
2128
2219
  return {
2129
2220
  ...attempt.model,
2130
- summary: attempt.summary,
2221
+ // absent on a focused build whose cut holds no summary — never a fold over the rendered window
2222
+ ...(attempt.summary ? { summary: attempt.summary } : {}),
2131
2223
  evalRevision: { epoch: projectionCache.epoch, generation: attempt.generation, content: attempt.revision },
2132
2224
  }
2133
2225
  }