spexcode 0.5.2 → 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 (151) 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 +13 -52
  8. package/spec-cli/src/cli.ts +209 -71
  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 +946 -71
  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/index.ts +95 -21
  22. package/spec-cli/src/init.ts +47 -8
  23. package/spec-cli/src/issues.ts +36 -3
  24. package/spec-cli/src/layout.ts +193 -37
  25. package/spec-cli/src/lint.ts +152 -27
  26. package/spec-cli/src/localIssues.ts +5 -1
  27. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  28. package/spec-cli/src/materialize.ts +6 -7
  29. package/spec-cli/src/opencode-headless.ts +4 -8
  30. package/spec-cli/src/pi-headless.ts +17 -60
  31. package/spec-cli/src/process-identity.ts +49 -0
  32. package/spec-cli/src/project-store.ts +17 -0
  33. package/spec-cli/src/pty-bridge.ts +6 -3
  34. package/spec-cli/src/reviews.ts +17 -13
  35. package/spec-cli/src/runtime-ownership.ts +99 -0
  36. package/spec-cli/src/session-maintenance.ts +750 -0
  37. package/spec-cli/src/session-timeline.ts +6 -6
  38. package/spec-cli/src/sessions.ts +1249 -208
  39. package/spec-cli/src/sh.ts +3 -0
  40. package/spec-cli/src/source-files.ts +27 -2
  41. package/spec-cli/src/specs.ts +58 -25
  42. package/spec-cli/src/supervise.ts +31 -11
  43. package/spec-cli/src/uninstall.ts +7 -6
  44. package/spec-cli/templates/hooks/commit-msg +14 -0
  45. package/spec-cli/templates/hooks/pre-commit +26 -6
  46. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  47. package/spec-cli/templates/hooks/reference-transaction +105 -0
  48. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  49. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  50. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  51. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  53. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  54. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  56. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  57. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  58. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  59. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  60. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  61. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  116. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  117. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  118. package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
  119. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  120. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  121. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  122. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  123. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  124. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  125. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  126. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  127. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  128. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  129. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  130. package/spec-dashboard/dist/index.html +5 -4
  131. package/spec-dashboard/src/reviewFilters.js +46 -8
  132. package/spec-dashboard/src/session.js +64 -9
  133. package/spec-eval/src/cli.ts +144 -32
  134. package/spec-eval/src/evaltab.ts +16 -9
  135. package/spec-eval/src/freshness.ts +336 -54
  136. package/spec-eval/src/scenariofresh.ts +51 -1
  137. package/spec-eval/src/scenarios.ts +324 -25
  138. package/spec-eval/src/sessioneval.ts +969 -173
  139. package/spec-cli/src/message-stream.ts +0 -147
  140. package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
  141. package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
  142. package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
  143. package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
  144. package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
  145. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
  146. package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
  147. package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
  148. package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
  149. package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
  150. package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
  151. package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
