spexcode 0.4.1 → 0.5.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 (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +110 -18
  7. package/spec-cli/src/claude-headless.ts +271 -0
  8. package/spec-cli/src/cli.ts +112 -38
  9. package/spec-cli/src/client.ts +42 -11
  10. package/spec-cli/src/doctor.ts +174 -36
  11. package/spec-cli/src/gateway-auth.ts +194 -0
  12. package/spec-cli/src/gateway-hub.ts +360 -0
  13. package/spec-cli/src/gateway.ts +27 -19
  14. package/spec-cli/src/git.ts +12 -5
  15. package/spec-cli/src/graph.ts +54 -43
  16. package/spec-cli/src/graphStream.ts +265 -34
  17. package/spec-cli/src/guide.ts +115 -56
  18. package/spec-cli/src/harness-select.ts +20 -6
  19. package/spec-cli/src/harness.ts +295 -39
  20. package/spec-cli/src/help.ts +68 -28
  21. package/spec-cli/src/host.ts +610 -0
  22. package/spec-cli/src/identity-presets.d.ts +13 -0
  23. package/spec-cli/src/identity-presets.js +128 -0
  24. package/spec-cli/src/index.ts +158 -87
  25. package/spec-cli/src/init.ts +53 -25
  26. package/spec-cli/src/layout.ts +6 -5
  27. package/spec-cli/src/lint.ts +25 -121
  28. package/spec-cli/src/login-page.ts +12 -5
  29. package/spec-cli/src/materialize.ts +39 -13
  30. package/spec-cli/src/mentions.ts +16 -11
  31. package/spec-cli/src/message-stream.ts +147 -0
  32. package/spec-cli/src/opencode-headless.ts +95 -0
  33. package/spec-cli/src/opencode.ts +145 -0
  34. package/spec-cli/src/pi-harness.ts +124 -0
  35. package/spec-cli/src/pi-headless.ts +195 -0
  36. package/spec-cli/src/project-identity.ts +73 -0
  37. package/spec-cli/src/pty-bridge.ts +321 -521
  38. package/spec-cli/src/pty-helper.mjs +86 -0
  39. package/spec-cli/src/reaper.ts +16 -8
  40. package/spec-cli/src/reviewSnapshot.ts +23 -0
  41. package/spec-cli/src/reviews.ts +347 -0
  42. package/spec-cli/src/session-timeline.ts +14 -0
  43. package/spec-cli/src/sessions.ts +211 -75
  44. package/spec-cli/src/shim-runtime.ts +169 -0
  45. package/spec-cli/src/slash-commands.ts +76 -0
  46. package/spec-cli/src/source-files.ts +79 -0
  47. package/spec-cli/src/specs.ts +12 -7
  48. package/spec-cli/src/supervise.ts +37 -15
  49. package/spec-cli/src/tree.ts +8 -8
  50. package/spec-cli/src/uninstall.ts +56 -23
  51. package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
  52. package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
  53. package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
  54. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  55. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
  56. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
  58. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  59. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  60. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  62. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  63. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  64. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  65. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  66. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  67. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  68. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  69. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
  70. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
  71. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  72. package/spec-cli/templates/spec/project/spec.md +6 -5
  73. package/spec-cli/templates/spexcode.json +10 -2
  74. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
  75. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
  76. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
  77. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
  78. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
  79. package/spec-dashboard/dist/assets/PageScroll-B_dKCuXx.js +1 -0
  80. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
  81. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  82. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
  83. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
  84. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
  85. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
  86. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  87. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
  88. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
  89. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  90. package/spec-dashboard/dist/index.html +10 -10
  91. package/spec-dashboard/src/reviewFilters.js +279 -0
  92. package/spec-dashboard/src/reviewQuery.js +174 -0
  93. package/spec-dashboard/src/session.js +167 -0
  94. package/spec-eval/src/cli.ts +49 -28
  95. package/spec-eval/src/humanok.ts +1 -1
  96. package/spec-eval/src/matrix.ts +693 -0
  97. package/spec-eval/src/sessioneval.ts +726 -66
  98. package/spec-cli/src/migrate-table.ts +0 -408
  99. package/spec-cli/src/migrate.ts +0 -386
  100. package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
  108. package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
  109. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
  110. package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
  111. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
@@ -0,0 +1,271 @@
1
+ import { appendFileSync, mkdirSync, rmSync } from 'node:fs'
2
+ import { createConnection, createServer, type Server, type Socket } from 'node:net'
3
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
9
+
10
+ type ControlRequest = { type: 'deliver'; text: string } | { type: 'interrupt' }
11
+ type ChildTurn = {
12
+ process: ChildProcessWithoutNullStreams
13
+ active: boolean
14
+ exited: Promise<number | null>
15
+ firstEvent: Promise<void>
16
+ sawFirstEvent: () => void
17
+ interruptAcks: Map<string, () => void>
18
+ }
19
+
20
+ const PKG = fileURLToPath(new URL('..', import.meta.url))
21
+ const SPEX = join(PKG, 'bin', 'spex.mjs')
22
+ const CONTROL_TIMEOUT_MS = 30_000
23
+ const START_TIMEOUT_MS = 30_000
24
+ const INTERRUPT_TIMEOUT_MS = 10_000
25
+
26
+ const shQuote = (s: string) => `'${s.replace(/'/g, `'\''`)}'`
27
+ const userEvent = (text: string) => JSON.stringify({
28
+ type: 'user',
29
+ message: { role: 'user', content: [{ type: 'text', text }] },
30
+ })
31
+
32
+ export const claudeHeadlessSock = (id: string) => join(tmpdir(), `spexcode-ch-${id}.sock`)
33
+
34
+ export function claudeHeadlessLaunchCommand(id: string, runtimeDir: string, claudeCmd: string): string {
35
+ return [shQuote(SPEX), 'internal', 'claude-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(claudeCmd), '--'].join(' ')
36
+ }
37
+
38
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
39
+ return new Promise((resolve, reject) => {
40
+ const timer = setTimeout(() => reject(new Error(message)), ms)
41
+ promise.then(
42
+ (value) => { clearTimeout(timer); resolve(value) },
43
+ (error) => { clearTimeout(timer); reject(error) },
44
+ )
45
+ })
46
+ }
47
+
48
+ function controlRequest(id: string, request: ControlRequest): Promise<DispatchResult> {
49
+ return new Promise((resolve) => {
50
+ const socket = createConnection(claudeHeadlessSock(id))
51
+ let buffer = ''
52
+ let settled = false
53
+ const finish = (result: DispatchResult) => {
54
+ if (settled) return
55
+ settled = true
56
+ clearTimeout(timer)
57
+ socket.destroy()
58
+ resolve(result)
59
+ }
60
+ const timer = setTimeout(() => finish({ ok: false, error: `claude-headless control timed out for session ${id}` }), CONTROL_TIMEOUT_MS)
61
+ socket.setEncoding('utf8')
62
+ socket.on('connect', () => socket.write(`${JSON.stringify(request)}\n`))
63
+ socket.on('data', (chunk) => {
64
+ buffer += chunk
65
+ const nl = buffer.indexOf('\n')
66
+ if (nl < 0) return
67
+ try {
68
+ const response = JSON.parse(buffer.slice(0, nl)) as DispatchResult
69
+ finish(response.ok ? { ok: true } : { ok: false, error: response.error || 'claude-headless control rejected the request' })
70
+ } catch (error) {
71
+ finish({ ok: false, error: `claude-headless returned an invalid control response: ${(error as Error).message}` })
72
+ }
73
+ })
74
+ socket.on('error', (error) => finish({ ok: false, error: `claude-headless controller unreachable for session ${id}: ${error.message}` }))
75
+ socket.on('close', () => finish({ ok: false, error: `claude-headless controller closed before confirming session ${id}` }))
76
+ })
77
+ }
78
+
79
+ export const deliverViaClaudeHeadless = (rec: HarnessDeliveryRecord, text: string) =>
80
+ controlRequest(rec.session, { type: 'deliver', text })
81
+
82
+ export const interruptClaudeHeadless = (rec: HarnessDeliveryRecord) =>
83
+ controlRequest(rec.session, { type: 'interrupt' })
84
+
85
+ export class ClaudeHeadlessController {
86
+ private server: Server | null = null
87
+ private child: ChildTurn | null = null
88
+ private controlQueue: Promise<void> = Promise.resolve()
89
+ private closing = false
90
+ private readonly messagesPath: string
91
+ private readonly socketPath: string
92
+
93
+ constructor(
94
+ private readonly id: string,
95
+ runtimeDir: string,
96
+ private readonly claudeCmd: string,
97
+ private readonly cwd = process.cwd(),
98
+ ) {
99
+ const dir = join(runtimeDir, 'sessions', id)
100
+ mkdirSync(dir, { recursive: true })
101
+ this.messagesPath = join(dir, 'messages.ndjson')
102
+ this.socketPath = claudeHeadlessSock(id)
103
+ }
104
+
105
+ async start(initialPrompt?: string): Promise<void> {
106
+ try { rmSync(this.socketPath, { force: true }) } catch { /* stale control socket is replaced at startup */ }
107
+ this.server = createServer((socket) => this.accept(socket))
108
+ await new Promise<void>((resolve, reject) => {
109
+ const onError = (error: Error) => { this.server?.off('listening', onListening); reject(error) }
110
+ const onListening = () => { this.server?.off('error', onError); resolve() }
111
+ this.server!.once('error', onError)
112
+ this.server!.once('listening', onListening)
113
+ this.server!.listen(this.socketPath)
114
+ })
115
+ if (initialPrompt) void this.spawnTurn(initialPrompt, false).catch((error) => {
116
+ console.error(`[spex claude-headless] initial turn failed: ${(error as Error).message}`)
117
+ })
118
+ }
119
+
120
+ async close(): Promise<void> {
121
+ if (this.closing) return
122
+ this.closing = true
123
+ const child = this.child
124
+ if (child && child.process.exitCode === null) child.process.kill('SIGTERM')
125
+ await new Promise<void>((resolve) => {
126
+ if (!this.server) return resolve()
127
+ this.server.close(() => resolve())
128
+ })
129
+ try { rmSync(this.socketPath, { force: true }) } catch { /* best-effort cleanup after close */ }
130
+ }
131
+
132
+ private accept(socket: Socket): void {
133
+ socket.setEncoding('utf8')
134
+ let buffer = ''
135
+ let handled = false
136
+ socket.on('data', (chunk) => {
137
+ if (handled) return
138
+ buffer += chunk
139
+ const nl = buffer.indexOf('\n')
140
+ if (nl < 0) return
141
+ handled = true
142
+ let request: ControlRequest
143
+ try {
144
+ request = JSON.parse(buffer.slice(0, nl)) as ControlRequest
145
+ } catch (error) {
146
+ socket.end(`${JSON.stringify({ ok: false, error: `invalid control request: ${(error as Error).message}` })}\n`)
147
+ return
148
+ }
149
+ this.controlQueue = this.controlQueue.then(async () => {
150
+ const result = await this.handle(request).catch((error) => ({ ok: false, error: (error as Error).message }))
151
+ socket.end(`${JSON.stringify(result)}\n`)
152
+ })
153
+ })
154
+ }
155
+
156
+ private async handle(request: ControlRequest): Promise<DispatchResult> {
157
+ if (request.type === 'deliver') {
158
+ if (!request.text) return { ok: false, error: 'empty prompt - nothing to deliver' }
159
+ const current = this.child
160
+ if (current?.active && current.process.stdin.writable) {
161
+ await this.writeLine(current, userEvent(request.text))
162
+ return { ok: true }
163
+ }
164
+ if (current) await withTimeout(current.exited, 5_000, 'previous claude-headless turn did not exit after its result')
165
+ await this.spawnTurn(request.text, true)
166
+ return { ok: true }
167
+ }
168
+ if (request.type === 'interrupt') return this.interrupt()
169
+ return { ok: false, error: 'unknown claude-headless control request' }
170
+ }
171
+
172
+ private async interrupt(): Promise<DispatchResult> {
173
+ const child = this.child
174
+ if (!child?.active || !child.process.stdin.writable) return { ok: true }
175
+ const requestId = randomUUID()
176
+ const ack = new Promise<void>((resolve) => child.interruptAcks.set(requestId, resolve))
177
+ await this.writeLine(child, JSON.stringify({
178
+ type: 'control_request',
179
+ request_id: requestId,
180
+ request: { subtype: 'interrupt' },
181
+ }))
182
+ try {
183
+ await withTimeout(ack, INTERRUPT_TIMEOUT_MS, `claude-headless interrupt was not confirmed for session ${this.id}`)
184
+ return { ok: true }
185
+ } finally {
186
+ child.interruptAcks.delete(requestId)
187
+ }
188
+ }
189
+
190
+ private async spawnTurn(text: string, resume: boolean): Promise<void> {
191
+ if (this.closing) throw new Error('claude-headless controller is closing')
192
+ const mode = resume ? ['--resume', this.id] : ['--session-id', this.id]
193
+ const args = ['-p', ...mode, '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose']
194
+ const command = `exec ${this.claudeCmd} ${args.map(shQuote).join(' ')}`
195
+ const childProcess = spawn('/bin/sh', ['-lc', command], { cwd: this.cwd, env: process.env, stdio: ['pipe', 'pipe', 'pipe'] })
196
+ let sawFirstEvent!: () => void
197
+ const firstEvent = new Promise<void>((resolve) => { sawFirstEvent = resolve })
198
+ let resolveExit!: (code: number | null) => void
199
+ const exited = new Promise<number | null>((resolve) => { resolveExit = resolve })
200
+ const turn: ChildTurn = { process: childProcess, active: true, exited, firstEvent, sawFirstEvent, interruptAcks: new Map() }
201
+ this.child = turn
202
+ let stdoutBuffer = ''
203
+ childProcess.stdout.setEncoding('utf8')
204
+ childProcess.stderr.pipe(process.stderr)
205
+ childProcess.stdout.on('data', (chunk) => {
206
+ stdoutBuffer += chunk
207
+ for (;;) {
208
+ const nl = stdoutBuffer.indexOf('\n')
209
+ if (nl < 0) break
210
+ const line = stdoutBuffer.slice(0, nl)
211
+ stdoutBuffer = stdoutBuffer.slice(nl + 1)
212
+ const nativeLine = `${line}\n`
213
+ appendFileSync(this.messagesPath, nativeLine)
214
+ globalThis.process.stdout.write(nativeLine)
215
+ turn.sawFirstEvent()
216
+ this.observeEvent(turn, line)
217
+ }
218
+ })
219
+ childProcess.once('error', (error) => {
220
+ console.error(`[spex claude-headless] child spawn failed: ${error.message}`)
221
+ })
222
+ childProcess.once('close', (code) => {
223
+ turn.active = false
224
+ if (stdoutBuffer) console.error('[spex claude-headless] dropped a partial non-line stdout event')
225
+ if (this.child === turn) this.child = null
226
+ resolveExit(code)
227
+ })
228
+ await this.writeLine(turn, userEvent(text))
229
+ await Promise.race([
230
+ withTimeout(turn.firstEvent, START_TIMEOUT_MS, `claude-headless child produced no stream event for session ${this.id}`),
231
+ turn.exited.then((code) => { throw new Error(`claude-headless child exited before accepting the turn (code ${code ?? 'signal'})`) }),
232
+ ])
233
+ }
234
+
235
+ private observeEvent(turn: ChildTurn, line: string): void {
236
+ let event: any
237
+ try { event = JSON.parse(line) } catch { return }
238
+ if (event?.type === 'control_response' && typeof event?.response?.request_id === 'string') {
239
+ turn.interruptAcks.get(event.response.request_id)?.()
240
+ }
241
+ if (event?.type === 'result') {
242
+ turn.active = false
243
+ turn.process.stdin.end()
244
+ }
245
+ }
246
+
247
+ private writeLine(turn: ChildTurn, line: string): Promise<void> {
248
+ return new Promise((resolve, reject) => {
249
+ if (!turn.process.stdin.writable) return reject(new Error('claude-headless child stdin is not writable'))
250
+ turn.process.stdin.write(`${line}\n`, (error) => error ? reject(error) : resolve())
251
+ })
252
+ }
253
+ }
254
+
255
+ export async function runClaudeHeadlessController(
256
+ id: string,
257
+ runtimeDir: string,
258
+ claudeCmd: string,
259
+ tail: string[],
260
+ ): Promise<void> {
261
+ const controller = new ClaudeHeadlessController(id, runtimeDir, claudeCmd)
262
+ const resume = tail[0] === '--resume'
263
+ const prompt = resume ? undefined : tail[0] === '--session-id' ? tail.slice(2).join(' ') : tail.join(' ')
264
+ await controller.start(prompt)
265
+ await new Promise<void>((resolve) => {
266
+ const stop = () => void controller.close().finally(resolve)
267
+ process.once('SIGINT', stop)
268
+ process.once('SIGTERM', stop)
269
+ process.once('SIGHUP', stop)
270
+ })
271
+ }
@@ -1,6 +1,6 @@
1
1
  export {} // make this a module so top-level await is allowed
2
2
  // static import is fine here: mentions.ts is dependency-free at module level, and stripRefSigil is needed
3
- // by several verbs (spec owner, session new, graph) — a CLI reference arg tolerates an optional @/[[ ]]
3
+ // by several verbs (spec owner, graph, issue/eval node args) — a CLI reference arg tolerates an optional @/[[ ]]
4
4
  // sigil ([[mentions]]).
5
5
  import { stripRefSigil } from './mentions.js'
6
6
 
@@ -60,6 +60,19 @@ function positionals(from: number): string[] {
60
60
  return out
61
61
  }
62
62
 
63
+ function rejectUnknownFlags(command: string, from: number, allowed: readonly string[]): void {
64
+ const known = new Set(allowed.map((name) => `--${name}`))
65
+ for (let i = from; i < process.argv.length; i++) {
66
+ const token = process.argv[i]
67
+ if (!token.startsWith('--')) continue
68
+ if (!known.has(token)) {
69
+ console.error(`${command}: unknown flag ${token}`)
70
+ process.exit(2)
71
+ }
72
+ if (VALUE_FLAGS.has(token)) i++
73
+ }
74
+ }
75
+
63
76
  // @@@ signposts (one version only — delete in 0.4.0) - every spelling v0.3.0 removed maps to its new home.
64
77
  // A signpost REPORTS and exits 2; it never executes (not an alias): a stale hook or a human's muscle memory
65
78
  // gets a readable failure that names the migration, and nothing old keeps silently working.
@@ -73,7 +86,6 @@ const SIGNPOSTS: Record<string, string> = {
73
86
  blob: 'spex evidence put|get',
74
87
  issues: 'spex issue — ls (was: bare issues) · show · open · reply · close · promote; on|off|status → the `issues.enabled` key in spexcode.json; `issues nudge` → spex internal nudge',
75
88
  forge: 'spex issue links [--pending] [--store <host>] (--host is now --store)',
76
- dashboard: 'spex serve ui',
77
89
  new: 'spex session new',
78
90
  ls: 'spex session ls',
79
91
  watch: 'spex session watch',
@@ -173,7 +185,7 @@ async function withWatchEdge<T>(selectors: string[], intervalMs: number, body: (
173
185
  }
174
186
 
175
187
  async function resolveSelectorOrExit(selector: string): Promise<string> {
176
- if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch)'); process.exit(2) }
188
+ if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch | . for self)'); process.exit(2) }
177
189
  const { resolveClientSession } = await import('./client.js')
178
190
  const { sessionLabel } = await import('./sessions.js')
179
191
  const r = await resolveClientSession(selector)
@@ -309,6 +321,19 @@ if (cmd === 'serve') {
309
321
  console.error(`spex serve: unknown target '${target}' — spex serve [api] (the backend) | spex serve ui (the dashboard)`)
310
322
  process.exit(2)
311
323
  }
324
+ } else if (cmd === 'dashboard') {
325
+ // the HOST-level dashboard ([[host-gateway]]): ONE gateway for every project this user serves. The
326
+ // engine is [[gateway-hub]] (routing + [[gateway-auth]] authorization: admin scope implicit from
327
+ // loopback until an admin password is set; per-project gates as configured); the host layer mounts the
328
+ // instance-validated project registry, its SSE stream, the durable catalog, and the /projects
329
+ // operations (register · init · doctor · start a backend) as the hub's admin extension. No --api-port
330
+ // pairing: which backend a request reaches is named in its /p/:projectId path, resolved per request.
331
+ // `spex serve ui` remains the explicit one-backend pairing; this verb is the zero-config many-project face.
332
+ const { startHostDashboard } = await import('./host.js')
333
+ const port = Number(flag('port') ?? process.env.SPEXCODE_DASHBOARD_PORT ?? 5173)
334
+ const host = flag('host') ?? '127.0.0.1'
335
+ if (!Number.isInteger(port)) { console.error('spex dashboard: --port must be an integer'); process.exit(2) }
336
+ startHostDashboard({ port, host })
312
337
  } else if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
313
338
  // `spex help <cmd>` drills into one command; bare help is the map. Both name the next layer down.
314
339
  const { commandHelp, overviewHelp } = await import('./help.js')
@@ -467,9 +492,9 @@ if (cmd === 'serve') {
467
492
  }
468
493
  } else if (cmd === 'init') {
469
494
  // scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
470
- // into <targetDir> (default cwd). spex init [targetDir] [--preset <tier>]
495
+ // into <targetDir> (default cwd). spex init [targetDir] --harness <ids> [--preset <tier>]
471
496
  const { specInit } = await import('./init.js')
472
- await specInit(positionals(3)[0], flag('preset'))
497
+ await specInit(positionals(3)[0], flag('preset'), flag('harness'))
473
498
  } else if (cmd === 'uninstall') {
474
499
  // the surgical inverse of init: remove every SpexCode-generated artifact (harness shims/contract/trust, the
475
500
  // .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.plugins data or their own
@@ -497,42 +522,35 @@ if (cmd === 'serve') {
497
522
  if (!r.ok) { console.error(`no evals for ${id} (status ${r.status})`); process.exit(1) }
498
523
  if (has('json')) { console.log(JSON.stringify(r.model, null, 2)); await flushExit(0) }
499
524
  const m = r.model
500
- // mirror the tab's scenarioStates per node: latest reading per DECLARED scenario (evals arrive
501
- // newest-first), so a retired scenario's residual reading contributes no row; the ✦ count is over
502
- // these rows the same number the tab's chip shows.
503
- const groups = m.nodes.map((n) => {
504
- const latest = new Map<string, (typeof n.evals)[number]>()
505
- for (const e of n.evals) if (!latest.has(e.scenario)) latest.set(e.scenario, e)
506
- const blind = n.scenarios.filter((s) => !latest.has(s.name))
507
- const rows = n.scenarios.filter((s) => latest.has(s.name)).map((s) => latest.get(s.name)!)
508
- .sort((a, b) => (Number(b.inSession) - Number(a.inSession)) || (a.ts < b.ts ? 1 : -1))
509
- return { n, blind, rows }
510
- })
511
- const own = groups.reduce((a, g) => a + g.rows.filter((e) => e.inSession).length, 0)
512
- console.log(`eval ${m.title} [${m.id}]`)
513
- console.log(` branch : ${m.branch ?? '—'} · ${m.ahead} commit(s) ahead · ${m.dirtyNonRuntime} uncommitted`)
525
+ const byNode = new Map<string, any[]>()
526
+ for (const item of m.items) {
527
+ const rows = byNode.get(item.node) ?? []
528
+ rows.push(item)
529
+ byNode.set(item.node, rows)
530
+ }
531
+ const groups = [...byNode].map(([node, rows]) => ({ node, rows }))
532
+ const own = m.items.filter((item) => item.inSession).length
533
+ console.log(`eval session [${m.id}]`)
514
534
  console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
515
535
  if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = inherited baseline)`)
516
- if (!m.nodes.length) console.log('\n no changed spec nodes — nothing to evaluate yet (empty diff)')
517
- for (const { n, blind, rows } of groups) {
518
- console.log(`\n${n.title} [${n.id}]${n.uncoveredFrontend ? ' ⚠ frontend change with NO eval.md — a blind spot: give it a scenario' : ''}`)
519
- for (const s of blind) console.log(` ∅ unmeasured ${s.name} — declared, never measured (blind spot)`)
536
+ if (!m.items.length) console.log('\n no affected scenarios to evaluate yet')
537
+ for (const { node, rows } of groups) {
538
+ console.log(`\n${node}`)
539
+ for (const item of rows.filter((row) => row.filterKind === 'blind')) console.log(` ∅ unmeasured ${item.scenario} — declared, never measured (blind spot)`)
520
540
  let divided = false
521
- for (const e of rows) {
522
- if (!e.inSession && !divided && rows.some((x) => x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
541
+ for (const e of rows.filter((row) => row.filterKind === 'result')) {
542
+ if (!e.inSession && !divided && rows.some((x) => x.filterKind === 'result' && x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
523
543
  const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
524
- const stale = e.fresh ? '' : ` (stale: ${e.staleAxes.join(',')})`
544
+ const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
525
545
  console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
526
546
  }
527
- if (!n.hasEvalFile && !n.uncoveredFrontend) console.log(' (no eval.md — nothing declared to measure)')
528
- else if (n.hasEvalFile && !n.scenarios.length) console.log(' (eval.md declares no scenarios)')
529
547
  }
530
- } else if (['add', 'ls', 'scenario', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
548
+ } else if (['add', 'ls', 'scenario', 'matrix', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
531
549
  // node-scoped verbs — thin route; the logic lives in spec-eval.
532
550
  const { runEval } = await import('../../spec-eval/src/cli.js')
533
551
  await flushExit(await runEval(process.argv.slice(3)))
534
552
  } else {
535
- console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | lint | ok | retract | clean (spex help eval)`)
553
+ console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | matrix | lint | ok | retract | clean (spex help eval)`)
536
554
  if (!sub.startsWith('--')) console.error(` (the old \`spex eval <SEL>\` session read is now \`spex eval ls --session <SEL>\` [--export])`) // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
