spexcode 0.5.1 → 0.5.3

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 (155) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +116 -66
  8. package/spec-cli/src/cli.ts +221 -72
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +13 -3
  17. package/spec-cli/src/harness.ts +965 -70
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +706 -0
  21. package/spec-cli/src/host.ts +1 -0
  22. package/spec-cli/src/index.ts +95 -21
  23. package/spec-cli/src/init.ts +47 -8
  24. package/spec-cli/src/issues.ts +36 -3
  25. package/spec-cli/src/layout.ts +193 -37
  26. package/spec-cli/src/lint.ts +152 -27
  27. package/spec-cli/src/localIssues.ts +5 -1
  28. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  29. package/spec-cli/src/materialize.ts +6 -7
  30. package/spec-cli/src/opencode-headless.ts +117 -7
  31. package/spec-cli/src/opencode.ts +8 -4
  32. package/spec-cli/src/pi-headless.ts +18 -60
  33. package/spec-cli/src/process-identity.ts +49 -0
  34. package/spec-cli/src/project-store.ts +17 -0
  35. package/spec-cli/src/pty-bridge.ts +37 -7
  36. package/spec-cli/src/pty-helper.mjs +16 -6
  37. package/spec-cli/src/pty-native-helper.mjs +22 -0
  38. package/spec-cli/src/reviews.ts +17 -13
  39. package/spec-cli/src/runtime-ownership.ts +99 -0
  40. package/spec-cli/src/session-maintenance.ts +750 -0
  41. package/spec-cli/src/session-timeline.ts +29 -23
  42. package/spec-cli/src/sessions.ts +1262 -200
  43. package/spec-cli/src/sh.ts +3 -0
  44. package/spec-cli/src/source-files.ts +27 -2
  45. package/spec-cli/src/specs.ts +58 -25
  46. package/spec-cli/src/supervise.ts +31 -11
  47. package/spec-cli/src/uninstall.ts +7 -6
  48. package/spec-cli/templates/hooks/commit-msg +14 -0
  49. package/spec-cli/templates/hooks/pre-commit +26 -6
  50. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  51. package/spec-cli/templates/hooks/reference-transaction +105 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  54. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  55. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  56. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  57. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  58. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  59. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  60. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  61. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  116. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  117. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  118. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  119. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  120. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  121. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  122. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
  123. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  124. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  125. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  126. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  127. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  128. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  129. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  130. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  131. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  132. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  133. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  134. package/spec-dashboard/dist/index.html +5 -4
  135. package/spec-dashboard/src/reviewFilters.js +46 -8
  136. package/spec-dashboard/src/session.js +64 -9
  137. package/spec-eval/src/cli.ts +144 -32
  138. package/spec-eval/src/evaltab.ts +16 -9
  139. package/spec-eval/src/freshness.ts +336 -54
  140. package/spec-eval/src/scenariofresh.ts +51 -1
  141. package/spec-eval/src/scenarios.ts +324 -25
  142. package/spec-eval/src/sessioneval.ts +969 -173
  143. package/spec-cli/src/message-stream.ts +0 -147
  144. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
  145. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
  146. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
  147. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
  148. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
  149. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
  150. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
  151. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
  152. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
  153. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
  154. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
  155. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
@@ -1,14 +1,45 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
- import { readFile } from 'node:fs/promises'
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
3
4
  import { dirname, join, relative } from 'node:path'
4
- import { git, gitA, repoRoot, driftIndex, historyIndex, type ReviewDiffFile } from '../../spec-cli/src/git.js'
5
+ import {
6
+ batchBlobTexts,
7
+ batchRevisionOids,
8
+ git,
9
+ gitA,
10
+ gitTry,
11
+ pathRangeEvents,
12
+ repoRoot,
13
+ driftIndex,
14
+ historyIndex,
15
+ treeTextFiles,
16
+ type DriftIndex,
17
+ type DriftPathEvent,
18
+ type ReviewDiffFile,
19
+ } from '../../spec-cli/src/git.js'
5
20
  import { loadSpecs } from '../../spec-cli/src/specs.js'
6
21
  import { mainBranch } from '../../spec-cli/src/layout.js'
7
22
  import { reviewPayload } from '../../spec-cli/src/sessions.js'
8
23
  import { loadEvalRemarkTracks } from '../../spec-cli/src/issues.js'
24
+ import {
25
+ anchorHitCommits,
26
+ diffHunkRanges,
27
+ extOf,
28
+ extractorFor,
29
+ extractors,
30
+ parseRelation,
31
+ relationClaimsPath,
32
+ resolveAnchor,
33
+ selectorsHitRanges,
34
+ type AnchorHit,
35
+ type Extractor,
36
+ type RelationEntry,
37
+ type Unit,
38
+ } from '../../spec-cli/src/anchors.js'
9
39
  import { evalTimeline, evalContext, readBlobByHash, type EvalEntry, type EvalTimeline, type ScenarioInfo } from './evaltab.js'
10
40
  import { isUiPath } from './cli.js'
11
- import { parseScenarios, scenarioHash, type Scenario } from './scenarios.js'
41
+ import { readReadings } from './sidecar.js'
42
+ import { parseScenarios, scenarioCodeAxis, scenarioHash, type Scenario } from './scenarios.js'
12
43
 
13
44
  // ---- the model ----
14
45
 
@@ -17,76 +48,640 @@ type ScoreState = 'pass' | 'fail' | 'stalePass' | 'staleFail' | 'empty' | null
17
48
  export type ScenarioImpactReason = 'code' | 'contract' | 'measurement'
18
49
  export type SessionScenarioInfo = ScenarioInfo & { impact: ScenarioImpactReason[] }
19
50
 