@@ -0,0 +1,276 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { once } from 'node:events'
3
+ import type { Duplex, Readable, Writable } from 'node:stream'
4
+ import type { Capability, LeaseOwner, MaintenanceState } from './session-maintenance.js'
5
+ import { processStartToken } from './process-identity.js'
6
+ import {
7
+ clientMaintenanceAcquire,
8
+ clientMaintenanceHeartbeat,
9
+ clientMaintenanceOperation,
10
+ clientMaintenanceRelease,
11
+ clientMaintenanceStatus,
12
+ } from './client.js'
13
+
14
+ type BrokerRequest = {
15
+ v?: number
16
+ id?: string
17
+ op?: string
18
+ sessionId?: string
19
+ force?: boolean
20
+ client?: { pid?: number; startToken?: string }
21
+ }
22
+ type PlannedCapability = {
23
+ capability: Capability
24
+ state: 'unused' | 'inflight' | 'committed' | 'indeterminate'
25
+ requestId?: string
26
+ }
27
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
28
+ const capabilityKey = (capability: Capability): string => JSON.stringify(capability)
29
+ const capabilityList = (rows: unknown): Capability[] => Array.isArray(rows)
30
+ ? rows.map((row: any) => row?.capability ?? row)
31
+ : []
32
+ const sameCapabilities = (left: readonly Capability[], right: readonly Capability[]): boolean =>
33
+ JSON.stringify(left) === JSON.stringify(right)
34
+ const sameOwner = (left: LeaseOwner | null | undefined, right: LeaseOwner): boolean =>
35
+ !!left && left.instanceId === right.instanceId && left.pid === right.pid && left.startToken === right.startToken
36
+ const assertLeaseView = (view: Pick<MaintenanceState, 'state' | 'epoch' | 'owner' | 'capabilities'>, epoch: number, owner: LeaseOwner, capabilities: readonly Capability[], allowDraining = false) => {
37
+ if (view.epoch !== epoch || (!allowDraining && view.state !== 'active') || (allowDraining && view.state !== 'active' && view.state !== 'draining'))
38
+ throw new Error('maintenance lease changed epoch or state')
39
+ if (!sameOwner(view.owner, owner)) throw new Error('maintenance lease owner generation changed')
40
+ if (!sameCapabilities(capabilityList(view.capabilities), capabilities)) throw new Error('maintenance lease changed its capability plan')
41
+ }
42
+ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
43
+ const reapCommandGroup = async (child: ReturnType<typeof spawn>, closed: Promise<[number | null, NodeJS.Signals | null]>) => {
44
+ if (!child.pid) return
45
+ const groupAlive = () => {
46
+ try { process.kill(-child.pid!, 0); return true } catch { return false }
47
+ }
48
+ if (groupAlive()) {
49
+ try { process.kill(-child.pid, 'SIGTERM') } catch { /* already exited */ }
50
+ const deadline = Date.now() + 1_000
51
+ while (groupAlive() && Date.now() < deadline) await wait(20)
52
+ if (groupAlive()) {
53
+ try { process.kill(-child.pid, 'SIGKILL') } catch { /* already exited */ }
54
+ }
55
+ }
56
+ const closedAfterReap = await new Promise<boolean>((resolve) => {
57
+ let settled = false
58
+ const finish = (value: boolean) => {
59
+ if (settled) return
60
+ settled = true
61
+ clearTimeout(timer)
62
+ resolve(value)
63
+ }
64
+ const timer = setTimeout(() => finish(false), 2_000)
65
+ timer.unref()
66
+ void closed.then(() => finish(true))
67
+ })
68
+ if (!closedAfterReap) throw new Error('maintenance operator pipes did not close after exact process-group reap')
69
+ }
70
+
71
+ export async function runMaintenanceWrapper(input: {
72
+ capabilities: Capability[]
73
+ ttlMs: number
74
+ waitMs: number
75
+ command: string[]
76
+ }): Promise<number> {
77
+ const acquired = await clientMaintenanceAcquire(input.capabilities, input.ttlMs, input.waitMs)
78
+ const { token, epoch, owner } = acquired.lease
79
+ const releaseable = typeof token === 'string' && /^[0-9a-f]{64}$/i.test(token) && Number.isSafeInteger(epoch)
80
+ let released = false
81
+ let authorityLost = false
82
+ let heartbeat: ReturnType<typeof setInterval> | null = null
83
+ let child: ReturnType<typeof spawn> | null = null
84
+ const pendingOperations = new Set<AbortController>()
85
+ let closeAdmission = () => {}
86
+ try {
87
+ if (!releaseable) throw new Error('maintenance acquire returned malformed private authority')
88
+ if (!owner || typeof owner.instanceId !== 'string' || !Number.isInteger(owner.pid) || typeof owner.startToken !== 'string')
89
+ throw new Error('maintenance acquire returned malformed supervisor authority')
90
+ if (!sameCapabilities(capabilityList(acquired.lease.capabilities), input.capabilities)) throw new Error('maintenance acquire returned a different capability plan')
91
+
92
+ let state = acquired.lease.state
93
+ const activationDeadline = Date.now() + input.ttlMs
94
+ try {
95
+ while (state === 'draining') {
96
+ const current = await clientMaintenanceHeartbeat(token, epoch, input.ttlMs)
97
+ assertLeaseView(current, epoch, owner, input.capabilities, true)
98
+ const status = await clientMaintenanceStatus()
99
+ assertLeaseView(status, epoch, owner, input.capabilities, true)
100
+ state = status.state as 'draining' | 'active'
101
+ if (state !== 'active') {
102
+ if (Date.now() >= activationDeadline) throw new Error('maintenance acquisition did not become active before its ttl')
103
+ await sleep(50)
104
+ }
105
+ }
106
+ if (state !== 'active') throw new Error(`maintenance acquisition entered unexpected state ${state}`)
107
+ } catch (error) {
108
+ authorityLost = true
109
+ throw error
110
+ }
111
+ if (!input.command.length) throw new Error('maintenance wrapper needs a command after --')
112
+
113
+ const plan: PlannedCapability[] = input.capabilities.map((capability) => ({ capability: { ...capability }, state: 'unused' }))
114
+ let heartbeatFailure: Error | null = null
115
+ let heartbeatBusy = false
116
+ let failAuthority!: (error: Error) => void
117
+ const authorityFailure = new Promise<never>((_, reject) => {
118
+ failAuthority = (error) => {
119
+ if (heartbeatFailure) return
120
+ heartbeatFailure = error
121
+ reject(error)
122
+ }
123
+ })
124
+ heartbeat = setInterval(() => {
125
+ if (heartbeatBusy || heartbeatFailure) return
126
+ heartbeatBusy = true
127
+ void clientMaintenanceHeartbeat(token, epoch, input.ttlMs)
128
+ .then((current) => assertLeaseView(current, epoch, owner, input.capabilities))
129
+ .catch((error) => failAuthority(error instanceof Error ? error : new Error(String(error))))
130
+ .finally(() => { heartbeatBusy = false })
131
+ }, Math.max(250, Math.min(1_000, Math.floor(input.ttlMs / 3))))
132
+ heartbeat.unref()
133
+
134
+ child = spawn(input.command[0], input.command.slice(1), {
135
+ stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'pipe', 'pipe'],
136
+ env: { ...process.env, SPEXCODE_MAINTENANCE_BROKER_FDS: '3,4,5' },
137
+ detached: true,
138
+ })
139
+ const exited = once(child, 'exit') as Promise<[number | null, NodeJS.Signals | null]>
140
+ const closed = once(child, 'close') as Promise<[number | null, NodeJS.Signals | null]>
141
+ const childStdio = child.stdio as Array<Readable | Writable | Duplex | null | undefined>
142
+ const requestPipe = childStdio[3] as Readable
143
+ const responsePipe = childStdio[4] as Writable
144
+ const turnPipe = childStdio[5] as Duplex
145
+ requestPipe.setEncoding('utf8')
146
+ let pending = ''
147
+ let serial = Promise.resolve()
148
+ let brokerWork = 0
149
+ let brokerOpen = true
150
+ let transportLossTimer: ReturnType<typeof setTimeout> | null = null
151
+ const brokerTransportLost = (error: unknown) => {
152
+ if (!brokerOpen || transportLossTimer) return
153
+ transportLossTimer = setTimeout(() => {
154
+ transportLossTimer = null
155
+ if (brokerOpen && child?.exitCode === null && child.signalCode === null) {
156
+ failAuthority(error instanceof Error ? error : new Error('maintenance broker transport closed while the operator command was live'))
157
+ }
158
+ }, 50)
159
+ transportLossTimer.unref()
160
+ }
161
+ requestPipe.on('error', brokerTransportLost)
162
+ requestPipe.on('end', () => brokerTransportLost(new Error('maintenance broker request pipe closed while the operator command was live')))
163
+ responsePipe.on('error', brokerTransportLost)
164
+ responsePipe.on('close', () => brokerTransportLost(new Error('maintenance broker response pipe closed while the operator command was live')))
165
+ turnPipe.on('error', brokerTransportLost)
166
+ turnPipe.on('end', () => brokerTransportLost(new Error('maintenance broker turn pipe closed while the operator command was live')))
167
+ closeAdmission = () => {
168
+ if (!brokerOpen) return
169
+ brokerOpen = false
170
+ for (const controller of pendingOperations) controller.abort(new Error('maintenance broker admission closed'))
171
+ if (transportLossTimer) clearTimeout(transportLossTimer)
172
+ transportLossTimer = null
173
+ try { requestPipe.destroy() } catch { /* already closed */ }
174
+ try { turnPipe.destroy() } catch { /* already closed */ }
175
+ }
176
+ turnPipe.on('data', (chunk: Buffer) => {
177
+ if (!brokerOpen) return
178
+ for (const byte of chunk) {
179
+ if (byte !== 1) return failAuthority(new Error('maintenance broker turn token was corrupted'))
180
+ turnPipe.write(Buffer.from([1]))
181
+ }
182
+ })
183
+ turnPipe.write(Buffer.from([1]))
184
+ const reply = (id: string | undefined, body: Record<string, unknown>) => {
185
+ if (brokerOpen) responsePipe.write(`${JSON.stringify({ id, ...body })}\n`)
186
+ }
187
+ const handle = async (request: BrokerRequest) => {
188
+ if (!brokerOpen) return
189
+ if (request.v !== 1 || typeof request.id !== 'string' || typeof request.op !== 'string' || typeof request.sessionId !== 'string'
190
+ || !Number.isInteger(request.client?.pid) || typeof request.client?.startToken !== 'string'
191
+ || processStartToken(request.client.pid!) !== request.client.startToken) {
192
+ reply(request.id, { ok: false, code: 'maintenance_invalid', error: 'malformed broker request' })
193
+ return
194
+ }
195
+ const candidate: Capability | null = request.op === 'stop'
196
+ ? { op: 'stop', sessionId: request.sessionId }
197
+ : request.op === 'resume' && typeof request.force === 'boolean'
198
+ ? { op: 'resume', sessionId: request.sessionId, force: request.force }
199
+ : null
200
+ const matching = candidate && plan.find((entry) => capabilityKey(entry.capability) === capabilityKey(candidate))
201
+ const planned = matching?.state === 'unused' ? matching : null
202
+ if (!planned) {
203
+ reply(request.id, {
204
+ ok: false,
205
+ code: matching ? 'maintenance_capability_used' : 'maintenance_capability_missing',
206
+ error: matching ? 'operation is already inflight, committed, or indeterminate' : 'operation is not in the exact maintenance plan',
207
+ })
208
+ return
209
+ }
210
+ planned.state = 'inflight'
211
+ planned.requestId = request.id
212
+ const controller = new AbortController()
213
+ pendingOperations.add(controller)
214
+ try {
215
+ const result = await clientMaintenanceOperation(token, candidate!, { signal: controller.signal, timeoutMs: Math.min(5_000, input.ttlMs) })
216
+ planned.state = result.outcome === 'committed' ? 'committed' : result.outcome === 'refused' ? 'unused' : 'indeterminate'
217
+ delete planned.requestId
218
+ reply(request.id, result)
219
+ } catch (error) {
220
+ planned.state = 'indeterminate'
221
+ delete planned.requestId
222
+ reply(request.id, { ok: false, code: 'maintenance_broker_failed', error: error instanceof Error ? error.message : String(error) })
223
+ } finally {
224
+ pendingOperations.delete(controller)
225
+ }
226
+ }
227
+ requestPipe.on('data', (chunk: Buffer | string) => {
228
+ pending += chunk.toString()
229
+ for (;;) {
230
+ const newline = pending.indexOf('\n')
231
+ if (newline < 0) break
232
+ const line = pending.slice(0, newline); pending = pending.slice(newline + 1)
233
+ if (!line) continue
234
+ brokerWork++
235
+ serial = serial.then(async () => {
236
+ try { await handle(JSON.parse(line) as BrokerRequest) }
237
+ catch (error) { reply(undefined, { ok: false, code: 'maintenance_broker_failed', error: error instanceof Error ? error.message : String(error) }) }
238
+ }).finally(() => { brokerWork-- })
239
+ }
240
+ })
241
+ const outcome = await Promise.race([
242
+ exited.then(([code, signal]) => ({ kind: 'exit' as const, code, signal })),
243
+ authorityFailure.catch((error) => ({ kind: 'lost' as const, error: error instanceof Error ? error : new Error(String(error)) })),
244
+ ])
245
+ if (outcome.kind === 'lost') {
246
+ authorityLost = true
247
+ closeAdmission()
248
+ await reapCommandGroup(child, closed)
249
+ throw outcome.error
250
+ }
251
+ const brokerIndeterminate = brokerWork > 0 || pendingOperations.size > 0 || pending.trim().length > 0
252
+ if (brokerIndeterminate) authorityLost = true
253
+ closeAdmission()
254
+ await serial
255
+ try { await reapCommandGroup(child, closed) }
256
+ catch (error) { authorityLost = true; throw error }
257
+ if (brokerIndeterminate) {
258
+ throw new Error('operator exited while a maintenance broker operation was indeterminate')
259
+ }
260
+ if (heartbeatFailure) {
261
+ authorityLost = true
262
+ throw heartbeatFailure
263
+ }
264
+ return outcome.code ?? (outcome.signal ? 1 : 0)
265
+ } finally {
266
+ if (heartbeat) clearInterval(heartbeat)
267
+ closeAdmission()
268
+ try { child?.stdio[3]?.destroy() } catch { /* child already closed */ }
269
+ try { child?.stdio[4]?.destroy() } catch { /* child already closed */ }
270
+ try { (child?.stdio as Array<Readable | Writable | null | undefined> | undefined)?.[5]?.destroy() } catch { /* child already closed */ }
271
+ if (releaseable && !released && !authorityLost) {
272
+ await clientMaintenanceRelease(token, epoch)
273
+ released = true
274
+ }
275
+ }
276
+ }
@@ -23,7 +23,7 @@ export type MaterializeResult = { contentHash: string; planted: MaterializedArti
23
23
  // a trigger. It turns the spec tree's surface nodes into the flat artifacts each consumer reads
24
24
  // cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the whole system via
25
25
  // harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it), (2) the CONTRACT — the
26
- // tracked docs guide (docs/AGENT_GUIDE.md) FOLLOWED BY the surface:system bodies — written WHOLE into each
26
+ // surface:system plugin bodies (in name order) — written WHOLE into each
27
27
  // harness's contract file(s), (3) the thin SHIMS (every event → dispatch.sh), (4) the per-harness TRUST
28
28
  // (Codex's deterministic trusted_hash; Claude none). EVERY harness-specific fact is owned by the
29
29
  // [[harness-adapter]] (harness.ts) — this file just loops over HARNESSES.
@@ -192,13 +192,12 @@ export function materialize(proj = process.cwd()): MaterializeResult {
192
192
  const manifest = join(rt, 'hooks-manifest')
193
193
  writeFileSync(manifest, compileManifest())
194
194
  record('hook manifest', manifest)
195
- // (2) the contract = the tracked docs guide (the hand-written agent/contributor notes the ONE piece of
196
- // in-tree prose) FOLLOWED BY the surface:system bodies (in name order), written WHOLE into EACH harness's
195
+ // (2) the contract = the surface:system plugin bodies (in name order), written WHOLE into EACH harness's
197
196
  // contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
198
- const guidePath = join(proj, 'docs', 'AGENT_GUIDE.md')
199
- const guide = existsSync(guidePath) ? readFileSync(guidePath, 'utf8').trim() : ''
200
- const systemBodies = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean)
201
- const contract = [guide, ...systemBodies].filter(Boolean).join('\n\n')
197
+ // ONE source, no per-project escape hatch: the contract IS the surface:system plugin bodies. A project's
198
+ // own hand-written prose is not folded in — repo-local notes belong in the harness file's own
199
+ // block-outside region (untracked, per-clone), and anything that must reach EVERY agent is a plugin node.
200
+ const contract = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean).join('\n\n')
202
201
  // WHICH harnesses to deliver into ([[harness-select]]): the spexcode.json `harnesses` set (default = every