537
555
  process.exit(2)
538
556
  }
@@ -583,7 +601,14 @@ if (cmd === 'serve') {
583
601
  // trust, for cwd's project. Anchored on git-native events only ([[commit-surgery]]): this verb, init,
584
602
  // session-worktree creation, and the planted pre-commit/post-checkout/post-merge hooks.
585
603
  const { materialize } = await import('./materialize.js')
586
- console.log(`materialized — content-hash ${materialize()}`)
604
+ try {
605
+ console.log(`materialized — content-hash ${materialize().contentHash}`)
606
+ } catch (e) {
607
+ // a policy error (e.g. a missing/illegal `harnesses` set) is a user-facing verdict, not a crash — one
608
+ // line + the repair it already carries, never a stack trace.
609
+ console.error(`spex materialize: ${(e as Error).message}`)
610
+ process.exit(1)
611
+ }
587
612
  } else if (cmd === 'doctor') {
588
613
  // @@@ doctor - the diagnosis surface ([[doctor]], né `self` — renamed: "self" read as the tool itself /
589
614
  // the global install, while the report is about THIS agent's wiring): does the materialized workflow
@@ -599,12 +624,16 @@ if (cmd === 'serve') {
599
624
  } else if (SESSION_SIGNPOSTS[sub]) {
600
625
  signpost(`spex session ${sub}`, SESSION_SIGNPOSTS[sub])
601
626
  } else if (sub === 'new') {
602
- // spex session new "<prompt>" [--node X] (prompt = first positional or --prompt, or --prompt-file
627
+ // spex session new "<prompt>" (prompt = first positional or --prompt, or --prompt-file
603
628
  // <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
604
629
  // createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
605
630
  // it falls back to an in-process launch only when no backend answers.
631
+ if (has('node')) {
632
+ console.error('spex session new: --node was removed — put a [[<id>]] mention in the prompt — the first mention binds')
633
+ process.exit(2)
634
+ }
635
+ rejectUnknownFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'api', 'port'])
606
636
  const { createSession } = await import('./sessions.js')
