spexcode 0.5.8 → 0.6.0

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 (104) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +122 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/delivery-queue.ts +107 -0
  15. package/spec-cli/src/doctor.ts +0 -16
  16. package/spec-cli/src/gateway-auth.ts +0 -13
  17. package/spec-cli/src/gateway.ts +0 -22
  18. package/spec-cli/src/git.ts +151 -48
  19. package/spec-cli/src/graph.ts +28 -7
  20. package/spec-cli/src/graphCache.ts +253 -72
  21. package/spec-cli/src/graphDelta.ts +0 -13
  22. package/spec-cli/src/graphStream.ts +70 -37
  23. package/spec-cli/src/guide.ts +29 -6
  24. package/spec-cli/src/harness.ts +232 -248
  25. package/spec-cli/src/help.ts +27 -26
  26. package/spec-cli/src/host-resources.ts +11 -2
  27. package/spec-cli/src/index.ts +71 -95
  28. package/spec-cli/src/issues-cli.ts +276 -0
  29. package/spec-cli/src/issues.ts +15 -197
  30. package/spec-cli/src/layout.ts +78 -2
  31. package/spec-cli/src/lint.ts +5 -3
  32. package/spec-cli/src/localIssues.ts +22 -185
  33. package/spec-cli/src/loop-in.ts +78 -0
  34. package/spec-cli/src/materialize.ts +0 -42
  35. package/spec-cli/src/mentions.ts +49 -193
  36. package/spec-cli/src/opencode-headless.ts +7 -7
  37. package/spec-cli/src/opencode.ts +2 -5
  38. package/spec-cli/src/pi-headless.ts +5 -5
  39. package/spec-cli/src/pty-bridge.ts +4 -5
  40. package/spec-cli/src/reviewSnapshot.ts +1 -0
  41. package/spec-cli/src/reviews.ts +6 -1
  42. package/spec-cli/src/root-lru.ts +54 -0
  43. package/spec-cli/src/runtime-guard.ts +0 -9
  44. package/spec-cli/src/session-cursors.ts +91 -0
  45. package/spec-cli/src/session-follow.ts +182 -0
  46. package/spec-cli/src/session-timeline.ts +55 -127
  47. package/spec-cli/src/sessions.ts +235 -1021
  48. package/spec-cli/src/shim-runtime.ts +9 -16
  49. package/spec-cli/src/specs.ts +33 -10
  50. package/spec-cli/src/uninstall.ts +0 -9
  51. package/spec-cli/src/uploads.ts +240 -18
  52. package/spec-cli/src/worktree-sources.ts +0 -16
  53. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  54. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +7 -3
  56. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  58. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  59. package/spec-cli/templates/spexcode.json +12 -0
  60. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  61. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  62. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  63. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  64. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  65. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  66. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  67. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  68. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  69. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  70. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  71. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  72. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  73. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  74. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  75. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  76. package/spec-dashboard/dist/index.html +2 -2
  77. package/spec-dashboard/src/session.js +1 -28
  78. package/spec-eval/src/cache.ts +0 -14
  79. package/spec-eval/src/cli.ts +26 -5
  80. package/spec-eval/src/evaltab.ts +67 -108
  81. package/spec-eval/src/filing.ts +0 -18
  82. package/spec-eval/src/freshness.ts +78 -32
  83. package/spec-eval/src/humanok.ts +0 -15
  84. package/spec-eval/src/scenariofresh.ts +8 -80
  85. package/spec-eval/src/scenarios.ts +16 -67
  86. package/spec-eval/src/sessioneval.ts +157 -78
  87. package/spec-eval/src/sidecar.ts +1 -70
  88. package/spec-eval/src/timeline.ts +0 -23
  89. package/spec-forge/src/cache.ts +34 -12
  90. package/spec-forge/src/cli.ts +0 -6
  91. package/spec-forge/src/drivers/github.ts +1 -12
  92. package/spec-forge/src/drivers/gitlab.ts +4 -19
  93. package/spec-forge/src/drivers.ts +0 -18
  94. package/spec-forge/src/links.ts +0 -4
  95. package/spec-forge/src/needs-eval.ts +0 -1
  96. package/spec-forge/src/port.ts +0 -12
  97. package/spec-forge/src/resident.ts +4 -20
  98. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  99. package/spec-cli/src/session-maintenance.ts +0 -751
  100. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  101. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  102. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  103. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  104. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -1,276 +0,0 @@
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
- }