203
202
  // native harness). resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
204
203
  const cfg = readConfig(mainCheckout(proj))
@@ -3,18 +3,14 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
3
  import { tmpdir, userInfo } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { promisify } from 'node:util'
6
- import { fileURLToPath } from 'node:url'
7
- import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
6
+ import { headlessTurnFailureShell, type DispatchResult, type HarnessDeliveryRecord } from './harness.js'
7
+ import { shQuote } from './sh.js'
8
8
 
9
9
  const pexec = promisify(execFile)
10
- const SPEX = join(fileURLToPath(new URL('..', import.meta.url)), 'bin', 'spex.mjs')
11
10
  const WAKE_EARLY_EXIT_MS = 5_000
12
11
  const OUTCOME_POLL_MS = 25
13
12
 
14
- const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
15
13
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
16
- const turnFailureShell = (swallow: boolean) =>
17
- `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote('opencode-headless')} "$__spex_rc"${swallow ? ' || true' : ''}`
18
14
 
19
15
  function accountLoginShell(): string {
20
16
  try { return userInfo().shell || '' } catch { return '' }
@@ -33,12 +29,12 @@ function turnHome(command: string, outcomePath?: string): string {
33
29
  '__spex_cas_rc=0',
34
30
  'if [ "$__spex_rc" -ne 0 ]; then',
35
31
  ' __spex_outcome "reporting:$$:$__spex_rc" || true',
36
- ` ${turnFailureShell(false)}`,
32
+ ` ${headlessTurnFailureShell('opencode-headless', false)}`,
37
33
  ' __spex_cas_rc=$?',
38
34
  'fi',
39
35
  '__spex_outcome "exit:$__spex_rc:cas:$__spex_cas_rc" || printf "[spex opencode-headless] could not finalize turn outcome marker\\n" >&2',
40
36
  ] : [
41
- `if [ "$__spex_rc" -ne 0 ]; then ${turnFailureShell(true)}; fi`,
37
+ `if [ "$__spex_rc" -ne 0 ]; then ${headlessTurnFailureShell('opencode-headless')}; fi`,
42
38
  ]
43
39
  const script = [
44
40
  ...outcomeSetup,
@@ -1,10 +1,12 @@
1
- import { createConnection, createServer, type Server, type Socket } from 'node:net'
1
+ import { createServer, type Server, type Socket } from 'node:net'
2
2
  import { spawn, type ChildProcess } from 'node:child_process'
3
3
  import { mkdirSync, rmSync } from 'node:fs'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
8
+ import { controlRequest, withTimeout } from './headless-controller.js'
9
+ import { shQuote } from './sh.js'
8
10
 
9
11
  type ControlRequest = { type: 'deliver'; text: string }
10
12
  type ChildTurn = { process: ChildProcess; exited: Promise<number | null> }
@@ -14,8 +16,6 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
14
16
  const CONTROL_TIMEOUT_MS = 30_000
15
17
  const START_TIMEOUT_MS = 30_000
16
18
 
17
- const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
18
-
19
19
  /** The resident controller socket is distinct from pi's per-turn rendezvous socket. */
20
20
  export const piHeadlessSock = (id: string) => join(tmpdir(), `spexcode-ph-${id}.sock`)
21
21
 
@@ -23,53 +23,11 @@ export function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: s
23
23
  return [shQuote(SPEX), 'internal', 'pi-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(piCmd), '--'].join(' ')
24
24
  }
25
25
 
26
- function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
27
- return new Promise((resolve, reject) => {
28
- const timer = setTimeout(() => reject(new Error(message)), ms)
29
- promise.then(
30
- (value) => { clearTimeout(timer); resolve(value) },
31
- (error) => { clearTimeout(timer); reject(error) },
32
- )
33
- })
34
- }
35
-
36
- function controlRequest(id: string, request: ControlRequest): Promise<DispatchResult> {
37
- return new Promise((resolve) => {
38
- let socket: Socket | undefined
39
- let settled = false
40
- let buffer = ''
41
- const finish = (result: DispatchResult) => {
42
- if (settled) return
43
- settled = true
44
- clearTimeout(timer)
45
- socket?.destroy()
46
- resolve(result)
47
- }
48
- const timer = setTimeout(() => finish({ ok: false, error: `pi-headless control timed out for session ${id}` }), CONTROL_TIMEOUT_MS)
49
- try { socket = createConnection(piHeadlessSock(id)) } catch (error) {
50
- finish({ ok: false, error: `pi-headless controller connect failed for session ${id}: ${(error as Error).message}` })
51
- return
52
- }
53
- socket.setEncoding('utf8')
54
- socket.on('connect', () => socket!.write(`${JSON.stringify(request)}\n`))
55
- socket.on('data', (chunk) => {
56
- buffer += chunk
57
- const nl = buffer.indexOf('\n')
58
- if (nl < 0) return
59
- try {
60
- const response = JSON.parse(buffer.slice(0, nl)) as DispatchResult
61
- finish(response.ok ? { ok: true } : { ok: false, error: response.error || 'pi-headless controller rejected the request' })
62
- } catch (error) {
63
- finish({ ok: false, error: `pi-headless returned an invalid control response: ${(error as Error).message}` })
64
- }
65
- })
66
- socket.on('error', (error) => finish({ ok: false, error: `pi-headless controller unreachable for session ${id}: ${error.message}` }))
67
- socket.on('close', () => finish({ ok: false, error: `pi-headless controller closed before confirming session ${id}` }))
68
- })
69
- }
70
-
71
26
  export const deliverViaPiHeadless = (rec: HarnessDeliveryRecord, text: string) =>
72
- controlRequest(rec.session, { type: 'deliver', text })
27
+ controlRequest(piHeadlessSock(rec.session), { type: 'deliver', text }, {
28
+ name: 'pi-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
29
+ rejected: 'pi-headless controller rejected the request',
30
+ })
73
31
 
74
32
  export class PiHeadlessController {
75
33
  private server: Server | null = null
@@ -111,9 +69,10 @@ export class PiHeadlessController {
111
69
  if (!this.server) return resolve()
112
70
  this.server.close(() => resolve())
113
71
  })
114
- try { rmSync(this.socketPath, { force: true }) } catch { /* best-effort cleanup after close */ }
115
- const { rvSock } = await import('./harness.js')
116
- try { rmSync(rvSock(this.id), { force: true }) } catch { /* pi may already have removed it */ }
72
+ // same proof-before-removal rule as every other teardown (harness.ts unlinkSocks): a socket path is keyed
73
+ // by session id alone, so only a listener PROVEN dead is ours to unlink.
74
+ const { rvSock, unlinkSocks } = await import('./harness.js')
75
+ await unlinkSocks(this.socketPath, rvSock(this.id))
117
76
  }