607
- if (has('harness')) { console.error('spex session new: --harness was removed; use --launcher <name> (for example --launcher codex)'); process.exit(2) }
608
637
  const promptFile = flag('prompt-file')
609
638
  const inline = flag('prompt') ?? positionals(4)[0]
610
639
  let prompt = inline ?? ''
@@ -616,8 +645,7 @@ if (cmd === 'serve') {
616
645
  catch (e) { console.error(`spex session new: --prompt-file ${promptFile}: ${e instanceof Error ? e.message : e}`); process.exit(2) }
617
646
  if (!prompt.trim()) { console.error(`spex session new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
618
647
  }
619
- const nodeArg = flag('node')
620
- const created = await createSession(nodeArg ? stripRefSigil(nodeArg) : null, prompt, flag('launcher') ?? undefined)
648
+ const created = await createSession(prompt, flag('launcher') ?? undefined)
621
649
  console.log(JSON.stringify(created, null, 2))
622
650
  await launchMonitorReminder(created.id)
623
651
  } else if (sub === 'ls') {
@@ -728,7 +756,7 @@ if (cmd === 'serve') {
728
756
  // overrides for a genuinely wedged process. A following prompt is what actually re-drives it.
729
757
  const full = await resolveSelectorOrExit(id)
730
758
  const r = await c.clientResume(full, process.argv.includes('--force'))
731
- if (r.ok) console.log(`${full} -> resumed`)
759
+ if (r.ok) console.log(r.info ? `${full} -> ${r.info}` : `${full} -> resumed`)
732
760
  else { console.error(`spex session resume: ${r.error || `no such session ${full}`}`); process.exit(2) }
733
761
  } else if (sub === 'done') {
734
762
  // sugar for awaiting; --propose merge|nothing|close, optional --note
@@ -758,6 +786,11 @@ if (cmd === 'serve') {
758
786
  // can be resumed (`session resume`). Distinct from `close`, which removes the worktree.
759
787
  const full = await resolveSelectorOrExit(id)
760
788
  console.log(await c.clientStop(full) ? `stopped ${full} (worktree kept — resumable)` : `no such session ${full}`)
789
+ } else if (sub === 'interrupt') {
790
+ const full = await resolveSelectorOrExit(id)
791
+ const r = await c.clientInterrupt(full)
792
+ console.log(r.ok ? `interrupted ${full}` : `interrupt failed: ${r.error}`)
793
+ process.exit(r.ok ? 0 : 1)
761
794
  } else if (sub === 'close') {
762
795
  const full = await resolveSelectorOrExit(id)
763
796
  console.log(await c.clientClose(full) ? `closed ${full}` : `no such session ${full}`)
@@ -840,7 +873,7 @@ if (cmd === 'serve') {
840
873
  await assertLocalBackend()
841
874
  await attachSession(await resolveSelectorOrExit(id))
842
875
  } else {
843
- console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
876
+ console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | interrupt | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
844
877
  process.exit(2)
845
878
  }
846
879
  }
@@ -855,6 +888,18 @@ if (cmd === 'serve') {
855
888
  // line; GET /api/settings exposes the same resolution (`.layout`).
856
889
  const { mainBranch } = await import('./layout.js')
857
890
  console.log(mainBranch())
891
+ } else if (sub === 'spec-governors') {
892
+ // Stable machine projection for spec-aware hooks: one real code: governor per row, with the live spec
893
+ // path the block reason can point at. Empty stdout means ungoverned (including related-only).
894
+ const file = process.argv[4]
895
+ if (!file) { console.error('usage: spex internal spec-governors <path>'); process.exit(2) }
896
+ const { specOwners, loadSpecsLite } = await import('./specs.js')
897
+ const paths = new Map(loadSpecsLite().map((node) => [node.id, node.path]))
898
+ for (const owner of specOwners(file)) {
899
+ const path = paths.get(owner.id)
900
+ if (!path) throw new Error(`governor '${owner.id}' has no live spec path`)
901
+ console.log(`${owner.id}\t${path}`)
902
+ }
858
903
  } else if (sub === 'codex-launch') {
859
904
  // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
860
905
  // (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
@@ -868,7 +913,7 @@ if (cmd === 'serve') {
868
913
  // On the bypass-trust path (the codex install supports the flag → materialize skipped writeCodexTrust's hash),
869
914
  // the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
870
915
  // the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
871
- const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex --yolo'))
916
+ const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex'))
872
917
  const r = await codexStartThread(sock, cwd, bypassHookTrust)
873
918
  if (!r.ok) { console.error(r.error); process.exit(1) }
874
919
  if (prompt) {
@@ -887,6 +932,35 @@ if (cmd === 'serve') {
887
932
  const sid = process.env.SPEXCODE_SESSION_ID
888
933
  if (sid) markHarnessSessionId(sid, r.threadId)
889
934
  console.log(r.threadId)
935
+ } else if (sub === 'opencode-capture') {
936
+ // opencode MINTS its own session id (no launch flag pins it), so the generated plugin's FIRST event calls
937
+ // this to store that id as harness_session_id on the governed record (SPEXCODE_SESSION_ID from the launch
938
+ // env, inherited by the opencode process → plugin). That is what lets reopen() resume the SAME
939
+ // conversation (`--session <id>`). A missing record/env is a clean no-op — a plugin loaded outside a
940
+ // governed launch has nothing to mark.
941
+ const { markHarnessSessionId } = await import('./sessions.js')
942
+ const ocid = process.argv[4]
943
+ if (!ocid) { console.error('usage: spex internal opencode-capture <opencode-session-id>'); process.exit(2) }
944
+ const sid = process.env.SPEXCODE_SESSION_ID
945
+ console.log(sid && markHarnessSessionId(sid, ocid) ? `captured ${ocid}` : 'noop (no governed session record)')
946
+ } else if (sub === 'claude-headless-run') {
947
+ const id = process.argv[4], runtimeDir = process.argv[5], claudeCmd = process.argv[6]
948
+ const divider = process.argv[7]
949
+ if (!id || !runtimeDir || !claudeCmd || divider !== '--') {
950
+ console.error('usage: spex internal claude-headless-run <session-id> <runtime-dir> <claude-cmd> -- [--session-id <id> <prompt> | --resume <id>]')
951
+ process.exit(2)
952
+ }
953
+ const { runClaudeHeadlessController } = await import('./claude-headless.js')
954
+ await runClaudeHeadlessController(id, runtimeDir, claudeCmd, process.argv.slice(8))
955
+ } else if (sub === 'pi-headless-run') {
956
+ const id = process.argv[4], runtimeDir = process.argv[5], piCmd = process.argv[6]
957
+ const divider = process.argv[7]
958
+ if (!id || !runtimeDir || !piCmd || divider !== '--') {
959
+ console.error('usage: spex internal pi-headless-run <session-id> <runtime-dir> <pi-cmd> -- [--session-id <id> <prompt> | --session <id>]')
960
+ process.exit(2)
961
+ }
962
+ const { runPiHeadlessController } = await import('./pi-headless.js')
963
+ await runPiHeadlessController(id, runtimeDir, piCmd, process.argv.slice(8))
890
964
  } else if (sub === 'commit-surgery') {
891
965
  // the pre-commit footprint anchor ([[commit-surgery]]): unconditional materialize + staged-index repair
892
966
  // (strip our sentinel block from staged blobs, unstage HEAD-untracked generated artifacts). Called only
@@ -1,5 +1,4 @@
1
1
  import { apiBase, assertProjectMatch, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
2
- import type { SessionEvals } from '../../spec-eval/src/sessioneval.js'
3
2
 
4
3
  export class BackendError extends Error {
5
4
  constructor(message: string, readonly status?: number) {
@@ -74,15 +73,38 @@ export async function clientEvalExport(id: string): Promise<ExportResult> {
74
73
  return { ok: false, status: r.status }
75
74
  }
76
75
 
77
- // GET /api/sessions/:id/evals the session EVAL model ([[session-eval]]'s interactive face, the same
78
- // route's default JSON representation): the changed
79
- // nodes' worktree-rooted reading rows (each carrying `inSession`), no diff enrichment, no inlined evidence
80
- // bytes — what `spex eval` renders, the dashboard Eval tab's source. 404 → no such session.
81
- export type EvalsResult = { ok: true; model: SessionEvals } | { ok: false; status: number }
76
+ // The CLI's explicit aggregate walks the same 25-row pages as the dashboard. No server response contains
77
+ // the full session model; aggregation exists only for this one-shot terminal rendering.
78
+ type SessionEvalPage = {
79
+ items: any[]
80
+ page: number
81
+ pageCount: number
82
+ total: number
83
+ gates: any[]
84
+ unknown: number
85
+ revision: string
86
+ summary?: any
87
+ evalRevision?: any
88
+ }
89
+ export type EvalsResult = { ok: true; model: SessionEvalPage & { id: string } } | { ok: false; status: number }
82
90
  export async function clientEvals(id: string): Promise<EvalsResult> {
83
- const r = await apiFetch(`/api/sessions/${seg(id)}/evals`)
84
- if (!r.ok) return { ok: false, status: r.status }
85
- return { ok: true, model: await r.json() as SessionEvals }
91
+ const q = encodeURIComponent(`is:eval scope:${id}`)
92
+ for (let attempt = 0; attempt < 2; attempt++) {
93
+ const items: any[] = []
94
+ let first: SessionEvalPage | null = null
95
+ let changed = false
96
+ for (let page = 1;; page++) {
97
+ const r = await apiFetch(`/api/evals?q=${q}&page=${page}`)
98
+ if (!r.ok) return { ok: false, status: r.status }
99
+ const current = await r.json() as SessionEvalPage
100
+ first ??= current
101
+ if (current.revision !== first.revision) { changed = true; break }
102
+ items.push(...current.items)
103
+ if (page >= current.pageCount) break
104
+ }
105
+ if (!changed) return { ok: true, model: { ...first!, id, items } }
106
+ }
107
+ throw new BackendError(`session eval pages changed while reading ${id}; retry the command`)
86
108
  }
87
109
 
88
110
  // POST /api/sessions/:id/merge — the cockpit's merge DISPATCH (200 {dispatched:true} / 409 {reason}).
@@ -94,8 +116,9 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
94
116
 
95
117
  // POST /api/sessions/:id/resume — bring the agent back (relaunch ONLY if confirmed offline); demotes
96
118
  // working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
97
- // `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
98
- export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
119
+ // `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404). `info`
120
+ // carries a non-error advisory.
121
+ export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
99
122
  await guarded('session resume')
100
123
  const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
101
124
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
@@ -109,6 +132,14 @@ export async function clientStop(id: string): Promise<boolean> {
109
132
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
110
133
  }
111
134
 
135
+ // POST /api/sessions/:id/interrupt - native hard interrupt of the current turn. Unsupported harnesses and
136
+ // unreachable control planes return the backend's loud DispatchResult; no signal/raw-key fallback exists.
137
+ export async function clientInterrupt(id: string): Promise<DispatchResult> {
138
+ await guarded('session interrupt')
139
+ const r = await apiFetch(`/api/sessions/${seg(id)}/interrupt`, post({}))
140
+ return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
141
+ }
142
+
112
143
  // POST /api/sessions/:id/close — the human-only worktree removal. {ok:false} = no such session.
113
144
  export async function clientClose(id: string): Promise<boolean> {
114
145
  await guarded('session close')