20
- // The ONE session-scope predicate. Declared scenarios come from the current worktree; impact is orthogonal
21
- // to freshness and is derived only from the scenario's own code axis, its semantic contract at merge-base,
22
- // or a reading this session owns. Consumers receive the selected set and never repeat these tests.
23
- export function selectImpactedScenarios(
24
- current: Scenario[],
25
- base: Scenario[],
26
- nodeCode: string[],
27
- changedPaths: ReadonlySet<string>,
28
- evalFileChanged: boolean,
29
- measuredBySession: ReadonlySet<string>,
30
- ): { scenario: Scenario; impact: ScenarioImpactReason[] }[] {
31
- const baseByName = new Map(base.map((scenario) => [scenario.name, scenario]))
32
- return current.flatMap((scenario) => {
33
- const impact: ScenarioImpactReason[] = []
34
- const codeAxis = scenario.code?.length ? scenario.code : nodeCode
35
- if ([...changedPaths].some((path) => codeClaims(codeAxis, path))) impact.push('code')
36
- const prior = baseByName.get(scenario.name)
37
- if (evalFileChanged && (!prior || scenarioHash(prior) !== scenarioHash(scenario))) impact.push('contract')
38
- if (measuredBySession.has(scenario.name)) impact.push('measurement')
39
- return impact.length ? [{ scenario, impact }] : []
40
- })
51
+ export type SessionImpactSelectorHit = AnchorHit & { path: string }
52
+ export type SessionImpactScenarioDelta = {
53
+ kind: 'added' | 'removed' | 'modified' | 'unchanged'
54
+ semantic: boolean
55
+ metadata: boolean
56
+ metadataOnly: boolean
57
+ }
58
+ export type SessionImpactScenario = {
59
+ name: string
60
+ state: 'added' | 'removed' | 'present'
61
+ impact: ScenarioImpactReason[]
62
+ baseEffectiveCode: RelationEntry[]
63
+ headEffectiveCode: RelationEntry[]
64
+ selectorHits: SessionImpactSelectorHit[]
65
+ baseScenarioHash: string | null
66
+ headScenarioHash: string | null
67
+ delta: SessionImpactScenarioDelta
68
+ }
69
+ export type SessionImpactNodeCause = {
70
+ kind: 'spec' | 'eval' | 'code' | 'related' | 'contract' | 'measurement'
71
+ paths: string[]
72
+ scenarios: string[]
73
+ selectorHits: SessionImpactSelectorHit[]
74
+ }
75
+ export type SessionImpactNode = {
76
+ id: string
77
+ causes: SessionImpactNodeCause[]
78
+ scenarios: SessionImpactScenario[]
79
+ }
80
+ export type SessionImpactProjection = {
81
+ base: string
82
+ head: string
83
+ revision: string
84
+ nodes: SessionImpactNode[]
85
+ }
86
+ export type SessionImpactOptions = {
87
+ base: string
88
+ head: string
89
+ measurements?: Readonly<Record<string, readonly string[]>>
90
+ overlay?: SessionImpactOverlay
41
91
  }
42
92
 
43
- export function unknownCoveragePaths(
44
- nodeCode: string[],
45
- changedPaths: ReadonlySet<string>,
46
- ): string[] {
47
- return [...changedPaths].filter((path) => (
48
- isUiPath(path)
49
- && codeClaims(nodeCode, path)
50
- ))
93
+ export type SessionImpactOverlay = {
94
+ revision: string
95
+ paths: string[]
96
+ baseRanges: Record<string, [number, number][]>
97
+ ranges: Record<string, [number, number][]>
98
+ specs: SessionImpactSpecSnapshot[]
99
+ evalSources: Record<string, string | null>
100
+ files: Record<string, string | null>
51
101
  }
52
102
 
53
- export function sessionEvalNodeCandidate(
54
- current: Scenario[],
55
- nodeCode: string[],
56
- evalPath: string,
57
- sidecarPath: string,
58
- changedPaths: ReadonlySet<string>,
59
- dirtyPaths: ReadonlySet<string>,
60
- ): boolean {
61
- if (changedPaths.has(evalPath) || changedPaths.has(sidecarPath) || dirtyPaths.has(sidecarPath)) return true
62
- return current.some((scenario) => {
63
- const codeAxis = scenario.code?.length ? scenario.code : nodeCode
64
- return [...changedPaths].some((path) => codeClaims(codeAxis, path))
103
+ export type SessionImpactSpecSnapshot = Pick<LoadedSpec,
104
+ 'id' | 'path' | 'code' | 'codeScoped' | 'related' | 'relatedScoped' | 'relationProblems'>
105
+
106
+ export class SessionEvalUnavailableError extends Error {
107
+ override name = 'SessionEvalUnavailableError'
108
+ }
109
+
110
+ export class SessionImpactUnavailableError extends SessionEvalUnavailableError {
111
+ override name = 'SessionImpactUnavailableError'
112
+ }
113
+
114
+ export class SessionImpactRevisionMovedError extends SessionImpactUnavailableError {
115
+ override name = 'SessionImpactRevisionMovedError'
116
+ }
117
+
118
+ type LoadedSpec = Awaited<ReturnType<typeof loadSpecs>>[number]
119
+
120
+ async function impactGit(root: string, args: string[], operation: string): Promise<string> {
121
+ const result = await gitTry(['-C', root, ...args])
122
+ if (!result.ok) {
123
+ const detail = result.stderr.trim() || `${result.failure ?? 'git'} failure`
124
+ throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`)
125
+ }
126
+ return result.stdout
127
+ }
128
+
129
+ async function impactIndexGit(root: string, indexFile: string, args: string[], operation: string): Promise<string> {
130
+ const result = await gitTry(['-C', root, ...args], { indexFile })
131
+ if (!result.ok) {
132
+ const detail = result.stderr.trim() || `${result.failure ?? 'git'} failure`
133
+ throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`)
134
+ }
135
+ return result.stdout
136
+ }
137
+
138
+ async function impactCommit(root: string, selector: string): Promise<string> {
139
+ const out = await impactGit(root, ['rev-parse', '--verify', `${selector}^{commit}`], `resolve revision '${selector}'`)
140
+ const oid = out.trim()
141
+ if (!oid) throw new SessionImpactUnavailableError(`session impact revision '${selector}' resolved to no commit`)
142
+ return oid
143
+ }
144
+
145
+ type NameStatusChange = { status: string; before: string; after: string }
146
+ function parseNameStatusChanges(out: string): NameStatusChange[] {
147
+ const records = out.split('\0')
148
+ const changes: NameStatusChange[] = []
149
+ for (let index = 0; index < records.length;) {
150
+ const status = records[index++]
151
+ if (!status) continue
152
+ const first = records[index++]
153
+ if (!first) throw new SessionImpactUnavailableError(`session impact diff emitted '${status}' without a path`)
154
+ if (/^[RC]/.test(status)) {
155
+ const second = records[index++]
156
+ if (!second) throw new SessionImpactUnavailableError(`session impact diff emitted '${status}' without both rename paths`)
157
+ changes.push({ status, before: first, after: second })
158
+ } else changes.push({ status, before: first, after: first })
159
+ }
160
+ return changes
161
+ }
162
+
163
+ async function revisionFile(root: string, revision: string, path: string): Promise<string | null> {
164
+ const listed = await impactGit(root, ['-c', 'core.quotePath=false', 'ls-tree', '-z', '--name-only', revision, '--', path], `read ${revision}:${path}`)
165
+ if (!listed.split('\0').includes(path)) return null
166
+ return impactGit(root, ['show', `${revision}:${path}`], `read ${revision}:${path}`)
167
+ }
168
+
169
+ function loadedRelationRows(spec: SessionImpactSpecSnapshot | undefined, relation: 'code' | 'related'): string[] {
170
+ 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])
175
+ }
176
+
177
+ function scenarioMetadata(scenario: Scenario, effectiveCode: readonly RelationEntry[]): string {
178
+ return JSON.stringify({
179
+ tags: scenario.tags ?? [],
180
+ test: scenario.test ?? null,
181
+ effectiveCode,
182
+ related: scenario.related ?? [],
65
183
  })
66
184
  }
67
185
 
186
+ type ImpactReadContext = {
187
+ root: string
188
+ base: string
189
+ head: string
190
+ committedPaths: ReadonlySet<string>
191
+ changedPaths: ReadonlySet<string>
192
+ overlay?: SessionImpactOverlay
193
+ regs: ReturnType<typeof extractors>
194
+ didx: DriftIndex | null
195
+ entryImpacts: Map<string, Promise<{ paths: string[]; selectorHits: SessionImpactSelectorHit[] }>>
196
+ windows: Map<string, DriftPathEvent[]>
197
+ sources: Map<string, Promise<string | null>>
198
+ units: Map<string, Promise<{ extractor: Extractor; units: Unit[] }>>
199
+ }
200
+
201
+ async function selectorSource(
202
+ context: ImpactReadContext,
203
+ revision: string,
204
+ path: string,
205
+ sourceView: 'base' | 'head',
206
+ ): Promise<string | null> {
207
+ if (sourceView === 'head' && context.overlay && Object.hasOwn(context.overlay.files, path)) return context.overlay.files[path]
208
+ const key = `${revision}\0${path}`
209
+ let pending = context.sources.get(key)
210
+ if (!pending) {
211
+ pending = revisionFile(context.root, revision, path)
212
+ context.sources.set(key, pending)
213
+ }
214
+ return pending
215
+ }
216
+
217
+ async function validateSelectorEntry(
218
+ context: ImpactReadContext,
219
+ revision: string,
220
+ entry: RelationEntry,
221
+ sourceView: 'base' | 'head',
222
+ ): Promise<{ extractor: Extractor; units: Unit[] }> {
223
+ const x = extractorFor(context.regs, extOf(entry.path))
224
+ if (!x) {
225
+ throw new SessionImpactUnavailableError(`session impact selector '${entry.path}#${entry.selectors[0]}' is unavailable: no designated extractor for '.${extOf(entry.path)}'`)
226
+ }
227
+ const ready = x.ready()
228
+ if (ready !== true) {
229
+ throw new SessionImpactUnavailableError(`session impact selectors on '${entry.path}' are unavailable: ${ready}`)
230
+ }
231
+ const sourceIdentity = sourceView === 'head' && context.overlay && Object.hasOwn(context.overlay.files, entry.path)
232
+ ? `overlay:${context.overlay.revision}`
233
+ : 'exact'
234
+ const key = `${revision}\0${sourceIdentity}\0${entry.path}\0${x.id}\0${x.memoKey(entry.path)}`
235
+ let extracted = context.units.get(key)
236
+ if (!extracted) {
237
+ extracted = (async () => {
238
+ const source = await selectorSource(context, revision, entry.path, sourceView)
239
+ if (source === null) {
240
+ throw new SessionImpactUnavailableError(`session impact selector '${entry.path}#${entry.selectors[0]}' is dead at ${revision.slice(0, 8)}: the file does not exist`)
241
+ }
242
+ try { return { extractor: x, units: x.extract(source, entry.path) } }
243
+ catch (error: any) {
244
+ throw new SessionImpactUnavailableError(`session impact selectors on '${entry.path}' are unextractable at ${revision.slice(0, 8)}: ${error?.message ?? String(error)}`)
245
+ }
246
+ })()
247
+ context.units.set(key, extracted)
248
+ }
249
+ const { extractor, units } = await extracted
250
+ for (const selector of entry.selectors) {
251
+ const resolved = resolveAnchor(units, selector)
252
+ if ('dead' in resolved) {
253
+ throw new SessionImpactUnavailableError(`dead session impact selector '${entry.path}#${selector}' at ${revision.slice(0, 8)}: follow the rename or repair the code relation`)
254
+ }
255
+ if ('ambiguous' in resolved) {
256
+ throw new SessionImpactUnavailableError(`ambiguous session impact selector '${entry.path}#${selector}' at ${revision.slice(0, 8)}: ${resolved.ambiguous} units share that name`)
257
+ }
258
+ }
259
+ return { extractor, units }
260
+ }
261
+
262
+ function selectorEntriesForSnapshot(
263
+ specs: readonly SessionImpactSpecSnapshot[],
264
+ scenariosById: ReadonlyMap<string, readonly Scenario[]>,
265
+ ): RelationEntry[] {
266
+ const entries: RelationEntry[] = []
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}'`))
270
+ for (const scenario of scenariosById.get(spec.id) ?? []) {
271
+ entries.push(...parsedRelation(scenario.code ?? [], 'code', `scenario '${spec.id} · ${scenario.name}'`))
272
+ entries.push(...parsedRelation(scenario.related ?? [], 'related', `scenario '${spec.id} · ${scenario.name}'`))
273
+ }
274
+ }
275
+ const unique = new Map<string, RelationEntry>()
276
+ for (const entry of entries) if (entry.selectors.length) unique.set(`${entry.path}\0${entry.selectors.join('\x1e')}`, entry)
277
+ return [...unique.values()]
278
+ }
279
+
280
+ function primeSelectorSources(
281
+ context: ImpactReadContext,
282
+ revision: string,
283
+ paths: readonly string[],
284
+ sourceView: 'base' | 'head',
285
+ ): void {
286
+ const exactPaths = [...new Set(paths)].filter((path) => (
287
+ !context.sources.has(`${revision}\0${path}`)
288
+ && !(sourceView === 'head' && context.overlay && Object.hasOwn(context.overlay.files, path))
289
+ )).sort()
290
+ if (!exactPaths.length) return
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))
294
+ for (let index = 0; index < exactPaths.length; index++) {
295
+ const path = exactPaths[index]
296
+ const oid = oids[index]
297
+ if (oid && !blobs.has(oid)) throw new Error(`cat-file returned no bytes for ${revision.slice(0, 8)}:${path}`)
298
+ context.sources.set(`${revision}\0${path}`, Promise.resolve(oid ? blobs.get(oid)! : null))
299
+ }
300
+ } catch (error: any) {
301
+ throw new SessionImpactUnavailableError(`session impact cannot batch-read selector sources at ${revision.slice(0, 8)}: ${error?.message ?? String(error)}`)
302
+ }
303
+ }
304
+
305
+ async function impactForEntry(
306
+ context: ImpactReadContext,
307
+ entry: RelationEntry,
308
+ options: { validateUnchanged?: boolean; validationRevision?: string; validationSide?: 'base' | 'head' } = {},
309
+ ): Promise<{ paths: string[]; selectorHits: SessionImpactSelectorHit[] }> {
310
+ const revision = options.validationRevision ?? context.head
311
+ const sourceView = options.validationSide ?? 'head'
312
+ const key = `${context.base}\0${context.head}\0${context.overlay?.revision ?? ''}\0${sourceView}\0${revision}\0${entry.path}\0${entry.selectors.join('\x1e')}\0${options.validateUnchanged ? 1 : 0}`
313
+ let pending = context.entryImpacts.get(key)
314
+ if (pending) return pending
315
+ pending = (async () => {
316
+ // Reuse sessioneval's existing relation claim matcher over the ONE exact base..head changed set. The
317
+ // projector adds no impact-local glob/path grammar; selector entries then narrow that file result.
318
+ const changed = [...context.changedPaths].filter((path) => codeClaims([entry.path], path))
319
+ if (!entry.selectors.length) return { paths: changed.sort(), selectorHits: [] }
320
+ // Resolution is a validity claim about the selected HEAD, independent of whether the file moved.
321
+ // Validating first prevents a dead selector from becoming a vacuous no-hit/fresh answer.
322
+ if (!changed.length && !options.validateUnchanged) return { paths: [], selectorHits: [] }
323
+ const { units } = await validateSelectorEntry(context, revision, entry, sourceView)
324
+ if (!changed.length) return { paths: [], selectorHits: [] }
325
+ const selectorHits: SessionImpactSelectorHit[] = []
326
+ const committed = [...context.committedPaths].some((path) => codeClaims([entry.path], path))
327
+ if (committed) {
328
+ if (!context.didx) throw new SessionImpactUnavailableError('session impact has committed paths but no exact event index')
329
+ const windowKey = `${revision}\0${entry.path}`
330
+ let window = context.windows.get(windowKey)
331
+ if (!window) {
332
+ const projected = pathRangeEvents(context.didx, context.base, entry.path, revision)
333
+ if (!projected) throw new SessionImpactUnavailableError(`session impact cannot place base ${context.base.slice(0, 8)} in the exact event topology`)
334
+ window = projected
335
+ context.windows.set(windowKey, window)
336
+ }
337
+ try {
338
+ const hits = await anchorHitCommits(context.root, window, [...entry.selectors], context.regs)
339
+ for (const hit of hits) selectorHits.push({ path: entry.path, ...hit })
340
+ } catch (error: any) {
341
+ throw new SessionImpactUnavailableError(`session impact selector '${entry.path}#${entry.selectors.join(',#')}' is unavailable: ${error?.message ?? String(error)}`)
342
+ }
343
+ }
344
+ if (context.overlay && context.overlay.paths.some((path) => codeClaims([entry.path], path))) {
345
+ const ranges = sourceView === 'base' ? context.overlay.baseRanges : context.overlay.ranges
346
+ const touched = selectorsHitRanges(units, entry.selectors, ranges[entry.path] ?? [])
347
+ if (touched.length) selectorHits.push({
348
+ path: entry.path,
349
+ commit: `worktree:${context.overlay.revision.slice(0, 12)}`,
350
+ selectors: touched,
351
+ })
352
+ }
353
+ return { paths: selectorHits.length ? changed.sort() : [], selectorHits }
354
+ })()
355
+ context.entryImpacts.set(key, pending)
356
+ return pending
357
+ }
358
+
359
+ async function impactForEntries(
360
+ context: ImpactReadContext,
361
+ entries: readonly RelationEntry[],
362
+ options: { validateUnchanged?: boolean; validationRevision?: string; validationSide?: 'base' | 'head' } = {},
363
+ ): Promise<{ paths: string[]; selectorHits: SessionImpactSelectorHit[] }> {
364
+ const results = await Promise.all(entries.map((entry) => impactForEntry(context, entry, options)))
365
+ return {
366
+ paths: [...new Set(results.flatMap((result) => result.paths))].sort(),
367
+ selectorHits: results.flatMap((result) => result.selectorHits),
368
+ }
369
+ }
370
+
371
+ function pushNodeCause(
372
+ causes: SessionImpactNodeCause[],
373
+ kind: SessionImpactNodeCause['kind'],
374
+ paths: readonly string[] = [],
375
+ scenarios: readonly string[] = [],
376
+ selectorHits: readonly SessionImpactSelectorHit[] = [],
377
+ ): void {
378
+ let cause = causes.find((candidate) => candidate.kind === kind)
379
+ if (!cause) {
380
+ cause = { kind, paths: [], scenarios: [], selectorHits: [] }
381
+ causes.push(cause)
382
+ }
383
+ cause.paths = [...new Set([...cause.paths, ...paths])].sort()
384
+ cause.scenarios = [...new Set([...cause.scenarios, ...scenarios])].sort()
385
+ const seen = new Set(cause.selectorHits.map((hit) => JSON.stringify(hit)))
386
+ for (const hit of selectorHits) {
387
+ const key = JSON.stringify(hit)
388
+ if (!seen.has(key)) { seen.add(key); cause.selectorHits.push(hit) }
389
+ }
390
+ }
391
+
392
+ function parsedRelation(raw: readonly string[], relation: 'code' | 'related', owner: string): RelationEntry[] {
393
+ const parsed = parseRelation([...raw], relation)
394
+ if (parsed.problems.length) {
395
+ throw new SessionImpactUnavailableError(`session impact ${owner} has invalid ${relation}: ${parsed.problems.join('; ')}`)
396
+ }
397
+ return parsed.entries
398
+ }
399
+
400
+ // Public, exact-revision impact projection. It reads both spec trees and all scenario declarations from
401
+ // immutable Git objects, reuses the canonical relation/anchor engine, then re-resolves the caller's selectors
402
+ // before publication. Callers may pass branch names, but never receive a projection spanning two ref states.
403
+ export async function projectSessionImpact(root: string, options: SessionImpactOptions): Promise<SessionImpactProjection> {
404
+ const [base, head] = await Promise.all([
405
+ impactCommit(root, options.base),
406
+ impactCommit(root, options.head),
407
+ ])
408
+ const ancestry = await gitTry(['-C', root, 'merge-base', '--is-ancestor', base, head])
409
+ if (!ancestry.ok) {
410
+ if (ancestry.failure === 'exit') {
411
+ throw new SessionImpactUnavailableError(`session impact base ${base.slice(0, 8)} is not an ancestor of head ${head.slice(0, 8)}; use the session merge-base as base`)
412
+ }
413
+ throw new SessionImpactUnavailableError(`session impact cannot verify base/head ancestry: ${ancestry.stderr.trim() || ancestry.failure}`)
414
+ }
415
+ // 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.
417
+ const treeByRevision = new Map<string, ReadonlyMap<string, string>>()
418
+ const readTree = (revision: string): ReadonlyMap<string, string> => {
419
+ const cached = treeByRevision.get(revision)
420
+ if (cached) return cached
421
+ try {
422
+ const tree = treeTextFiles(root, revision, '.spec')
423
+ treeByRevision.set(revision, tree)
424
+ return tree
425
+ } catch (error: any) {
426
+ throw new SessionImpactUnavailableError(`session impact cannot read ${revision.slice(0, 8)} declarations: ${error?.message ?? String(error)}`)
427
+ }
428
+ }
429
+ const baseTree = readTree(base)
430
+ const headTree = options.overlay ? null : readTree(head)
431
+ const [baseSpecs, headSpecs] = await Promise.all([
432
+ loadSpecs(root, { tip: base, history: null, drift: null, snapshot: { tip: base, files: baseTree } }),
433
+ options.overlay
434
+ ? Promise.resolve(options.overlay.specs)
435
+ : loadSpecs(root, { tip: head, history: null, drift: null, snapshot: { tip: head, files: headTree! } }),
436
+ ])
437
+ const committedChanges = parseNameStatusChanges(await impactGit(
438
+ root,
439
+ ['-c', 'core.quotePath=false', 'diff', '--name-status', '-z', '-M', base, head],
440
+ `diff ${base.slice(0, 8)}..${head.slice(0, 8)}`,
441
+ ))
442
+ const committedPaths = new Set(committedChanges.flatMap((change) => [change.before, change.after]))
443
+ let didx: DriftIndex | null = null
444
+ if (committedPaths.size) {
445
+ try { didx = await driftIndex(root, head) }
446
+ catch (error: any) {
447
+ throw new SessionImpactUnavailableError(`session impact cannot derive the exact ${base.slice(0, 8)}..${head.slice(0, 8)} event window: ${error?.message ?? String(error)}`)
448
+ }
449
+ }
450
+ const changedPaths = new Set([...committedPaths, ...(options.overlay?.paths ?? [])])
451
+ const context: ImpactReadContext = {
452
+ root,
453
+ base,
454
+ head,
455
+ committedPaths,
456
+ changedPaths,
457
+ overlay: options.overlay,
458
+ regs: extractors(root),
459
+ didx,
460
+ entryImpacts: new Map(),
461
+ windows: new Map(),
462
+ sources: new Map(),
463
+ units: new Map(),
464
+ }
465
+ const baseById = new Map<string, SessionImpactSpecSnapshot>(baseSpecs.map((spec) => [spec.id, spec]))
466
+ const headById = new Map<string, SessionImpactSpecSnapshot>(headSpecs.map((spec) => [spec.id, spec]))
467
+ const nodeIds = [...new Set([...baseById.keys(), ...headById.keys()])].sort()
468
+ if (options.overlay) {
469
+ for (const path of options.overlay.paths) {
470
+ if (!Object.hasOwn(options.overlay.files, path)
471
+ || !Object.hasOwn(options.overlay.baseRanges, path)
472
+ || !Object.hasOwn(options.overlay.ranges, path)) {
473
+ throw new SessionImpactUnavailableError(`session impact overlay '${options.overlay.revision}' is incomplete for '${path}'`)
474
+ }
475
+ }
476
+ }
477
+ const scenariosAt = (
478
+ side: 'base' | 'head',
479
+ spec: SessionImpactSpecSnapshot,
480
+ ): Scenario[] => {
481
+ const evalPath = join(dirname(spec.path), 'eval.md')
482
+ let source: string | null
483
+ if (side === 'base') source = baseTree.get(evalPath) ?? null
484
+ else if (options.overlay) {
485
+ if (!Object.hasOwn(options.overlay.evalSources, evalPath)) {
486
+ throw new SessionImpactUnavailableError(`session impact overlay '${options.overlay.revision}' has no declaration snapshot for '${evalPath}'`)
487
+ }
488
+ source = options.overlay.evalSources[evalPath]
489
+ } else source = headTree!.get(evalPath) ?? null
490
+ if (source === null) return []
491
+ try { return parseScenarios(source) }
492
+ catch (error: any) {
493
+ throw new SessionImpactUnavailableError(`session impact cannot parse ${side} declarations '${evalPath}': ${error?.message ?? String(error)}`)
494
+ }
495
+ }
496
+ const baseScenariosById = new Map(baseSpecs.map((spec) => [spec.id, scenariosAt('base', spec)]))
497
+ const headScenariosById = new Map(headSpecs.map((spec) => [spec.id, scenariosAt('head', spec)]))
498
+ const baseSelectorEntries = selectorEntriesForSnapshot(baseSpecs, baseScenariosById)
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')
502
+ await Promise.all([
503
+ ...baseSelectorEntries.map((entry) => validateSelectorEntry(context, base, entry, 'base')),
504
+ ...headSelectorEntries.map((entry) => validateSelectorEntry(context, head, entry, 'head')),
505
+ ])
506
+ const nodes: SessionImpactNode[] = []
507
+
508
+ for (const id of nodeIds) {
509
+ const baseSpec = baseById.get(id)
510
+ const headSpec = headById.get(id)
511
+ const causes: SessionImpactNodeCause[] = []
512
+ const baseSpecPath = baseSpec?.path ?? ''
513
+ const headSpecPath = headSpec?.path ?? ''
514
+ const specPaths = [...new Set([baseSpecPath, headSpecPath].filter(Boolean))]
515
+ const specChanged = [...changedPaths].filter((path) => specPaths.some((claim) => codeClaims([claim], path)))
516
+ if (specChanged.length) pushNodeCause(causes, 'spec', specChanged)
517
+
518
+ if (baseSpec?.relationProblems.length || headSpec?.relationProblems.length) {
519
+ throw new SessionImpactUnavailableError(`session impact node '${id}' has invalid relations: ${[
520
+ ...(baseSpec?.relationProblems ?? []), ...(headSpec?.relationProblems ?? []),
521
+ ].join('; ')}`)
522
+ }
523
+
524
+ const baseEvalPath = baseSpec ? join(dirname(baseSpec.path), 'eval.md') : ''
525
+ const headEvalPath = headSpec ? join(dirname(headSpec.path), 'eval.md') : ''
526
+ const baseScenarios = baseScenariosById.get(id) ?? []
527
+ const headScenarios = headScenariosById.get(id) ?? []
528
+ const evalPaths = [...new Set([baseEvalPath, headEvalPath].filter(Boolean))]
529
+ const evalChanged = [...changedPaths].filter((path) => evalPaths.some((claim) => codeClaims([claim], path)))
530
+ if (evalChanged.length) pushNodeCause(causes, 'eval', evalChanged)
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
+ }
541
+ const nodeCodeMoved = !!baseSpec && !!headSpec
542
+ && JSON.stringify(baseNodeCode.entries) !== JSON.stringify(headNodeCode.entries)
543
+ const nodeCodeReads = nodeCodeMoved
544
+ ? 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' }),
547
+ ])
548
+ : [await impactForEntries(context, (headSpec ? headNodeCode : baseNodeCode).entries, {
549
+ validateUnchanged: !baseSpec || !headSpec,
550
+ validationRevision: headSpec ? head : base,
551
+ validationSide: headSpec ? 'head' : 'base',
552
+ })]
553
+ const nodeCodeImpact = {
554
+ paths: [...new Set(nodeCodeReads.flatMap((read) => read.paths))].sort(),
555
+ selectorHits: nodeCodeReads.flatMap((read) => read.selectorHits),
556
+ }
557
+ if (nodeCodeImpact.paths.length) pushNodeCause(causes, 'code', nodeCodeImpact.paths, [], nodeCodeImpact.selectorHits)
558
+
559
+ // Related declarations belong to their own owners. Combine their parsed DATA entries after validation;
560
+ // parsing one synthetic concatenated relation would turn two legitimate owners naming the same path into
561
+ // a fake duplicate/mixed-form schema error.
562
+ const relatedFor = (spec: SessionImpactSpecSnapshot | undefined, scenarios: readonly Scenario[]) => [
563
+ ...parsedRelation(loadedRelationRows(spec, 'related'), 'related', `node '${id}'`),
564
+ ...scenarios.flatMap((scenario) => (
565
+ parsedRelation(scenario.related ?? [], 'related', `scenario '${id} · ${scenario.name}'`)
566
+ )),
567
+ ]
568
+ const baseRelatedEntries = relatedFor(baseSpec, baseScenarios)
569
+ const headRelatedEntries = relatedFor(headSpec, headScenarios)
570
+ const relatedMoved = !!baseSpec && !!headSpec
571
+ && JSON.stringify(baseRelatedEntries) !== JSON.stringify(headRelatedEntries)
572
+ const relatedReads = relatedMoved
573
+ ? await Promise.all([
574
+ impactForEntries(context, baseRelatedEntries, { validateUnchanged: true, validationRevision: base, validationSide: 'base' }),
575
+ impactForEntries(context, headRelatedEntries, { validateUnchanged: true, validationRevision: head, validationSide: 'head' }),
576
+ ])
577
+ : [await impactForEntries(context, headSpec ? headRelatedEntries : baseRelatedEntries, {
578
+ validateUnchanged: !baseSpec || !headSpec,
579
+ validationRevision: headSpec ? head : base,
580
+ validationSide: headSpec ? 'head' : 'base',
581
+ })]
582
+ const relatedImpact = {
583
+ paths: [...new Set(relatedReads.flatMap((read) => read.paths))].sort(),
584
+ selectorHits: relatedReads.flatMap((read) => read.selectorHits),
585
+ }
586
+ if (relatedImpact.paths.length) pushNodeCause(causes, 'related', relatedImpact.paths, [], relatedImpact.selectorHits)
587
+
588
+ const baseByName = new Map(baseScenarios.map((scenario) => [scenario.name, scenario]))
589
+ const headByName = new Map(headScenarios.map((scenario) => [scenario.name, scenario]))
590
+ const scenarioNames = [...new Set([...baseByName.keys(), ...headByName.keys()])].sort()
591
+ const measured = new Set(options.measurements?.[id] ?? [])
592
+ const scenarios: SessionImpactScenario[] = []
593
+ for (const name of scenarioNames) {
594
+ const before = baseByName.get(name)
595
+ const after = headByName.get(name)
596
+ const state = !before ? 'added' : !after ? 'removed' : 'present'
597
+ const baseScenarioHash = before ? scenarioHash(before) : null
598
+ const headScenarioHash = after ? scenarioHash(after) : null
599
+ 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: [] }
602
+ if (baseAxis.problems.length || headAxis.problems.length) {
603
+ throw new SessionImpactUnavailableError(`session impact scenario '${id} · ${name}' has invalid code: ${[
604
+ ...baseAxis.problems, ...headAxis.problems,
605
+ ].join('; ')}`)
606
+ }
607
+ // Metadata is the RESOLVED measurement pointer, not only bytes authored in this scenario block. A
608
+ // declaration without own code inherits the node relation at each revision, so node #alpha -> #beta is
609
+ // a metadata move for that scenario even though its eval.md block did not change.
610
+ const metadata = !!before && !!after
611
+ && scenarioMetadata(before, baseAxis.entries) !== scenarioMetadata(after, headAxis.entries)
612
+ const effectiveCodeMoved = JSON.stringify(baseAxis.entries) !== JSON.stringify(headAxis.entries)
613
+ const kind: SessionImpactScenarioDelta['kind'] = !before ? 'added' : !after ? 'removed'
614
+ : semantic || metadata ? 'modified' : 'unchanged'
615
+ const codeReads = before && after && effectiveCodeMoved
616
+ ? await Promise.all([
617
+ impactForEntries(context, baseAxis.entries, { validateUnchanged: true, validationRevision: base, validationSide: 'base' }),
618
+ impactForEntries(context, headAxis.entries, { validateUnchanged: true, validationRevision: head, validationSide: 'head' }),
619
+ ])
620
+ : [await impactForEntries(context, (after ? headAxis : baseAxis).entries, {
621
+ validateUnchanged: kind === 'added' || kind === 'removed',
622
+ validationRevision: after ? head : base,
623
+ validationSide: after ? 'head' : 'base',
624
+ })]
625
+ const code = {
626
+ paths: [...new Set(codeReads.flatMap((read) => read.paths))].sort(),
627
+ selectorHits: codeReads.flatMap((read) => read.selectorHits),
628
+ }
629
+ const impact: ScenarioImpactReason[] = []
630
+ if (code.paths.length) impact.push('code')
631
+ if (semantic) impact.push('contract')
632
+ if (measured.has(name)) impact.push('measurement')
633
+ if (code.paths.length) pushNodeCause(causes, 'code', code.paths, [name], code.selectorHits)
634
+ if (semantic) pushNodeCause(causes, 'contract', evalChanged, [name])
635
+ if (measured.has(name)) pushNodeCause(causes, 'measurement', [], [name])
636
+ scenarios.push({
637
+ name,
638
+ state,
639
+ impact,
640
+ baseEffectiveCode: baseAxis.entries,
641
+ headEffectiveCode: headAxis.entries,
642
+ selectorHits: code.selectorHits,
643
+ baseScenarioHash,
644
+ headScenarioHash,
645
+ delta: { kind, semantic, metadata, metadataOnly: metadata && !semantic },
646
+ })
647
+ }
648
+
649
+ if (causes.length || scenarios.some((scenario) => scenario.delta.kind !== 'unchanged' || scenario.impact.length)) {
650
+ nodes.push({ id, causes, scenarios })
651
+ }
652
+ }
653
+
654
+ const [baseAfter, headAfter] = await Promise.all([
655
+ impactCommit(root, options.base),
656
+ impactCommit(root, options.head),
657
+ ])
658
+ if (baseAfter !== base || headAfter !== head) {
659
+ throw new SessionImpactRevisionMovedError(`session impact revisions moved while projecting: base ${base.slice(0, 8)} -> ${baseAfter.slice(0, 8)}, head ${head.slice(0, 8)} -> ${headAfter.slice(0, 8)}; retry the projection`)
660
+ }
661
+ const revision = createHash('sha256').update(JSON.stringify({ base, head, overlay: options.overlay?.revision ?? null, nodes })).digest('hex')
662
+ return { base, head, revision, nodes }
663
+ }
664
+
68
665
  type SessionEvalReading = EvalEntry & { inSession: boolean }