118
77
 
119
78
  private accept(socket: Socket): void {
@@ -144,14 +103,12 @@ export class PiHeadlessController {
144
103
 
145
104
  // A live extension listener is an in-flight pi turn. The native steer path is parse-confirmed by the
146
105
  // shared rendezvous protocol. Only a proven absent listener may cold-wake a saved session.
147
- const { deliverViaRendezvous, rendezvousListening } = await import('./harness.js')
148
- const listener = await rendezvousListening(this.id)
149
- if (listener === 'live') return deliverViaRendezvous(this.id, request.text)
150
- if (listener === 'unproven') return { ok: false, error: `could not determine whether pi turn ${this.id} is live — prompt NOT delivered` }
151
-
152
- if (this.child) await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`)
153
- await this.spawnTurn(request.text, true)
154
- return { ok: true }
106
+ const { deliverViaSocketOrWake } = await import('./harness.js')
107
+ return deliverViaSocketOrWake(this.id, request.text, async () => {
108
+ if (this.child) await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`)
109
+ await this.spawnTurn(request.text, true)
110
+ return { ok: true }
111
+ }, `could not determine whether pi turn ${this.id} is live — prompt NOT delivered`)
155
112
  }
156
113
 
157
114
  private async spawnTurn(text: string, resume: boolean): Promise<void> {
@@ -0,0 +1,49 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { readFileSync } from 'node:fs'
3
+ import { platform } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ export type ProcessIdentity = { pid: number; startToken: string }
7
+ export type ProcessTopology = ProcessIdentity & { processGroupId: number; sessionId: number }
8
+
9
+ export function parseProcStat(text: string): { ppid: number; processGroupId: number; sessionId: number; ticks: number; startToken: string; rssPages: number } {
10
+ const end = text.lastIndexOf(')')
11
+ if (end < 0) throw new Error('malformed proc stat')
12
+ const fields = text.slice(end + 2).trim().split(/\s+/)
13
+ if (fields.length < 22) throw new Error('short proc stat')
14
+ return {
15
+ ppid: Number(fields[1]),
16
+ processGroupId: Number(fields[2]),
17
+ sessionId: Number(fields[3]),
18
+ ticks: Number(fields[11]) + Number(fields[12]),
19
+ startToken: fields[19],
20
+ rssPages: Number(fields[21]),
21
+ }
22
+ }
23
+
24
+ export function processTopology(pid: number, procRoot = '/proc'): ProcessTopology | null {
25
+ if (platform() === 'linux' || procRoot !== '/proc') {
26
+ try {
27
+ const stat = parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8'))
28
+ return { pid, startToken: stat.startToken, processGroupId: stat.processGroupId, sessionId: stat.sessionId }
29
+ } catch { return null }
30
+ }
31
+ try {
32
+ const [processGroupId, sessionId] = execFileSync('ps', ['-o', 'pgid=', '-o', 'sess=', '-p', String(pid)], { encoding: 'utf8' }).trim().split(/\s+/).map(Number)
33
+ const startToken = processStartToken(pid, procRoot)
34
+ return startToken && Number.isFinite(processGroupId) && Number.isFinite(sessionId)
35
+ ? { pid, startToken, processGroupId, sessionId }
36
+ : null
37
+ } catch { return null }
38
+ }
39
+
40
+ export function processStartToken(pid: number, procRoot = '/proc'): string | null {
41
+ if (platform() === 'linux' || procRoot !== '/proc') {
42
+ try { return parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8')).startToken }
43
+ catch { return null }
44
+ }
45
+ try {
46
+ const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' }).trim()
47
+ return started || null
48
+ } catch { return null }
49
+ }
@@ -0,0 +1,17 @@
1
+ import { homedir } from 'node:os'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ // One pure identity seam for every project-scoped runtime consumer. Git discovery stays with callers;
5
+ // once they have the shared common dir, sessions, materialize, history indexes, backends, and uninstall
6
+ // all resolve the same top-level store.
7
+ export function spexcodeHome(): string {
8
+ return process.env.SPEXCODE_HOME || join(homedir(), '.spexcode')
9
+ }
10
+
11
+ export function encodeProject(root: string): string {
12
+ return root.replace(/[/.]/g, '-')
13
+ }
14
+
15
+ export function projectRuntimeRoot(commonDir: string): string {
16
+ return join(spexcodeHome(), 'projects', encodeProject(dirname(commonDir)))
17
+ }
@@ -1,7 +1,8 @@
1
1
  import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { promisify } from 'node:util'
4
- import { alive } from './sessions.js'
4
+ import { alive, withSessionInputLock } from './sessions.js'
5
+ import { runSessionOperationSync } from './session-maintenance.js'
5
6
 
6
7
  const pexec = promisify(execFile)
7
8
  const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
@@ -362,8 +363,10 @@ const MAX_INPUT_BYTES = 64 * 1024
362
363
  export function forwardInput(id: string, viewer: Viewer, data: string): boolean {
363
364
  const subscription = currentSubscription(id, viewer)
364
365
  if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES) return false
365
- sendControl(subscription.bridge, { t: 'input', data })
366
- return true
366
+ return runSessionOperationSync({ op: 'terminal-input', sessionId: id }, () => withSessionInputLock(id, () => {
367
+ sendControl(subscription.bridge!, { t: 'input', data })
368
+ return true
369
+ }) ?? false)
367
370
  }
368
371
 
369
372
  async function restoreBridge(id: string, viewer: Viewer, subscription: Subscription): Promise<void> {
@@ -15,6 +15,11 @@ import { EVAL_QUERY_DEFAULT, ISSUE_QUERY_DEFAULT, readToken } from '../../spec-d
15
15
  export const REVIEW_PER_PAGE = 25
16
16
 
17
17
  type ReviewItem = Record<string, unknown>
18
+ // A section count is one number, or — when the domain adapter splits that section — its named buckets,
19
+ // which re-add to the same whole population ([[review-filters]] owns the split; Evals splits its measured
20
+ // verdicts into {fresh,stale} so the remeasurement debt travels with the count instead of being recomputed
21
+ // per surface). The fold happens ONCE, here on the server, over the complete filtered population.
22
+ export type ReviewCount = number | Record<string, number>
18
23
  type ReviewOption = { value: string; label?: string; count?: number }
19
24
  type ReviewFacet = { key: string; label?: string; value: string; meaningful?: boolean; options: ReviewOption[] }
20
25
  type EvalNeighbor = { node: string; scenario: string; state: string }
@@ -29,7 +34,7 @@ export type PagedReview<T extends ReviewItem = ReviewItem> = {
29
34
  prev: number | null
30
35
  next: number | null
31
36
  revision: string
32
- counts: Record<string, number>
37
+ counts: Record<string, ReviewCount>
33
38
  facets: Record<string, ReviewFacet>
34
39
  section: { key: string; value: string; options: ReviewOption[] } | null
35
40
  }
@@ -129,12 +134,12 @@ export async function issuesReview(query: string | undefined, requestedPage: unk
129
134
  }
130
135
  }
131
136
 
132
- const byNewest = (a: any, b: any): number => String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
137
+ const byNewest = (a: any, b: any): number => Number(b.filterKind === EVAL_FILTER_KIND.RESULT) - Number(a.filterKind === EVAL_FILTER_KIND.RESULT)
138
+ || String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
133
139
  || String(a.node ?? '').localeCompare(String(b.node ?? ''))
134
140
  || String(a.scenario ?? '').localeCompare(String(b.scenario ?? ''))
135
141
 
136
142
  export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
137
- const blind: any[] = []
138
143
  const items: any[] = []
139
144
  for (const node of nodes ?? []) {
140
145
  const latest = new Map<string, any>()
@@ -142,7 +147,7 @@ export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
142
147
  for (const scenario of node.scenarios ?? []) {
143
148
  const reading = latest.get(scenario.name)
144
149
  if (!reading) {
145
- blind.push({
150
+ items.push({
146
151
  scenario: scenario.name,
147
152
  expected: scenario.expected,
148
153
  tags: scenario.tags,
@@ -163,21 +168,18 @@ export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
163
168
  })
164
169
  }
165
170
  }
166
- blind.sort((a, b) => String(a.node).localeCompare(String(b.node)) || String(a.scenario).localeCompare(String(b.scenario)))
167
- return [...blind, ...items.sort(byNewest)]
171
+ return items.sort(byNewest)
168
172
  }
169
173
 
170
174
  export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
171
- const blind: any[] = []
172
- const own: any[] = []
173
- const inherited: any[] = []
175
+ const items: any[] = []
174
176
  for (const node of model.nodes ?? []) {
175
177
  const latest = new Map<string, any>()
176
178
  for (const reading of node.evals ?? []) if (!latest.has(reading.scenario)) latest.set(reading.scenario, reading)
177
179
  for (const scenario of node.scenarios ?? []) {
178
180
  const reading = latest.get(scenario.name)
179
181
  if (!reading) {
180
- blind.push({
182
+ items.push({
181
183
  scenario: scenario.name,
182
184
  expected: scenario.expected,
183
185
  tags: scenario.tags,
@@ -192,15 +194,16 @@ export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
192
194
  ...reading,
193
195
  expected: scenario.expected ?? reading.expected,
194
196
  tags: scenario.tags,
197
+ impact: scenario.impact,
195
198
  state: evalReviewState(reading),
196
199
  node: node.id,
197
200
  hue: node.hue,
198
201
  filterKind: EVAL_FILTER_KIND.RESULT,
199
202
  }
200
- ;(reading.inSession ? own : inherited).push(item)
203
+ items.push(item)
201
204
  }
202
205
  }
203
- return [...blind, ...own.sort(byNewest), ...inherited.sort(byNewest)]
206
+ return items.sort(byNewest)
204
207
  }
205
208
 
206
209
  const evalItemKey = (item: any): string => `${String(item?.node ?? '')}\0${String(item?.scenario ?? '')}`
@@ -327,9 +330,10 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
327
330
  unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
328
331
  summary: model.summary,
329
332
  evalRevision: model.evalRevision,
333
+ impact: model.impact,
330
334
  ...paginateReview(items, filtered.shown, filtered, requestedPage, {
331
335
  domain: 'evals', scope, items, gates: model.gates, summary: model.summary,
332
- evalRevision: model.evalRevision, sessions: sessions.map((session) => session.id),
336
+ evalRevision: model.evalRevision, impact: model.impact, sessions: sessions.map((session) => session.id),
333
337
  }),
334
338
  }
335
339
  }