69
666
 
70
667
  export function scopeSessionScenarioRows(
71
668
  current: Scenario[],
72
- base: Scenario[],
73
669
  scenarioInfo: ScenarioInfo[],
74
- nodeCode: string[],
75
- changedPaths: ReadonlySet<string>,
76
- evalFileChanged: boolean,
77
670
  evals: SessionEvalReading[],
671
+ projected: readonly SessionImpactScenario[],
78
672
  ): { scenarios: SessionScenarioInfo[]; evals: SessionEvalReading[] } {
79
- const measured = new Set(evals.filter((reading) => reading.inSession).map((reading) => reading.scenario))
80
- const selected = selectImpactedScenarios(current, base, nodeCode, changedPaths, evalFileChanged, measured)
673
+ const impactByName = new Map(projected
674
+ .filter((scenario) => scenario.state !== 'removed' && scenario.impact.length)
675
+ .map((scenario) => [scenario.name, scenario.impact]))
81
676
  const infoByName = new Map(scenarioInfo.map((scenario) => [scenario.name, scenario]))
82
- const scenarios: SessionScenarioInfo[] = selected.map(({ scenario, impact }) => ({
677
+ const scenarios: SessionScenarioInfo[] = current.filter((scenario) => impactByName.has(scenario.name)).map((scenario) => ({
83
678
  ...(infoByName.get(scenario.name) ?? {
84
679
  name: scenario.name, expected: scenario.expected,
85
680
  ...(scenario.tags?.length ? { tags: scenario.tags } : {}),
86
681
  ...(scenario.test ? { test: scenario.test } : {}),
87
682
  ...(scenario.code?.length ? { code: scenario.code } : {}),
88
683
  }),
89
- impact,
684
+ impact: impactByName.get(scenario.name)!,
90
685
  }))
91
686
  const names = new Set(scenarios.map((scenario) => scenario.name))
92
687
  return { scenarios, evals: evals.filter((reading) => names.has(reading.scenario)) }
@@ -99,10 +694,6 @@ export function completeExportNodeIds(
99
694
  return [...new Set([...changedNodeIds, ...scopedNodeIds])]
100
695
  }
101
696
 
102
- export function mergeBasePath(path: string, oldPaths: ReadonlyMap<string, string>): string {
103
- return oldPaths.get(path) ?? path
104
- }
105
-
106
697
  // one eval reading rendered for the export: the latest measurement of one scenario, with its evidence
107
698
  // resolved to inline bytes (an image data-URI, or transcript text) so the document is self-contained.
108
699
  export type ExportReading = {
@@ -170,6 +761,7 @@ export type ExportModel = {
170
761
  dirtyNonRuntime: number
171
762
  gates: ExportGate[]
172
763
  score: { passed: number; total: number; fresh: number } // the eval summary across all nodes
764
+ impact: SessionImpactProjection
173
765
  nodes: ExportNode[]
174
766
  otherFiles: ExportFile[] // changed files no spec node claims
175
767
  }
@@ -189,23 +781,13 @@ export async function buildExportModel(id: string): Promise<ExportModel | null>
189
781
  const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
190
782
  const ctx = await evalContext(ctxRoot, specs, didx, hidx)
191
783
 
192
- const changedPaths = new Set(payload.diff.map((file) => file.path))
193
- const oldPaths = new Map(payload.diff.flatMap((file) => file.oldPath ? [[file.path, file.oldPath] as const] : []))
194
-
195
784
  // enrich each changed file with its unified diff + full before/after content (derived from the session
196
785
  // worktree at the merge-base ↔ HEAD), so the proof can drill summary → diff → whole-file comparison with no
197
786
  // extra fetch. Capped at MAX_ENRICHED_FILES so a huge changeset can't bloat the page; the rest keep their
198
787
  // row but say so (omitted), never silently blank.
199
- const [base, shaRows, dirtyState] = wtPath ? await Promise.all([
200
- gitA(['-C', wtPath, 'merge-base', mainBranch(), 'HEAD']).then((out) => out.trim()),
201
- gitA(['-C', wtPath, 'rev-list', `${mainBranch()}..HEAD`]),
202
- worktreeDirtyState(wtPath),
203
- ]) : ['', '', { paths: new Set<string>(), oldPaths: new Map<string, string>() }] as const
204
- const dirtyPaths = dirtyState.paths
205
- for (const path of dirtyPaths) changedPaths.add(path)
206
- for (const [path, oldPath] of dirtyState.oldPaths) if (!oldPaths.has(path)) oldPaths.set(path, oldPath)
207
- const shas = new Set(shaRows.split('\n').filter(Boolean))
208
- const scopedNodes = await sessionScopeNodes(id, ctx, changedPaths, dirtyPaths, oldPaths, base, shas)
788
+ const { impact, shas } = await sessionImpactForContext(id, ctx, wtPath)
789
+ const base = impact.base
790
+ const scopedNodes = await sessionScopeNodes(id, ctx, impact, shas)
209
791
  const enriched = new Map<string, ExportFile>()
210
792
  let budget = MAX_ENRICHED_FILES
211
793
  for (const f of payload.diff) {
@@ -277,6 +859,7 @@ export async function buildExportModel(id: string): Promise<ExportModel | null>
277
859
  dirtyNonRuntime: payload.dirtyNonRuntime,
278
860
  gates: gateRows(payload),
279
861
  score: { passed, total, fresh },
862
+ impact,
280
863
  nodes,
281
864
  otherFiles,
282
865
  }
@@ -420,13 +1003,7 @@ function nodeForFile(file: string, specs: Awaited<ReturnType<typeof loadSpecs>>,
420
1003
  }
421
1004
 
422
1005
  function codeClaims(code: string[], file: string): boolean {
423
- return code.some((cf) => {
424
- if (cf === file) return true
425
- const dir = cf.replace(/\/+$/, '') + '/'
426
- if (file.startsWith(dir)) return true
427
- if (cf.includes('*')) return new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$').test(file)
428
- return false
429
- })
1006
+ return code.some((claim) => relationClaimsPath(claim, file))
430
1007
  }
431
1008
 
432
1009
  // ---- worktree resolution (no sessions.ts edit: read git's own worktree list) ----
@@ -460,22 +1037,94 @@ export function parsePorcelainPaths(out: string): Set<string> {
460
1037
  return paths
461
1038
  }
462
1039
 
463
- export function parsePorcelainRenames(out: string): Map<string, string> {
464
- const renames = new Map<string, string>()
465
- const records = out.split('\0')
466
- for (let i = 0; i < records.length; i++) {
467
- const record = records[i]
468
- if (!record) continue
469
- const status = record.slice(0, 2)
470
- const path = record.slice(3)
471
- if ((status.includes('R') || status.includes('C')) && records[i + 1]) renames.set(path, records[++i])
472
- }
473
- return renames
1040
+ async function worktreeDirtyState(wtPath: string): Promise<{ paths: Set<string>; status: string }> {
1041
+ const out = await impactGit(
1042
+ wtPath,
1043
+ ['-c', 'core.quotePath=false', 'status', '--porcelain=v1', '-z', '--untracked-files=all'],
1044
+ 'snapshot worktree status',
1045
+ )
1046
+ return { paths: parsePorcelainPaths(out), status: out }
474
1047
  }
475
1048
 
476
- async function worktreeDirtyState(wtPath: string): Promise<{ paths: Set<string>; oldPaths: Map<string, string> }> {
477
- const out = await gitA(['-C', wtPath, '-c', 'core.quotePath=false', 'status', '--porcelain=v1', '-z', '--untracked-files=all'])
478
- return { paths: parsePorcelainPaths(out), oldPaths: parsePorcelainRenames(out) }
1049
+ // Capture one immutable description of the live worktree overlay. One rename-aware change set supplies both
1050
+ // path images; untracked files are wholly new result ranges. The canonical anchor parser
1051
+ // turns those patches into ranges, and projectSessionImpact consumes the snapshot beside base..HEAD. The
1052
+ // outer session content double-read fences concurrent edits before this result can publish.
1053
+ export async function sessionImpactOverlay(
1054
+ wtPath: string,
1055
+ dirty: { paths: ReadonlySet<string>; status: string },
1056
+ ): Promise<SessionImpactOverlay | undefined> {
1057
+ if (!dirty.paths.size) return undefined
1058
+ const untracked = new Set(untrackedPaths(dirty.status))
1059
+ const paths = [...dirty.paths].sort()
1060
+ const baseRanges: Record<string, [number, number][]> = {}
1061
+ const ranges: Record<string, [number, number][]> = {}
1062
+ const files: Record<string, string | null> = {}
1063
+ const fingerprints: string[] = []
1064
+ const bytesByPath = new Map<string, Buffer | null>()
1065
+ for (const path of paths) {
1066
+ let bytes: Buffer | null = null
1067
+ try { bytes = await readFile(join(wtPath, path)) }
1068
+ catch (error: any) {
1069
+ if (error?.code !== 'ENOENT') throw new SessionImpactUnavailableError(`session impact cannot snapshot '${path}': ${error?.message ?? String(error)}`)
1070
+ }
1071
+ bytesByPath.set(path, bytes)
1072
+ files[path] = bytes?.toString('utf8') ?? null
1073
+ baseRanges[path] = []
1074
+ ranges[path] = []
1075
+ fingerprints.push(`${path}\0${bytes ? createHash('sha256').update(bytes).digest('hex') : '<gone>'}`)
1076
+ }
1077
+ const indexDir = await mkdtemp(join(tmpdir(), 'spex-impact-index-'))
1078
+ const indexFile = join(indexDir, 'index')
1079
+ try {
1080
+ await impactIndexGit(wtPath, indexFile, ['read-tree', 'HEAD'], 'seed the isolated worktree index')
1081
+ await impactIndexGit(wtPath, indexFile, ['add', '-A', '--', ...paths], 'snapshot dirty paths in the isolated worktree index')
1082
+ const trackedChanges = parseNameStatusChanges(await impactIndexGit(
1083
+ wtPath,
1084
+ indexFile,
1085
+ ['-c', 'core.quotePath=false', 'diff', '--cached', 'HEAD', '--name-status', '-z', '-M'],
1086
+ 'snapshot worktree change identities',
1087
+ ))
1088
+ for (const change of trackedChanges) {
1089
+ if (!Object.hasOwn(baseRanges, change.before) || !Object.hasOwn(ranges, change.after))
1090
+ throw new SessionImpactUnavailableError(`session impact dirty snapshot omitted '${change.before}' -> '${change.after}'`)
1091
+ const patch = await impactIndexGit(
1092
+ wtPath,
1093
+ indexFile,
1094
+ ['-c', 'core.quotePath=false', 'diff', '--cached', 'HEAD', '--unified=0', '--no-ext-diff', '-M', '--', ...new Set([change.before, change.after])],
1095
+ `snapshot worktree patch '${change.before}' -> '${change.after}'`,
1096
+ )
1097
+ baseRanges[change.before] = diffHunkRanges(patch, 'old')
1098
+ ranges[change.after] = diffHunkRanges(patch, 'new')
1099
+ fingerprints.push(`${change.status}\0${change.before}\0${change.after}\0${createHash('sha256').update(patch).digest('hex')}`)
1100
+ }
1101
+ } finally {
1102
+ await rm(indexDir, { recursive: true, force: true })
1103
+ }
1104
+ for (const path of untracked) fingerprints.push(`untracked\0${path}\0${bytesByPath.get(path)?.length ?? 0}`)
1105
+ const loaded = await loadSpecs(wtPath, { history: null, drift: null })
1106
+ const specs: SessionImpactSpecSnapshot[] = loaded.map((spec) => ({
1107
+ id: spec.id,
1108
+ path: spec.path,
1109
+ code: spec.code,
1110
+ codeScoped: spec.codeScoped,
1111
+ related: spec.related,
1112
+ relatedScoped: spec.relatedScoped,
1113
+ relationProblems: spec.relationProblems,
1114
+ }))
1115
+ const evalSources: Record<string, string | null> = {}
1116
+ for (const spec of specs) {
1117
+ const evalPath = join(dirname(spec.path), 'eval.md')
1118
+ try { evalSources[evalPath] = await readFile(join(wtPath, evalPath), 'utf8') }
1119
+ catch (error: any) {
1120
+ if (error?.code === 'ENOENT') evalSources[evalPath] = null
1121
+ else throw new SessionImpactUnavailableError(`session impact cannot snapshot '${evalPath}': ${error?.message ?? String(error)}`)
1122
+ }
1123
+ }
1124
+ const revision = createHash('sha256')
1125
+ .update([dirty.status, ...fingerprints, JSON.stringify(specs), JSON.stringify(evalSources)].join('\0'))
1126
+ .digest('hex')
1127
+ return { revision, paths, baseRanges, ranges, specs, evalSources, files }
479
1128
  }
480
1129
 
481
1130
  // ---- the renderer ----
@@ -585,7 +1234,7 @@ export function renderExportHtml(m: ExportModel): string {
585
1234
  const idShort = m.id.slice(0, 8)
586
1235
  const ribbon = [
587
1236
  ...m.gates.map((g) => `<span class="chip ${g.ok ? 'ok' : 'bad'}" title="${esc(g.detail)}">${g.ok ? '✓' : '✗'} ${esc(g.label)}</span>`),
588
- m.score.total ? `<span class="chip ${m.score.passed === m.score.total ? 'ok' : 'warn'}" title="affected scenarios fresh-passing; ${m.score.fresh}/${m.score.total} have a fresh reading">★ ${m.score.passed}/${m.score.total} passing</span>` : `<span class="chip warn" title="no scenario is affected by this worktree">★ no affected scenarios</span>`,
1237
+ m.score.total ? `<span class="chip ${m.score.passed === m.score.total ? 'ok' : 'warn'}" title="affected scenarios fresh-passing; ${m.score.fresh}/${m.score.total} have a fresh measurement">★ ${m.score.passed}/${m.score.total} passing</span>` : `<span class="chip warn" title="no scenario is affected by this worktree">★ no affected scenarios</span>`,
589
1238
  ].join('')
590
1239
  const gates = m.gates.map((g) => `<li class="${g.ok ? 'ok' : 'bad'}"><span class="gmark">${g.ok ? '✓' : '✗'}</span><span class="glabel">${esc(g.label)}</span><span class="gdetail">${esc(g.detail)}</span></li>`).join('')
591
1240
  const otherBlock = m.otherFiles.length
@@ -720,8 +1369,11 @@ export type SessionEvalNode = {
720
1369
  // Changed frontend code that no declared scenario covers. This is node-level UNKNOWN coverage, never a
721
1370
  // synthetic scenario: it stays outside scenario totals and filters while remaining visible to consumers.
722
1371
  unknownCoverage: string[]
723
- // Already scoped by selectImpactedScenarios. Consumers must not infer impact from node membership,
724
- // freshness, or the full eval.md again.
1372
+ // Node-level review context from the SAME exact impact projection. Related movement lives here and never
1373
+ // becomes a synthetic scenario reason.
1374
+ causes: SessionImpactNodeCause[]
1375
+ // Already scoped by the canonical exact impact projection. Consumers must not infer impact from node
1376
+ // membership, freshness, or the full eval.md again.
725
1377
  scenarios: SessionScenarioInfo[]
726
1378
  // each reading carries the trunk eval-concern thread for its (node, scenario) ([[remark-teeth]] / directive
727
1379
  // 3): the server-side join (attached by evalTimeline as `EvalEntry.thread`), so the session tab's event
@@ -738,6 +1390,7 @@ export type SessionEvals = {
738
1390
  dirtyNonRuntime: number
739
1391
  gates: ExportGate[]
740
1392
  nodes: SessionEvalNode[]
1393
+ impact: SessionImpactProjection
741
1394
  summary: SessionEvalSummary
742
1395
  evalRevision: SessionEvalRevision
743
1396
  }
@@ -767,10 +1420,6 @@ export type SessionEvalProjection = {
767
1420
  lastKnown?: { generation: number; revision: string; value: SessionEvalSummary }
768
1421
  }
769
1422
 
770
- export class SessionEvalUnavailableError extends Error {
771
- override name = 'SessionEvalUnavailableError'
772
- }
773
-
774
1423
  // The one count projection over the already-affected rows. This is the backend source both the graph
775
1424
  // glance and the demand full model carry; consumers never repeat impact selection or score classification.
776
1425
  export function sessionEvalSummary(nodes: SessionEvalNode[]): SessionEvalSummary {
@@ -801,61 +1450,55 @@ export function sessionEvalSummary(nodes: SessionEvalNode[]): SessionEvalSummary
801
1450
  async function sessionScopeNodes(
802
1451
  id: string,
803
1452
  ctx: Awaited<ReturnType<typeof evalContext>>,
804
- changedPaths: ReadonlySet<string>,
805
- dirtyPaths: ReadonlySet<string>,
806
- oldPaths: ReadonlyMap<string, string>,
807
- base: string,
1453
+ impact: SessionImpactProjection,
808
1454
  shas: ReadonlySet<string>,
809
1455
  latestOnly = false,
810
1456
  ): Promise<SessionEvalNode[]> {
811
1457
  const evalById = new Map(ctx.ynodes.map((node) => [node.id, node]))
1458
+ const specById = new Map(ctx.specs.map((spec) => [spec.id, spec]))
812
1459
  const nodes: SessionEvalNode[] = []
813
1460
 
814
- for (const spec of ctx.specs) {
1461
+ for (const projected of impact.nodes) {
1462
+ const spec = specById.get(projected.id)
1463
+ if (!spec) continue // removed nodes remain fully explained by impact.nodes; they have no live eval rows.
815
1464
  const evalNode = evalById.get(spec.id)
816
1465
  const current = evalNode?.scenarios ?? []
817
1466
  // Unknown means the node has no measurement contract at all. A node that has eval.md is known even
818
1467
  // when individual scenarios narrow their code axes; partial scenario ownership is not a synthetic gap.
819
- const unknownCoverage = evalNode ? [] : unknownCoveragePaths(spec.code, changedPaths)
1468
+ const unknownCoverage = evalNode ? [] : [...new Set(projected.causes
1469
+ .filter((cause) => cause.kind === 'code')
1470
+ .flatMap((cause) => cause.paths)
1471
+ .filter(isUiPath))]
820
1472
 
821
1473
  if (!evalNode) {
822
1474
  if (unknownCoverage.length) {
823
1475
  nodes.push({
824
1476
  id: spec.id, title: spec.title, hue: spec.hue, desc: spec.desc,
825
1477
  hasEvalFile: false, uncoveredFrontend: true, unknownCoverage,
1478
+ causes: projected.causes,
826
1479
  scenarios: [], evals: [],
827
1480
  })
828
1481
  }
829
1482
  continue
830
1483
  }
831
1484
 
832
- const evalFileChanged = changedPaths.has(evalNode.evalPath)
833
- const sidecarPath = relative(ctx.root, evalNode.sidecarPath)
834
- if (!sessionEvalNodeCandidate(current, spec.code, evalNode.evalPath, sidecarPath, changedPaths, dirtyPaths)) continue
835
-
836
1485
  const timeline = await evalTimeline(spec.id, ctx)
837
1486
  // A reading is this session's own when the session filed it OR its anchor is a branch commit. This is
838
1487
  // the same marker the UI and CLI render; measurement impact consumes that marker instead of inventing
839
1488
  // another attribution rule.
840
1489
  const evals = timeline.readings.map((reading) => ({
841
1490
  ...reading,
842
- inSession: reading.by === id || shas.has(reading.codeSha),
1491
+ inSession: readingInSession(reading, id, shas),
843
1492
  }))
844
- const baseEvalPath = mergeBasePath(evalNode.evalPath, oldPaths)
845
- const baseHasEval = evalFileChanged && base
846
- ? (await gitA(['-C', ctx.root, 'ls-tree', '--name-only', base, '--', baseEvalPath])).trim() !== ''
847
- : false
848
- const baseScenarios = baseHasEval
849
- ? parseScenarios(await gitA(['-C', ctx.root, 'show', `${base}:${baseEvalPath}`]))
850
- : []
851
- const scoped = scopeSessionScenarioRows(current, baseScenarios, timeline.scenarios, spec.code, changedPaths, evalFileChanged, evals)
852
- if (!scoped.scenarios.length && !unknownCoverage.length) continue
1493
+ const scoped = scopeSessionScenarioRows(current, timeline.scenarios, evals, projected.scenarios)
1494
+ if (!scoped.scenarios.length && !unknownCoverage.length && !projected.causes.length) continue
853
1495
 
854
1496
  nodes.push({
855
1497
  id: spec.id, title: spec.title, hue: spec.hue, desc: spec.desc,
856
1498
  hasEvalFile: timeline.hasEvalFile,
857
1499
  uncoveredFrontend: !timeline.hasEvalFile && unknownCoverage.length > 0,
858
1500
  unknownCoverage,
1501
+ causes: projected.causes,
859
1502
  scenarios: scoped.scenarios,
860
1503
  // Preserve the whole A/B history for selected scenarios. Fresh, stale, legacy and missing remain
861
1504
  // honest downstream states; impact selection never removes a row because its reading is stale.
@@ -866,6 +1509,57 @@ async function sessionScopeNodes(
866
1509
  return nodes
867
1510
  }
868
1511
 
1512
+ function readingInSession(reading: { by?: string; codeSha: string }, id: string, shas: ReadonlySet<string>): boolean {
1513
+ return reading.by === id || shas.has(reading.codeSha)
1514
+ }
1515
+
1516
+ function sessionMeasurements(
1517
+ id: string,
1518
+ ctx: Awaited<ReturnType<typeof evalContext>>,
1519
+ shas: ReadonlySet<string>,
1520
+ ): Record<string, string[]> {
1521
+ const measurements: Record<string, string[]> = {}
1522
+ for (const node of ctx.ynodes) {
1523
+ const names = [...new Set(readReadings(node.sidecarPath)
1524
+ .filter((reading) => readingInSession(reading, id, shas))
1525
+ .map((reading) => reading.scenario))]
1526
+ if (names.length) measurements[node.id] = names
1527
+ }
1528
+ return measurements
1529
+ }
1530
+
1531
+ async function sessionImpactForContext(
1532
+ id: string,
1533
+ ctx: Awaited<ReturnType<typeof evalContext>>,
1534
+ wtPath: string | null,
1535
+ ): Promise<{ impact: SessionImpactProjection; shas: Set<string> }> {
1536
+ const root = wtPath ?? ctx.root
1537
+ if (!wtPath) {
1538
+ const head = await impactCommit(root, 'HEAD')
1539
+ const impact = await projectSessionImpact(root, {
1540
+ base: head,
1541
+ head,
1542
+ measurements: sessionMeasurements(id, ctx, new Set()),
1543
+ })
1544
+ return { impact, shas: new Set() }
1545
+ }
1546
+ const [base, head, shaRows, dirty] = await Promise.all([
1547
+ impactGit(wtPath, ['merge-base', mainBranch(), 'HEAD'], 'resolve session merge-base').then((out) => out.trim()),
1548
+ impactCommit(wtPath, 'HEAD'),
1549
+ impactGit(wtPath, ['rev-list', `${mainBranch()}..HEAD`], 'read session commits'),
1550
+ worktreeDirtyState(wtPath),
1551
+ ])
1552
+ const shas = new Set(shaRows.split('\n').filter(Boolean))
1553
+ const overlay = await sessionImpactOverlay(wtPath, dirty)
1554
+ const impact = await projectSessionImpact(wtPath, {
1555
+ base,
1556
+ head,
1557
+ overlay,
1558
+ measurements: sessionMeasurements(id, ctx, shas),
1559
+ })
1560
+ return { impact, shas }
1561
+ }
1562
+
869
1563
  type ReviewPayloadValue = NonNullable<Awaited<ReturnType<typeof reviewPayload>>>
870
1564
  type SessionEvalModel = Omit<SessionEvals, 'summary' | 'evalRevision'>
871
1565
 
@@ -882,21 +1576,8 @@ async function buildSessionEvalModel(
882
1576
  const specById = new Map(specs.map((s) => [s.id, s]))
883
1577
  const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
884
1578
  const ctx = await evalContext(ctxRoot, specs, didx, hidx)
885
- const changedPaths = new Set(payload.diff.map((file) => file.path))
886
- const oldPaths = new Map(payload.diff.flatMap((file) => file.oldPath ? [[file.path, file.oldPath] as const] : []))
887
- const [base, shaRows, dirtyState] = wtPath ? await Promise.all([
888
- gitA(['-C', wtPath, 'merge-base', mainBranch(), 'HEAD']).then((out) => out.trim()),
889
- gitA(['-C', wtPath, 'rev-list', `${mainBranch()}..HEAD`]),
890
- worktreeDirtyState(wtPath),
891
- ]) : ['', '', { paths: new Set<string>(), oldPaths: new Map<string, string>() }] as const
892
- const dirtyPaths = dirtyState.paths
893
- // A session evaluation is the proposal as it exists now, not only its committed slice. A dirty source,
894
- // staged rename, draft eval.md, or uncommitted sidecar therefore enters the SAME affected selector.
895
- for (const path of dirtyPaths) changedPaths.add(path)
896
- for (const [path, oldPath] of dirtyState.oldPaths) if (!oldPaths.has(path)) oldPaths.set(path, oldPath)
897
- // this session's own commits — the membership test behind `inSession` and measurement impact
898
- const shas = new Set(shaRows.split('\n').filter(Boolean))
899
- const nodes = await sessionScopeNodes(id, ctx, changedPaths, dirtyPaths, oldPaths, base, shas, latestOnly)
1579
+ const { impact, shas } = await sessionImpactForContext(id, ctx, wtPath)
1580
+ const nodes = await sessionScopeNodes(id, ctx, impact, shas, latestOnly)
900
1581
  // nodes with in-session measurements lead, then the most-measured — the session's own evidence first.
901
1582
  nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length)
902
1583
  || (b.scenarios.length - a.scenarios.length) || (b.unknownCoverage.length - a.unknownCoverage.length))
@@ -911,6 +1592,7 @@ async function buildSessionEvalModel(
911
1592
  dirtyNonRuntime: payload.dirtyNonRuntime,
912
1593
  gates: gateRows(payload),
913
1594
  nodes,
1595
+ impact,
914
1596
  }
915
1597
  }
916
1598
 
@@ -965,9 +1647,17 @@ type SummaryBuildResult =
965
1647
 
966
1648
  export type SessionEvalSummaryBuilder = (id: string, path: string) => Promise<SummaryBuildResult>
967
1649
 
1650
+ type DemandJob<T> = {
1651
+ id: string
1652
+ run: () => Promise<T>
1653
+ resolve: (value: T) => void
1654
+ reject: (error: unknown) => void
1655
+ }
1656
+
968
1657
  type ProjectionEntry = {
969
1658
  id: string
970
1659
  path: string
1660
+ liveness: string
971
1661
  generation: number
972
1662
  phase: SessionEvalProjection['phase']
973
1663
  current?: { generation: number; revision: string; value: SessionEvalSummary }
@@ -978,6 +1668,15 @@ type ProjectionEntry = {
978
1668
 
979
1669
  type ProjectionTarget = 'all' | { id?: string; path?: string }
980
1670
 
1671
+ // Summary builds touch a session worktree's diff, history, and eval sidecars. Running one job per row
1672
+ // multiplies those git children and their parsed indexes by the retained session count, so the shared
1673
+ // projection queue has one bounded capacity for every project. A deployment can tune the capacity, but the
1674
+ // default is deliberately serial: board assembly remains responsive and memory has a natural settle point.
1675
+ const configuredProjectionConcurrency = Number(process.env.SPEXCODE_SESSION_EVAL_CONCURRENCY || 1)
1676
+ const PROJECTION_CONCURRENCY = Number.isFinite(configuredProjectionConcurrency)
1677
+ ? Math.max(1, Math.floor(configuredProjectionConcurrency))
1678
+ : 1
1679
+
981
1680
  // Pure generation coordinator around an injected stable builder. Snapshot construction only serializes
982
1681
  // entries and authorizes the newest dirty generations; the async batch runs after that snapshot has captured
983
1682
  // `updating(lastKnown)`, then emits one completion nudge for all stable/error results in the batch.
@@ -988,15 +1687,66 @@ export class SessionEvalProjectionCache {
988
1687
  private readonly observerWaiters = new Set<() => void>()
989
1688
  private batch: Promise<void> | null = null
990
1689
  private notify: () => void
991
-
992
- constructor(private readonly build: SessionEvalSummaryBuilder, notify: () => void = () => {}, epoch: string = randomUUID()) {
1690
+ private precompute: boolean
1691
+ private readonly demandQueue: DemandJob<any>[] = []
1692
+ private readonly demands = new Map<string, Promise<unknown>>()
1693
+
1694
+ constructor(
1695
+ private readonly build: SessionEvalSummaryBuilder,
1696
+ notify: () => void = () => {},
1697
+ epoch: string = randomUUID(),
1698
+ precompute = true,
1699
+ ) {
993
1700
  this.notify = notify
994
1701
  this.epoch = epoch
1702
+ this.precompute = precompute
995
1703
  }
996
1704
 
997
1705
  setNotify(notify: () => void): void { this.notify = notify }
998
1706
 
999
- snapshot(sessions: { id: string; path: string }[]): Map<string, SessionEvalProjection> {
1707
+ setPrecompute(enabled: boolean): void {
1708
+ if (this.precompute === enabled) return
1709
+ this.precompute = enabled
1710
+ for (const entry of this.entries.values()) {
1711
+ if (!enabled) {
1712
+ // A running build is allowed to settle; dropping only queued work prevents a last subscriber's
1713
+ // departure from starting another historical walk after the era has ended.
1714
+ if (entry.running == null) entry.scheduled = null
1715
+ continue
1716
+ }
1717
+ this.authorize(entry)
1718
+ }
1719
+ if (enabled) queueMicrotask(() => this.startBatch())
1720
+ }
1721
+
1722
+ private authorize(entry: ProjectionEntry): void {
1723
+ if (!this.precompute || entry.liveness === 'offline' || entry.observerHolds.size) return
1724
+ if ((entry.phase === 'loading' || entry.phase === 'updating')
1725
+ && entry.running !== entry.generation && entry.scheduled !== entry.generation)
1726
+ entry.scheduled = entry.generation
1727
+ }
1728
+
1729
+ private ensureEntry(id: string, path: string): ProjectionEntry {
1730
+ let entry = this.entries.get(id)
1731
+ if (entry) {
1732
+ entry.path = path || entry.path
1733
+ return entry
1734
+ }
1735
+ entry = {
1736
+ id,
1737
+ path,
1738
+ liveness: 'offline',
1739
+ generation: 0,
1740
+ phase: 'loading',
1741
+ scheduled: null,
1742
+ running: null,
1743
+ observerHolds: new Set(),
1744
+ }
1745
+ this.entries.set(id, entry)
1746
+ return entry
1747
+ }
1748
+
1749
+ snapshot(sessions: { id: string; path: string; liveness?: string }[]): Map<string, SessionEvalProjection> {
1000
1750
  const live = new Set(sessions.map((session) => session.id))
1001
1751
  for (const id of this.entries.keys()) if (!live.has(id)) this.entries.delete(id)
1002
1752
  const out = new Map<string, SessionEvalProjection>()
@@ -1006,6 +1756,8 @@ export class SessionEvalProjectionCache {
1006
1756
  entry = {
1007
1757
  id: session.id,
1008
1758
  path: session.path,
1759
+ // Unit tests and direct callers omit liveness; those callers retain the historical eager default.
1760
+ liveness: session.liveness ?? 'online',
1009
1761
  generation: 0,
1010
1762
  phase: 'loading',
1011
1763
  scheduled: null,
@@ -1013,14 +1765,19 @@ export class SessionEvalProjectionCache {
1013
1765
  observerHolds: new Set(),
1014
1766
  }
1015
1767
  this.entries.set(session.id, entry)
1016
- } else entry.path = session.path
1768
+ } else {
1769
+ entry.path = session.path
1770
+ entry.liveness = session.liveness ?? 'online'
1771
+ }
1017
1772
  entry.observerHolds = new Set([...this.observerHolds]
1018
1773
  .filter(([, target]) => this.matches(entry!, target))
1019
1774
  .map(([observer]) => observer))
1020
1775
  if (entry.observerHolds.size) entry.phase = 'updating'
1021
- if ((entry.phase === 'loading' || entry.phase === 'updating')
1022
- && entry.observerHolds.size === 0
1023
- && entry.running !== entry.generation && entry.scheduled !== entry.generation) entry.scheduled = entry.generation
1776
+ // Dormant history is demand-only. The graph still exposes a loading/last-known projection for an
1777
+ // offline row, but does not fan out a summary build for every retained session. Selecting that row's
1778
+ // eval calls buildSessionEvals directly; live rows are eager only during a delta stream era.
1779
+ if (session.liveness === 'offline' && entry.running == null) entry.scheduled = null
1780
+ this.authorize(entry)
1024
1781
  out.set(session.id, this.project(entry))
1025
1782
  }
1026
1783
  queueMicrotask(() => this.startBatch())
@@ -1094,6 +1851,25 @@ export class SessionEvalProjectionCache {
1094
1851
  return entry ? this.project(entry) : null
1095
1852
  }
1096
1853
 
1854
+ async demand<T>(id: string, path: string, run: () => Promise<T>): Promise<T> {
1855
+ const existing = this.demands.get(id)
1856
+ if (existing) return existing as Promise<T>
1857
+ 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
1861
+ let resolve!: (value: T) => void
1862
+ let reject!: (error: unknown) => void
1863
+ const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
1864
+ this.demands.set(id, promise)
1865
+ this.demandQueue.push({ id, run, resolve, reject })
1866
+ promise.finally(() => {
1867
+ if (this.demands.get(id) === promise) this.demands.delete(id)
1868
+ }).catch(() => {})
1869
+ queueMicrotask(() => this.startBatch())
1870
+ return promise
1871
+ }
1872
+
1097
1873
  async idle(): Promise<void> {
1098
1874
  await Promise.resolve()
1099
1875
  while (this.batch) await this.batch
@@ -1128,19 +1904,28 @@ export class SessionEvalProjectionCache {
1128
1904
 
1129
1905
  private startBatch(): void {
1130
1906
  if (this.batch) return
1131
- const hasScheduled = () => [...this.entries.values()].some((entry) => entry.scheduled != null && entry.running == null)
1132
- if (!hasScheduled()) return
1907
+ const hasPending = () => this.demandQueue.length > 0
1908
+ || [...this.entries.values()].some((entry) => entry.scheduled != null && entry.running == null)
1909
+ if (!hasPending()) return
1133
1910
  this.batch = (async () => {
1134
1911
  let publish = false
1135
- while (hasScheduled()) {
1136
- const jobs = [...this.entries.values()].filter((entry) => entry.scheduled != null && entry.running == null)
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)
1137
1922
  const changed = await Promise.all(jobs.map((entry) => this.runEntry(entry)))
1138
1923
  publish = publish || changed.some(Boolean)
1139
1924
  }
1140
1925
  if (publish) this.notify()
1141
1926
  })().finally(() => {
1142
1927
  this.batch = null
1143
- if (hasScheduled()) this.startBatch()
1928
+ if (hasPending()) this.startBatch()
1144
1929
  })
1145
1930
  }
1146
1931
 
@@ -1198,7 +1983,7 @@ async function buildSummaryAttempt(id: string, _path: string): Promise<SummaryBu
1198
1983
  }
1199
1984
 
1200
1985
  const summaryByContent = new Map<string, SessionEvalSummary>()
1201
- const projectionCache = new SessionEvalProjectionCache(buildSummaryAttempt)
1986
+ const projectionCache = new SessionEvalProjectionCache(buildSummaryAttempt, () => {}, randomUUID(), false)
1202
1987
  const OBSERVER_RECOVERY_TIMEOUT_MS = 10_000
1203
1988
 
1204
1989
  async function awaitObservableInputs(id: string, path: string): Promise<void> {
@@ -1207,9 +1992,16 @@ async function awaitObservableInputs(id: string, path: string): Promise<void> {
1207
1992
  }
1208
1993
 
1209
1994
  export function setSessionEvalProjectionNotify(notify: () => void): void { projectionCache.setNotify(notify) }
1210
- export function sessionEvalProjections(sessions: { id: string; path: string }[]): Map<string, SessionEvalProjection> {
1995
+ export function setSessionEvalProjectionWarmup(enabled: boolean): void { projectionCache.setPrecompute(enabled) }
1996
+ export function sessionEvalProjections(sessions: { id: string; path: string; liveness?: string }[]): Map<string, SessionEvalProjection> {
1211
1997
  return projectionCache.snapshot(sessions)
1212
1998
  }
1999
+ // The single-session READ of the same projection: no entry is minted, no build is scheduled, no generation
2000
+ // moves. `null` means this cache has never held that session — a caller that must distinguish "not computed"
2001
+ // from "computed and empty" gets to; one that builds instead goes through buildSessionEvals.
2002
+ export function sessionEvalProjection(id: string): SessionEvalProjection | null {
2003
+ return projectionCache.get(id)
2004
+ }
1213
2005
  export function invalidateSessionEvalProjections(target: 'all' | { id?: string; path?: string } = 'all'): number {
1214
2006
  return projectionCache.invalidate(target)
1215
2007
  }
@@ -1225,35 +2017,39 @@ export function releaseSessionEvalProjectionObserver(observer: string): boolean
1225
2017
  export async function awaitSessionEvalProjectionIdle(): Promise<void> { await projectionCache.idle() }
1226
2018
 
1227
2019
  export async function buildSessionEvals(id: string): Promise<SessionEvals | null> {
1228
- // A full model is demand-only. It fences itself against both the content fingerprint and the graph cache's
1229
- // generation, and only publishes its summary when it is still the newest observed generation.
2020
+ // A full model is demand-only, but it still runs through the projection queue. This gives a selected session
2021
+ // priority over unrelated queued summaries without opening a second git/build lane.
1230
2022
  for (;;) {
1231
- await projectionCache.idle()
1232
- const payload = await reviewPayload(id)
1233
- if (!payload) return null
1234
- const wtPath = worktreePathForBranch(payload.branch)
1235
- const ctxPath = wtPath ?? repoRoot()
1236
- await awaitObservableInputs(id, ctxPath)
1237
- const known = projectionCache.get(id)
1238
- const generation = known?.generation ?? 0
1239
- const before = await sessionEvalContentRevision(ctxPath)
1240
- const model = await buildSessionEvalModel(id, payload, wtPath, false)
1241
- const after = await sessionEvalContentRevision(ctxPath)
1242
- const current = projectionCache.get(id)
1243
- if (before !== after || projectionCache.isObserverHeld(id, ctxPath)
1244
- || (current && current.generation !== generation)) {
1245
- if (before !== after) projectionCache.invalidate({ id })
1246
- continue
1247
- }
1248
- const summary = sessionEvalSummary(model.nodes)
1249
- const cacheKey = `${id}\0${after}`
1250
- for (const key of summaryByContent.keys()) if (key.startsWith(`${id}\0`)) summaryByContent.delete(key)
1251
- summaryByContent.set(cacheKey, summary)
1252
- if (current) projectionCache.accept(id, generation, after, summary)
2023
+ const attempt = await projectionCache.demand(id, '', async () => {
2024
+ const payload = await reviewPayload(id)
2025
+ if (!payload) return { kind: 'missing' as const }
2026
+ const wtPath = worktreePathForBranch(payload.branch)
2027
+ const ctxPath = wtPath ?? repoRoot()
2028
+ const known = projectionCache.get(id)
2029
+ const generation = known?.generation ?? 0
2030
+ await awaitObservableInputs(id, ctxPath)
2031
+ const before = await sessionEvalContentRevision(ctxPath)
2032
+ const model = await buildSessionEvalModel(id, payload, wtPath, false)
2033
+ const after = await sessionEvalContentRevision(ctxPath)
2034
+ const current = projectionCache.get(id)
2035
+ if (before !== after || projectionCache.isObserverHeld(id, ctxPath)
2036
+ || (current && current.generation !== generation)) {
2037
+ if (before !== after) projectionCache.invalidate({ id })
2038
+ return { kind: 'retry' as const }
2039
+ }
2040
+ 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)
2044
+ if (current) projectionCache.accept(id, generation, after, summary)
2045
+ return { kind: 'ready' as const, model, summary, generation, revision: after }
2046
+ })
2047
+ if (attempt.kind === 'missing') return null
2048
+ if (attempt.kind === 'retry') continue
1253
2049
  return {
1254
- ...model,
1255
- summary,
1256
- evalRevision: { epoch: projectionCache.epoch, generation, content: after },
2050
+ ...attempt.model,
2051
+ summary: attempt.summary,
2052
+ evalRevision: { epoch: projectionCache.epoch, generation: attempt.generation, content: attempt.revision },
1257
2053
  }
1258
2054
  }
1259
2055
  }