spexcode 0.6.0 → 0.6.2

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 (88) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. package/spec-eval/src/matrix.ts +0 -693
@@ -1,693 +0,0 @@
1
- import { execFile } from 'node:child_process'
2
- import { randomBytes } from 'node:crypto'
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
4
- import { join } from 'node:path'
5
- import { fileURLToPath } from 'node:url'
6
- import { repoRoot } from '../../spec-cli/src/git.js'
7
- import { resolveLauncher, type Launcher } from '../../spec-cli/src/harness.js'
8
- import { envSessionId, mainCheckout, sessionStoreDir } from '../../spec-cli/src/layout.js'
9
- import { BOOT_GRACE_MS, TMUX_SOCK } from '../../spec-cli/src/sessions.js'
10
- import { fileHumanReading } from './filing.js'
11
- import { parseScenarios, type Scenario } from './scenarios.js'
12
-
13
- // @@@ live-behavior matrix - the parameterized conformance suite behind [[harness-adapter]]'s acceptance
14
- // rule. The eight lifecycle behaviors an adapter must prove are defined ONCE here, harness-agnostically —
15
- // each row = drive steps + expected + evidence collection — and `spex eval matrix <launcher>` runs them
16
- // against a REAL dispatched session of any registered launcher, reusing only the public CLI verbs
17
- // (session new/send/show/stop/resume/close, materialize) plus tmux for the kill. A new harness is covered
18
- // by registering its launcher and creating its `<harness>-harness` spec node — zero new runner code.
19
- //
20
- // The rows are also the single source of the matrix's CONTRACT TEXT: before running, the suite SYNCS each
21
- // row's description/expected into the target harness node's eval.md scenarios (matching the node's
22
- // HISTORICAL scenario names via exact key, `<harness>-` prefix, or the alias list, so reading history is
23
- // never orphaned; a missing scenario is appended under the canonical key). One definition, N
24
- // materializations — the same shape materialize gives the plugin surfaces.
25
-
26
- const SPEX_BIN = fileURLToPath(new URL('../../spec-cli/bin/spex.mjs', import.meta.url))
27
-
28
- export type RowVerdict = { status: 'pass' | 'fail' | 'skip'; note: string }
29
- export type MatrixRow = {
30
- key: string // canonical scenario name (used verbatim for a freshly-scaffolded harness node)
31
- aliases: string[] // the historical per-harness scenario names this row also answers to
32
- description: string // the harness-agnostic measurement contract (synced into eval.md)
33
- expected: string
34
- drive: (ctx: MatrixRun) => Promise<RowVerdict>
35
- }
36
-
37
- type ExecResult = { code: number; out: string; err: string }
38
-
39
- // settled = the worker's turn ended under a truthful state: a declaration (asking/awaiting/parked) or the
40
- // idle demotion. `active` means a turn is (still) running — never a settle.
41
- const SETTLED = new Set(['asking', 'awaiting', 'parked', 'idle'])
42
-
43
- const trim = (s: string, n = 1400): string => (s.length > n ? s.slice(0, n) + ` …[+${s.length - n}b]` : s)
44
-
45
- // @@@ MatrixRun - the shared run context a row drives through: ONE worker session (launched lazily, closed
46
- // by the close-residue row), logged subprocess helpers over the real CLI, and the per-row transcript slices
47
- // that become each reading's evidence.
48
- export class MatrixRun {
49
- readonly root = repoRoot()
50
- readonly launcher: Launcher
51
- readonly lines: string[] = []
52
- worker: { id: string; path: string; branch: string | null } | null = null
53
- lastLaunchAt = 0 // when we last (re)launched the worker's agent — the liveness row waits out the boot grace from here
54
- private rowStart = 0
55
- constructor(launcher: Launcher) { this.launcher = launcher }
56
-
57
- log(s: string): void {
58
- const line = `[${new Date().toISOString().slice(11, 19)}] ${s}`
59
- this.lines.push(line)
60
- console.log(line)
61
- }
62
- nonce(): string { return randomBytes(3).toString('hex') }
63
-
64
- beginRow(key: string): void { this.rowStart = this.lines.length; this.log(`--- row ${key} ---`) }
65
- rowTranscript(): string {
66
- return [`spex eval matrix ${this.launcher.name} (harness ${this.launcher.harness})`, ...this.lines.slice(this.rowStart)].join('\n')
67
- }
68
-
69
- exec(cmd: string, args: string[], opts: { cwd?: string; quiet?: boolean } = {}): Promise<ExecResult> {
70
- return new Promise((resolve) => {
71
- execFile(cmd, args, { cwd: opts.cwd ?? this.root, maxBuffer: 16 * 1024 * 1024 }, (e, out, err) => {
72
- const code = e ? (typeof (e as NodeJS.ErrnoException & { code?: unknown }).code === 'number' ? (e as unknown as { code: number }).code : 1) : 0
73
- if (!opts.quiet) {
74
- this.log(`$ ${cmd === process.execPath ? 'spex' : cmd} ${args.filter((a) => a !== SPEX_BIN).join(' ')} -> exit ${code}`)
75
- if (out.trim()) this.log(` stdout: ${trim(out.trim())}`)
76
- if (err.trim()) this.log(` stderr: ${trim(err.trim(), 600)}`)
77
- }
78
- resolve({ code, out, err })
79
- })
80
- })
81
- }
82
- spex(args: string[], opts: { cwd?: string; quiet?: boolean } = {}): Promise<ExecResult> {
83
- return this.exec(process.execPath, [SPEX_BIN, ...args], opts)
84
- }
85
-
86
- // the board's per-session read — the SAME surface a human manager polls. null = the backend answered
87
- // "no such session" (or is down); callers treat that per row.
88
- async show(): Promise<Record<string, any> | null> {
89
- if (!this.worker) return null
90
- const r = await this.spex(['session', 'show', this.worker.id, '--json'], { quiet: true })
91
- if (r.code !== 0) return null
92
- try { return JSON.parse(r.out) } catch { return null }
93
- }
94
- async capture(label: string): Promise<string> {
95
- if (!this.worker) return ''
96
- const r = await this.spex(['session', 'show', this.worker.id, '--capture'], { quiet: true })
97
- this.log(`pane capture (${label}, ${r.code === 0 ? `${r.out.length}b` : 'unavailable'}):\n${trim(r.out.trimEnd(), 2600)}`)
98
- return r.code === 0 ? r.out : ''
99
- }
100
-
101
- // poll a predicate over the live board until it holds or the budget expires. The polling itself is part
102
- // of the measurement: every tick is a real board read, i.e. the normal probe pressure deliver must survive.
103
- async poll<T>(what: string, seconds: number, fn: () => Promise<T | null | false>): Promise<T | null> {
104
- const t0 = Date.now()
105
- let last = ''
106
- while (Date.now() - t0 < seconds * 1000) {
107
- const v = await fn()
108
- if (v) { this.log(`observed: ${what} (after ${Math.round((Date.now() - t0) / 1000)}s)`); return v }
109
- const s = await this.show()
110
- const now = s ? `${s.status}/${s.lifecycle}/${s.liveness}` : 'no-record'
111
- if (now !== last) { this.log(`… waiting for ${what} — status ${now}`); last = now }
112
- await new Promise((r) => setTimeout(r, 2500))
113
- }
114
- this.log(`TIMEOUT (${seconds}s) waiting for ${what}`)
115
- return null
116
- }
117
- settle(seconds: number): Promise<Record<string, any> | null> {
118
- return this.poll('worker settled', seconds, async () => {
119
- const s = await this.show()
120
- return s && s.liveness === 'online' && SETTLED.has(s.lifecycle) ? s : false
121
- })
122
- }
123
- async send(text: string): Promise<number> {
124
- if (!this.worker) return 1
125
- return (await this.spex(['session', 'send', this.worker.id, text])).code
126
- }
127
- async resume(): Promise<number> {
128
- if (!this.worker) return 1
129
- const r = await this.spex(['session', 'resume', this.worker.id])
130
- this.lastLaunchAt = Date.now()
131
- return r.code
132
- }
133
- // send with a bounded retry: right after a relaunch a harness may honestly read online off its
134
- // pid-fallback while its control socket is still booting, and deliver fails LOUD in that window (per
135
- // contract). Retrying is what a human manager does; the deliver-steer row keeps strict single-shot sends
136
- // because first-shot accept on a settled worker IS its contract.
137
- async sendRetry(text: string, seconds: number): Promise<boolean> {
138
- const t0 = Date.now()
139
- while (true) {
140
- if ((await this.send(text)) === 0) return true
141
- if (Date.now() - t0 > seconds * 1000) return false
142
- this.log('send refused — retrying (the agent may still be rebinding its control socket)')
143
- await new Promise((r) => setTimeout(r, 3000))
144
- }
145
- }
146
- // wait until the agent is past the launcher's boot grace: inside that window a dead agent legitimately
147
- // reads `starting` (death is unprovable mid-boot), so a liveness kill must land on an ESTABLISHED agent.
148
- async waitEstablished(): Promise<void> {
149
- const wait = this.lastLaunchAt + BOOT_GRACE_MS + 5000 - Date.now()
150
- if (wait > 0) {
151
- this.log(`waiting ${Math.ceil(wait / 1000)}s for the boot grace to lapse (kill must land on an established agent)`)
152
- await new Promise((r) => setTimeout(r, wait))
153
- }
154
- }
155
-
156
- // launch the one worker the whole matrix drives. The prompt provokes row 1: a clean answer with an
157
- // explicit no-declaration instruction, so the FIRST settle is exactly the undeclared stop the gate must catch.
158
- async launchWorker(): Promise<Record<string, any> | null> {
159
- const prompt = 'Conformance probe. Reply with exactly one short line containing the single word: ready. '
160
- + 'Do not use any tool, do not run any command, and do NOT declare any session state (no spex commands). Just answer and stop.'
161
- const r = await this.spex(['session', 'new', '--launcher', this.launcher.name, '--prompt', prompt])
162
- if (r.code !== 0) { this.log('launch failed'); return null }
163
- let created: Record<string, any>
164
- try { created = JSON.parse(r.out) } catch { this.log('launch printed no JSON'); return null }
165
- this.worker = { id: created.id, path: created.path, branch: created.branch ?? null }
166
- this.lastLaunchAt = Date.now()
167
- this.log(`worker ${created.id} launched (worktree ${created.path}, branch ${created.branch})`)
168
- return created
169
- }
170
- async ensureSettledWorker(): Promise<Record<string, any> | null> {
171
- if (!this.worker) {
172
- if (!(await this.launchWorker())) return null
173
- return this.settle(420)
174
- }
175
- const s = await this.show()
176
- if (s && s.liveness === 'online' && SETTLED.has(s.lifecycle)) return s
177
- return this.settle(180)
178
- }
179
-
180
- // every strict descendant of the worker pane's root pid — the harness-agnostic "the agent process", used
181
- // by the liveness row's kill (the pane and window stay, so only the adapter's own signal can notice).
182
- async paneDescendants(): Promise<number[]> {
183
- if (!this.worker) return []
184
- const pane = await this.exec('tmux', ['-L', TMUX_SOCK, 'list-panes', '-t', this.worker.id, '-F', '#{pane_pid}'], { quiet: true })
185
- const panePid = Number(pane.out.trim().split('\n')[0])
186
- if (!panePid) return []
187
- const ps = await this.exec('ps', ['-eo', 'pid=,ppid='], { quiet: true })
188
- const kids = new Map<number, number[]>()
189
- for (const line of ps.out.split('\n')) {
190
- const m = /^\s*(\d+)\s+(\d+)/.exec(line)
191
- if (!m) continue
192
- const [pid, ppid] = [Number(m[1]), Number(m[2])]
193
- const siblings = kids.get(ppid)
194
- if (siblings) siblings.push(pid)
195
- else kids.set(ppid, [pid])
196
- }
197
- const out: number[] = []
198
- const stack = [panePid]
199
- while (stack.length) for (const c of kids.get(stack.pop()!) ?? []) { out.push(c); stack.push(c) }
200
- this.log(`pane pid ${panePid}, descendants: ${out.join(', ') || '(none)'}`)
201
- return out
202
- }
203
- }
204
-
205
- // ---------------------------------------------------------------------------------------------------------
206
- // the eight rows. Order matters: they share one worker whose lifecycle they walk end to end — launch/settle
207
- // (1), blocked tool (2), ask (3), deliver+steer (4), resume (5), kill/liveness (6), commit gate (7), close (8).
208
- // ---------------------------------------------------------------------------------------------------------
209
-
210
- export const MATRIX: MatrixRow[] = [
211
- {
212
- key: 'undeclared-stop',
213
- aliases: ['stop-gate-bridge', 'undeclared-stop-gate-rejection'],
214
- description: 'Live-behavior matrix row (run by `spex eval matrix <launcher>`): dispatch a real worker of '
215
- + 'this harness with a controlled prompt that answers one line and stops WITHOUT declaring, then watch '
216
- + 'the settle from the outside — no steering, no help.',
217
- expected: "The stop-gate's rejection reaches the session — the gate's teach sentinel is planted and the "
218
- + 'record flows out of `active` into a declared status (asking/review) on its own. The failure '
219
- + 'signature is a record stuck `active` forever with the rejection silently dropped.',
220
- drive: async (ctx) => {
221
- if (ctx.worker) return { status: 'skip', note: 'worker already launched — the first settle is gone' }
222
- if (!(await ctx.launchWorker())) return { status: 'fail', note: 'session new failed' }
223
- const s = await ctx.settle(420)
224
- await ctx.capture('after first settle')
225
- if (!s) return { status: 'fail', note: 'record stuck active past 420s — the stop-gate rejection never flowed the record out (the pi-harness incident signature)' }
226
- const taught = existsSync(join(sessionStoreDir(ctx.worker!.id), 'stop-gate-taught'))
227
- ctx.log(`stop-gate-taught sentinel: ${taught}`)
228
- if (!taught) return { status: 'skip', note: `worker declared on its own (${s.status}) before ever stopping undeclared — gate unprovoked, re-run to measure` }
229
- if (!['asking', 'awaiting'].includes(s.lifecycle)) return { status: 'fail', note: `gate taught but the record settled ${s.status}/${s.lifecycle}, not a declared state` }
230
- return { status: 'pass', note: `undeclared settle gated: teach sentinel planted, record flowed active -> ${s.status} unaided` }
231
- },
232
- },
233
- {
234
- key: 'pretooluse-block',
235
- aliases: ['pretooluse-block-live'],
236
- description: 'Matrix row: plant a transient `surface: hook` node (PreToolUse, block: true) guarding one '
237
- + "marked file in the live worker's worktree, `spex materialize` there, then tell the worker to modify "
238
- + 'the guarded file; sweep the node and re-materialize afterwards.',
239
- expected: "The tool call is genuinely blocked — the guarded file's content is untouched — and the "
240
- + "handler's OWN reason (a unique marker) is visible to the agent, who reports it; the session "
241
- + 'continues normally after the block.',
242
- drive: async (ctx) => {
243
- if (!(await ctx.ensureSettledWorker())) return { status: 'skip', note: 'no settled worker to drive' }
244
- const wt = ctx.worker!.path
245
- const plugRoot = findPluginsRoot(wt)
246
- if (!plugRoot) return { status: 'skip', note: 'no .plugins root in the worker worktree — nowhere to plant the guard hook' }
247
- const marker = `MATRIX-BLOCK-${ctx.nonce()}`
248
- const guardFile = join(wt, 'matrix-guard.txt')
249
- const guardContent = 'guarded content — the matrix pretooluse row plants and sweeps this file\n'
250
- const nodeDir = join(plugRoot, 'tmp-matrix-guard')
251
- try {
252
- writeFileSync(guardFile, guardContent)
253
- mkdirSync(nodeDir, { recursive: true })
254
- writeFileSync(join(nodeDir, 'spec.md'), '---\ntitle: tmp-matrix-guard\nsurface: hook\nstatus: active\nevents:\n- PreToolUse\norder: 5\nblock: true\n---\nTransient guard planted by `spex eval matrix` for the pretooluse-block row; swept by the same run.\n')
255
- const reason = `${marker}: matrix-guard.txt is guarded by the live-behavior matrix run. Do not try another way; report the marker you saw, then run: spex session ask --note 'blocked ${marker}'`
256
- writeFileSync(join(nodeDir, 'tmp-matrix-guard.sh'), `#!/usr/bin/env bash\npayload=$(cat 2>/dev/null)\ncase "$payload" in\n *matrix-guard.txt*) printf '%s\\n' ${shellQuote(JSON.stringify({ decision: 'block', reason }))} ;;\nesac\nexit 0\n`)
257
- const mat = await ctx.spex(['materialize'], { cwd: wt })
258
- if (mat.code !== 0) return { status: 'skip', note: 'materialize failed in the worker worktree — guard never armed' }
259
- const sent = await ctx.send("Using any tool, append a line saying TOUCHED to the file matrix-guard.txt in your worktree. If a hook blocks you, do not work around it — note the exact reason marker you saw, then run: spex session ask --note 'blocked: <the marker>' and stop.")
260
- if (sent !== 0) return { status: 'fail', note: 'instruction send was not accepted' }
261
- const seen = await ctx.poll('block marker visible + settled', 300, async () => {
262
- const s = await ctx.show()
263
- if (!s || !SETTLED.has(s.lifecycle)) return false
264
- const pane = await ctx.spex(['session', 'show', ctx.worker!.id, '--capture'], { quiet: true })
265
- return pane.out.includes(marker) || (s.note || '').includes(marker) ? s : false
266
- })
267
- await ctx.capture('after guarded touch attempt')
268
- const content = existsSync(guardFile) ? readFileSync(guardFile, 'utf8') : '(deleted)'
269
- ctx.log(`guarded file content unchanged: ${content === guardContent}`)
270
- if (content !== guardContent) return { status: 'fail', note: 'the guarded file CHANGED — the block did not genuinely stop the tool' }
271
- if (!seen) return { status: 'fail', note: "guarded file untouched but the handler's reason marker never surfaced to the agent" }
272
- return { status: 'pass', note: `tool call genuinely blocked (file untouched) and the handler's own reason ${marker} reached the agent` }
273
- } finally {
274
- rmSync(nodeDir, { recursive: true, force: true })
275
- rmSync(guardFile, { force: true })
276
- await ctx.spex(['materialize'], { cwd: wt, quiet: true })
277
- }
278
- },
279
- },
280
- {
281
- key: 'ask-note',
282
- aliases: ['pi-ask-note'],
283
- description: "Matrix row: the live worker runs `spex session ask --note '<question>'` (its own "
284
- + 'declaration verb, from inside its worktree) with a unique marker in the note.',
285
- expected: "The record flips to `asking` with the note carried verbatim in the graph's session entry "
286
- + '(`spex session show`), attributed to the right record.',
287
- drive: async (ctx) => {
288
- if (!(await ctx.ensureSettledWorker())) return { status: 'skip', note: 'no settled worker to drive' }
289
- const marker = `MATRIX-ASK-${ctx.nonce()}`
290
- const sent = await ctx.send(`Run exactly this one command from your worktree, then stop without doing anything else: spex session ask --note '${marker}: matrix probe question'`)
291
- if (sent !== 0) return { status: 'fail', note: 'instruction send was not accepted' }
292
- const s = await ctx.poll('record asking with the note marker', 300, async () => {
293
- const x = await ctx.show()
294
- return x && x.lifecycle === 'asking' && (x.note || '').includes(marker) ? x : false
295
- })
296
- if (!s) return { status: 'fail', note: 'record never read asking with the marker note in the graph' }
297
- return { status: 'pass', note: `record flipped to asking with the note verbatim (${marker}) in the graph's session entry` }
298
- },
299
- },
300
- {
301
- key: 'deliver-steer',
302
- aliases: ['pi-deliver-steer', 'deliver-second-message', 'deliver-mid-turn'],
303
- description: 'Matrix row: `spex session send` a task to the settled (idle) worker that starts a long '
304
- + 'turn, then send a SECOND message while that turn is in flight — all under normal graph-probe '
305
- + 'pressure (the runner polls the graph throughout).',
306
- expected: 'Both sends exit 0; the idle send lands EXACTLY once (no duplicate injection of the message '
307
- + 'text); the mid-turn send reaches the LIVE turn — its steer marker shows in that same turn\'s '
308
- + 'output — never dropped, never duplicated.',
309
- drive: async (ctx) => {
310
- if (!(await ctx.ensureSettledWorker())) return { status: 'skip', note: 'no settled worker to drive' }
311
- const ack = `ACK-${ctx.nonce()}`
312
- const steer = `STEER-${ctx.nonce()}`
313
- const idleCode = await ctx.send(`New task: first print the line ${ack}. Then run this exact shell command and wait for it: for i in $(seq 1 25); do echo tick $i; sleep 1; done. Then stop. Do not repeat the text of this instruction.`)
314
- if (idleCode !== 0) return { status: 'fail', note: 'idle send was not accepted (exit != 0)' }
315
- const working = await ctx.poll('turn in flight (status working)', 150, async () => {
316
- const s = await ctx.show()
317
- return s && s.status === 'working' ? s : false
318
- })
319
- if (!working) return { status: 'skip', note: 'never observed the turn in flight — no mid-turn window to measure steer in' }
320
- const steerCode = await ctx.send(`Change of plan: stop the counting task as soon as you can, print the line ${steer}, and stop.`)
321
- const settled = await ctx.settle(360)
322
- const pane = await ctx.capture('after steer settle')
323
- const ackCount = (pane.match(new RegExp(ack, 'g')) || []).length
324
- const steered = pane.includes(steer)
325
- ctx.log(`idle-send exit ${idleCode}; steer-send exit ${steerCode}; ack marker x${ackCount} in pane; steer marker present: ${steered}`)
326
- if (steerCode !== 0) return { status: 'fail', note: 'mid-turn send was not accepted (exit != 0)' }
327
- if (!settled) return { status: 'fail', note: 'worker never settled after the steer' }
328
- if (!steered) return { status: 'fail', note: 'steer marker never appeared — the mid-turn send was dropped' }
329
- if (ackCount > 3) return { status: 'fail', note: `ack marker appeared ${ackCount}x — duplicate injection signature` }
330
- return { status: 'pass', note: `idle send landed once (exit 0, marker x${ackCount} incl. the injected render + echo), mid-turn send steered the live turn (${steer} in the same turn)` }
331
- },
332
- },
333
- {
334
- key: 'resume',
335
- aliases: ['pi-resume', 'resume-continuity'],
336
- description: 'Matrix row: seed the live worker with a token to remember, `spex session stop` it (tmux '
337
- + 'killed, worktree kept), `spex session resume` it, then ask for the token back without repeating it.',
338
- expected: 'The resumed agent continues the SAME conversation — it answers with the seeded token from '
339
- + 'prior context in a fresh RECALL=<token> line — never a fresh empty session; the graph reports it online again.',
340
- drive: async (ctx) => {
341
- if (!(await ctx.ensureSettledWorker())) return { status: 'skip', note: 'no settled worker to drive' }
342
- const token = `TK${ctx.nonce()}`
343
- if ((await ctx.send(`Remember this token: ${token}. Reply ok, then stop.`)) !== 0) return { status: 'fail', note: 'seed send was not accepted' }
344
- if (!(await ctx.settle(300))) return { status: 'fail', note: 'worker never settled after the seed' }
345
- await ctx.spex(['session', 'stop', ctx.worker!.id])
346
- const off = await ctx.poll('offline after stop', 60, async () => {
347
- const s = await ctx.show()
348
- return s && s.liveness === 'offline' ? s : false
349
- })
350
- if (!off) return { status: 'fail', note: 'session never read offline after spex session stop' }
351
- if ((await ctx.resume()) !== 0) return { status: 'fail', note: 'spex session resume failed' }
352
- const on = await ctx.poll('online after resume', 240, async () => {
353
- const s = await ctx.show()
354
- return s && s.liveness === 'online' ? s : false
355
- })
356
- if (!on) return { status: 'fail', note: 'session never read online after resume' }
357
- if (!(await ctx.sendRetry('Earlier in this conversation I gave you a token starting with TK. Reply with one line in the exact format RECALL=<that token>, then stop.', 90))) return { status: 'fail', note: 'recall send never accepted within 90s of the relaunch — the resumed agent is not reachable' }
358
- await ctx.settle(300)
359
- const pane = await ctx.capture('after recall')
360
- if (!pane.includes(`RECALL=${token}`)) return { status: 'fail', note: `resumed agent could not produce RECALL=${token} — not the same conversation` }
361
- return { status: 'pass', note: `stop -> resume continued the SAME conversation (agent recalled ${token} across the relaunch)` }
362
- },
363
- },
364
- {
365
- key: 'liveness',
366
- aliases: ['pi-liveness', 'liveness-signals'],
367
- description: "Matrix row: SIGKILL an ESTABLISHED agent's whole process tree out from under the pane "
368
- + '(the kill lands outside the launcher boot-grace window; the tmux window and any stale socket file '
369
- + 'stay), read graph liveness until it flips; then `spex session resume` and read again.',
370
- expected: 'Liveness reads `offline` within seconds of the kill — a stale socket FILE never reads as '
371
- + "alive; the adapter's own per-harness signal decides — and after resume the session reads online again.",
372
- drive: async (ctx) => {
373
- if (!(await ctx.ensureSettledWorker())) return { status: 'skip', note: 'no settled worker to drive' }
374
- await ctx.waitEstablished()
375
- const pids = await ctx.paneDescendants()
376
- if (!pids.length) return { status: 'skip', note: 'no agent processes found under the pane to kill' }
377
- for (const pid of pids) { try { process.kill(pid, 'SIGKILL') } catch { /* raced its own death */ } }
378
- const t0 = Date.now()
379
- ctx.log(`SIGKILLed ${pids.length} pane descendants`)
380
- const off = await ctx.poll('offline after kill', 60, async () => {
381
- const s = await ctx.show()
382
- return s && s.liveness === 'offline' ? s : false
383
- })
384
- const dt = Math.round((Date.now() - t0) / 1000)
385
- if (!off) return { status: 'fail', note: 'killed agent never read offline — a stale socket/pid still reads as alive' }
386
- if ((await ctx.resume()) !== 0) return { status: 'fail', note: `offline in ${dt}s, but resume failed` }
387
- const on = await ctx.poll('online after relaunch', 240, async () => {
388
- const s = await ctx.show()
389
- return s && s.liveness === 'online' ? s : false
390
- })
391
- if (!on) return { status: 'fail', note: `offline in ${dt}s, but the relaunch never read online` }
392
- if (dt > 15) return { status: 'fail', note: `offline took ${dt}s — the dead agent read alive far beyond the honest window` }
393
- return { status: 'pass', note: `killed agent read offline in ${dt}s (pane + stale socket file still on disk); relaunch reads online` }
394
- },
395
- },
396
- {
397
- key: 'commit-gate',
398
- aliases: ['pi-commit-gate', 'commit-gate-rejection'],
399
- description: 'Matrix row: the runner plants an uncommitted file in the live worker\'s worktree and the '
400
- + 'worker runs `spex session done --propose merge`; the gate must reject the dirty proposal, and a '
401
- + 'committed re-proposal must be accepted.',
402
- expected: 'The dirty proposal is rejected at settle with the reason delivered into the session (the '
403
- + 'record never stands as review while the tree is dirty); once the work is committed the same '
404
- + 'proposal is accepted (status review) and the commit carries the `Session:` trailer attributing it '
405
- + 'to this record.',
406
- drive: async (ctx) => {
407
- if (!(await ctx.ensureSettledWorker())) return { status: 'skip', note: 'no settled worker to drive' }
408
- const wt = ctx.worker!.path
409
- writeFileSync(join(wt, 'matrix-scratch.txt'), 'scratch\n')
410
- const isDirty = async () => (await ctx.exec('git', ['-C', wt, 'status', '--porcelain'], { quiet: true })).out.trim() !== ''
411
- if (!(await ctx.sendRetry('This conformance run deliberately planted an uncommitted file (matrix-scratch.txt) in your worktree to test the commit gate. Do not commit or remove anything yourself yet. Run exactly: spex session done --propose merge — then stop. If the gate rejects you, follow whatever it says.', 90))) return { status: 'fail', note: 'instruction send never accepted within 90s — the worker is not reachable' }
412
- // classify what the gate did. The record reads `review` the moment the CLI writes the proposal —
413
- // BEFORE the stop-gate has run at the agent's stop — so a transient review+dirty is the normal
414
- // mid-loop state, and only a STABLE dirty review (the gate's stop verdict and its forced-continuation
415
- // escape both missed) is the gate failing. A settle with the tree still dirty and no review is the
416
- // gate holding; a clean review after the rejection text surfaced is the agent obeying the gate's own
417
- // teaching (rejection PROVEN either way).
418
- let rejectionSeen = false
419
- let dirtyReviewSince = 0
420
- let outcome: 'dirty-review' | 'held' | 'self-repaired' | null = null
421
- await ctx.poll('commit-gate outcome', 420, async () => {
422
- const s = await ctx.show()
423
- if (!s) return false
424
- const dirty = await isDirty()
425
- if (!rejectionSeen) {
426
- const pane = await ctx.spex(['session', 'show', ctx.worker!.id, '--capture'], { quiet: true })
427
- if (/uncommitted (changes|work)/.test(pane.out + (s.note || ''))) { rejectionSeen = true; ctx.log('gate rejection text surfaced in the session') }
428
- }
429
- if (s.status === 'review' && dirty) {
430
- dirtyReviewSince ||= Date.now()
431
- if (Date.now() - dirtyReviewSince > 90_000) { outcome = 'dirty-review'; return true }
432
- return false
433
- }
434
- dirtyReviewSince = 0
435
- if (s.status === 'review' && !dirty && rejectionSeen) { outcome = 'self-repaired'; return true }
436
- if (SETTLED.has(s.lifecycle) && s.status !== 'review' && dirty && rejectionSeen) { outcome = 'held'; return true }
437
- return false
438
- })
439
- await ctx.capture('after dirty proposal')
440
- if (outcome === 'dirty-review') return { status: 'fail', note: 'a DIRTY merge proposal stands as review — the commit gate let a dishonest proposal through' }
441
- if (!outcome) {
442
- return rejectionSeen
443
- ? { status: 'fail', note: 'gate rejection surfaced but the session reached no classifiable settle in time' }
444
- : { status: 'skip', note: 'the dirty-proposal rejection was never observed (worker may have committed first) — re-run to measure' }
445
- }
446
- if (outcome === 'held') {
447
- // the agent obeyed the rejection and is waiting — drive the honest completion explicitly.
448
- if ((await ctx.send("Now commit the work: run git add -A && git commit -m 'matrix: scratch probe', then run exactly: spex session done --propose merge — and then stop.")) !== 0) return { status: 'fail', note: 'commit instruction send was not accepted' }
449
- const b = await ctx.poll('clean proposal accepted (review)', 360, async () => {
450
- const s = await ctx.show()
451
- return s && s.status === 'review' && !(await isDirty()) ? s : false
452
- })
453
- if (!b) return { status: 'fail', note: 'dirty proposal correctly rejected, but the clean re-proposal never reached review' }
454
- }
455
- const msg = (await ctx.exec('git', ['-C', wt, 'log', '-1', '--format=%B'], { quiet: true })).out
456
- ctx.log(`worker HEAD commit message:\n${trim(msg.trim(), 500)}`)
457
- if (!/^Session: /m.test(msg)) return { status: 'fail', note: 'clean proposal accepted but the commit carries no Session: trailer — attribution lost' }
458
- return { status: 'pass', note: `dirty proposal rejected (reason delivered in-session), committed re-proposal accepted as review${outcome === 'self-repaired' ? ' (agent self-repaired per the gate\'s teaching)' : ''}, commit auto-stamped with the Session trailer` }
459
- },
460
- },
461
- {
462
- key: 'close-residue',
463
- aliases: ['pi-close-residue'],
464
- description: 'Matrix row: `spex session close` the worker, then sweep the box — tmux window, surviving '
465
- + 'processes of that worktree, the worktree directory and node branch, the session record and its '
466
- + 'global store dir.',
467
- expected: 'Zero residue: the tmux window is gone, no process of that worktree survives, worktree and '
468
- + "branch are retired, and the session's record/store dir is swept (durable history lives in git and "
469
- + 'the eval filings, not the record).',
470
- drive: async (ctx) => {
471
- if (!ctx.worker) return { status: 'skip', note: 'no worker to close' }
472
- const { id, path: wt, branch } = ctx.worker
473
- const store = sessionStoreDir(id)
474
- const r = await ctx.spex(['session', 'close', id])
475
- if (r.code !== 0) return { status: 'fail', note: 'spex session close failed' }
476
- const gone = await ctx.poll('zero residue', 120, async () => {
477
- const tmuxAlive = (await ctx.exec('tmux', ['-L', TMUX_SOCK, 'has-session', '-t', id], { quiet: true })).code === 0
478
- const procs = (await ctx.exec('ps', ['-eo', 'args='], { quiet: true })).out.split('\n').filter((l) => l.includes(wt))
479
- const branchLeft = branch ? (await ctx.exec('git', ['-C', mainCheckout(), 'branch', '--list', branch], { quiet: true })).out.trim() : ''
480
- const left = [
481
- tmuxAlive && 'tmux window', existsSync(wt) && 'worktree dir', branchLeft && `branch ${branch}`,
482
- existsSync(store) && 'session store dir', procs.length && `${procs.length} process(es): ${trim(procs.join(' | '), 300)}`,
483
- ].filter(Boolean)
484
- if (left.length) { ctx.log(`residue still present: ${left.join(', ')}`); return false }
485
- return true
486
- })
487
- ctx.worker = null
488
- if (!gone) return { status: 'fail', note: 'residue survived close past 120s (see transcript for what remained)' }
489
- return { status: 'pass', note: 'close swept everything: tmux window, process tree, worktree + branch, session record/store dir' }
490
- },
491
- },
492
- ]
493
-
494
- // single-quote a string for safe embedding in the generated guard script
495
- function shellQuote(s: string): string { return `'${s.replace(/'/g, `'\\''`)}'` }
496
-
497
- // the worktree's plugin root (`.plugins` dir holding a spec.md), found by walking .spec — where the
498
- // pretooluse row plants its transient guard node.
499
- function findPluginsRoot(wt: string): string | null {
500
- const specDir = join(wt, '.spec')
501
- const stack = existsSync(specDir) ? [specDir] : []
502
- while (stack.length) {
503
- const dir = stack.pop()!
504
- let ents
505
- try { ents = readdirSync(dir, { withFileTypes: true }) } catch { continue }
506
- for (const e of ents) {
507
- if (!e.isDirectory()) continue
508
- const p = join(dir, e.name)
509
- if (e.name === '.plugins' && existsSync(join(p, 'spec.md'))) return p
510
- stack.push(p)
511
- }
512
- }
513
- return null
514
- }
515
-
516
- // ---------------------------------------------------------------------------------------------------------
517
- // eval.md sync — project the rows' contract text into the harness node's scenarios (see the header note).
518
- // ---------------------------------------------------------------------------------------------------------
519
-
520
- const wrap = (text: string, indent: number, width = 100): string => {
521
- const words = text.split(/\s+/)
522
- const pad = ' '.repeat(indent)
523
- const lines: string[] = []
524
- let cur = pad
525
- for (const w of words) {
526
- if (cur.length > indent && cur.length + w.length + 1 > width) { lines.push(cur); cur = pad + w }
527
- else cur += (cur.length > indent ? ' ' : '') + w
528
- }
529
- if (cur.trim()) lines.push(cur)
530
- return lines.join('\n')
531
- }
532
-
533
- // render one scenario chunk in the suite's canonical shape, carrying over the node's own name/tags/code/
534
- // related/test while the description/expected come from the row (the shared contract).
535
- function renderChunk(name: string, row: MatrixRow, keep?: Scenario): string {
536
- const tags = keep?.tags?.length ? keep.tags : ['backend-api']
537
- const lines = [` - name: ${name}`, ` tags: [${tags.join(', ')}]`]
538
- if (keep?.code?.length) lines.push(` code: [${keep.code.join(', ')}]`)
539
- if (keep?.related?.length) lines.push(` related: [${keep.related.join(', ')}]`)
540
- if (keep?.test) lines.push(` test: { path: ${keep.test.path}${keep.test.name ? `, name: "${keep.test.name}"` : ''} }`)
541
- lines.push(' description: >-', wrap(row.description, 6), ' expected: >-', wrap(row.expected, 6))
542
- return lines.join('\n')
543
- }
544
-
545
- export type SyncResult = { evalPath: string; matched: Map<string, string[]>; changed: boolean; created: boolean }
546
-
547
- // converge the node's eval.md matrix scenarios onto the rows: a scenario matching a row (exact canonical
548
- // key, `<harness>-<key>`, or a historical alias) keeps its NAME (and tags/code/test) but its
549
- // description/expected are rewritten to the row's; a row with no match is appended under the canonical key.
550
- // Non-matrix scenarios and the body prose pass through byte-for-byte.
551
- export function syncMatrixEvalMd(nodeDir: string, harnessId: string): SyncResult {
552
- const evalPath = join(nodeDir, 'eval.md')
553
- const matched = new Map<string, string[]>()
554
- if (!existsSync(evalPath)) {
555
- const chunks = MATRIX.map((row) => { matched.set(row.key, [row.key]); return renderChunk(row.key, row) })
556
- writeFileSync(evalPath, `---\nscenarios:\n${chunks.join('\n')}\n---\n\nThe scenarios above are the live-behavior matrix of the harness-adapter acceptance rule, synced from\nspec-eval/src/matrix.ts by \`spex eval matrix <launcher>\` — edit the rows there, not here (a re-run\nre-syncs). Harness-specific scenarios may be added beside them by hand.\n`)
557
- return { evalPath, matched, changed: true, created: true }
558
- }
559
- const src = readFileSync(evalPath, 'utf8')
560
- const fm = src.match(/^---\n([\s\S]*?)\n---/)
561
- if (!fm) throw new Error(`${evalPath} has no frontmatter — cannot sync the matrix scenarios`)
562
- const scenarios = parseScenarios(src)
563
- const byName = new Map(scenarios.map((s) => [s.name, s]))
564
- const lines = fm[1].split('\n')
565
- const start = lines.findIndex((l) => /^scenarios:\s*$/.test(l))
566
- if (start < 0) throw new Error(`${evalPath} declares no scenarios: key — cannot sync the matrix scenarios`)
567
- // chunk the block into items by the first item's `- ` indent (the same walk scenarios.ts parses by)
568
- const indentOf = (l: string) => l.length - l.replace(/^\s+/, '').length
569
- let end = lines.length
570
- const bounds: number[] = []
571
- let itemIndent = -1
572
- for (let i = start + 1; i < lines.length; i++) {
573
- const line = lines[i]
574
- if (!line.trim()) continue
575
- if (indentOf(line) === 0) { end = i; break }
576
- const dash = line.trim().startsWith('- ')
577
- if (dash && (itemIndent < 0 || indentOf(line) <= itemIndent)) { itemIndent = indentOf(line); bounds.push(i) }
578
- }
579
- const chunks = bounds.map((b, i) => {
580
- const text = lines.slice(b, i + 1 < bounds.length ? bounds[i + 1] : end).join('\n').replace(/\n+$/, '')
581
- const name = /name:\s*(.+)/.exec(text)?.[1].trim().replace(/^["'](.*)["']$/, '$1') ?? ''
582
- return { name, text }
583
- })
584
- const rowFor = (name: string): MatrixRow | undefined =>
585
- MATRIX.find((r) => name === r.key || name === `${harnessId}-${r.key}` || r.aliases.includes(name))
586
- const rebuilt = chunks.map((c) => {
587
- const row = rowFor(c.name)
588
- if (!row) return c.text
589
- matched.set(row.key, [...(matched.get(row.key) ?? []), c.name])
590
- return renderChunk(c.name, row, byName.get(c.name))
591
- })
592
- for (const row of MATRIX) {
593
- if (matched.has(row.key)) continue
594
- matched.set(row.key, [row.key])
595
- rebuilt.push(renderChunk(row.key, row))
596
- }
597
- const out = `---\n${[...lines.slice(0, start + 1), ...rebuilt, ...lines.slice(end)].join('\n')}\n---${src.slice(fm[0].length)}`
598
- const changed = out !== src
599
- if (changed) writeFileSync(evalPath, out)
600
- return { evalPath, matched, changed, created: false }
601
- }
602
-
603
- // ---------------------------------------------------------------------------------------------------------
604
- // the CLI verb: spex eval matrix <launcher> [--node <id>] [--rows k1,k2]
605
- // ---------------------------------------------------------------------------------------------------------
606
-
607
- // the target spec node's directory: --node override or the `<harness>-harness` convention. Fail loud with
608
- // the repair when it doesn't exist — creating a harness's spec node is spec work, not runner work.
609
- function findNodeDir(root: string, id: string): string | null {
610
- const specDir = join(root, '.spec')
611
- const hits: string[] = []
612
- const stack = existsSync(specDir) ? [specDir] : []
613
- while (stack.length) {
614
- const dir = stack.pop()!
615
- let ents
616
- try { ents = readdirSync(dir, { withFileTypes: true }) } catch { continue }
617
- for (const e of ents) {
618
- if (!e.isDirectory()) continue
619
- const p = join(dir, e.name)
620
- if (e.name === id && existsSync(join(p, 'spec.md'))) hits.push(p)
621
- stack.push(p)
622
- }
623
- }
624
- if (hits.length > 1) throw new Error(`node id '${id}' is ambiguous (${hits.length} dirs) — pass --node with a unique id`)
625
- return hits[0] ?? null
626
- }
627
-
628
- const VALUE_FLAGS = ['--node', '--rows']
629
-
630
- export async function runMatrix(args: string[]): Promise<number> {
631
- const flag = (name: string): string | undefined => {
632
- const i = args.indexOf(`--${name}`)
633
- return i >= 0 ? args[i + 1] : undefined
634
- }
635
- // the launcher is the first bare positional — scanned left to right, skipping each value flag's value
636
- // (so `--rows liveness pi` reads `pi`, never `liveness`).
637
- let launcherName: string | undefined
638
- for (let i = 0; i < args.length; i++) {
639
- if (VALUE_FLAGS.includes(args[i])) { i++; continue }
640
- if (args[i].startsWith('--')) continue
641
- launcherName = args[i]
642
- break
643
- }
644
- if (!launcherName) {
645
- console.error('usage: spex eval matrix <launcher> [--node <id>] [--rows key1,key2]\n runs the eight-row live-behavior matrix against a REAL dispatched session of that launcher and files a per-row eval (rows: ' + MATRIX.map((r) => r.key).join(', ') + ')')
646
- return 2
647
- }
648
- let launcher: Launcher
649
- try { launcher = resolveLauncher(launcherName) } catch (e) { console.error(`spex eval matrix: ${(e as Error).message}`); return 2 }
650
- const root = repoRoot()
651
- const nodeId = flag('node') ?? `${launcher.harness}-harness`
652
- const nodeDir = findNodeDir(root, nodeId)
653
- if (!nodeDir) {
654
- console.error(`spex eval matrix: no spec node '${nodeId}' in this tree — create .spec/…/${nodeId}/spec.md (the harness's acceptance node) first, or pass --node <id>`)
655
- return 2
656
- }
657
- const rowKeys = flag('rows')?.split(',').map((s) => s.trim()).filter(Boolean)
658
- const rows = rowKeys ? MATRIX.filter((r) => rowKeys.includes(r.key)) : MATRIX
659
- if (rowKeys && rows.length !== rowKeys.length) {
660
- console.error(`spex eval matrix: unknown row(s) ${rowKeys.filter((k) => !MATRIX.some((r) => r.key === k)).join(', ')} — rows: ${MATRIX.map((r) => r.key).join(', ')}`)
661
- return 2
662
- }
663
- const sync = syncMatrixEvalMd(nodeDir, launcher.harness)
664
- console.log(`matrix: target node '${nodeId}' (${sync.created ? 'eval.md scaffolded' : sync.changed ? 'eval.md scenarios synced' : 'eval.md already in sync'})`)
665
- const by = envSessionId() ?? undefined
666
- const ctx = new MatrixRun(launcher)
667
- const results: { row: MatrixRow; verdict: RowVerdict; filedAs: string[] }[] = []
668
- for (const row of rows) {
669
- ctx.beginRow(row.key)
670
- let verdict: RowVerdict
671
- try { verdict = await row.drive(ctx) } catch (e) {
672
- verdict = { status: 'fail', note: `runner error: ${(e as Error).message}` }
673
- ctx.log(verdict.note)
674
- }
675
- ctx.log(`row ${row.key}: ${verdict.status.toUpperCase()} — ${verdict.note}`)
676
- const filedAs: string[] = []
677
- if (verdict.status !== 'skip') {
678
- for (const name of sync.matched.get(row.key) ?? [row.key]) {
679
- const filed = fileHumanReading(nodeId, { scenario: name, status: verdict.status, note: verdict.note, transcript: ctx.rowTranscript(), ...(by ? { by } : {}) })
680
- if (filed.ok) filedAs.push(name)
681
- else ctx.log(`filing to scenario '${name}' failed: ${filed.error}`)
682
- }
683
- }
684
- results.push({ row, verdict, filedAs })
685
- }
686
- console.log('\n=== live-behavior matrix ===')
687
- console.log(`launcher ${launcher.name} (harness ${launcher.harness}) -> node ${nodeId}`)
688
- for (const r of results) {
689
- console.log(` ${r.verdict.status.toUpperCase().padEnd(4)} ${r.row.key.padEnd(17)} ${r.verdict.note}${r.filedAs.length ? ` [filed: ${r.filedAs.join(', ')}]` : ' [not filed]'}`)
690
- }
691
- if (ctx.worker) console.log(`note: worker ${ctx.worker.id} left open for inspection (close-residue did not run/pass) — spex session close ${ctx.worker.id}`)
692
- return results.every((r) => r.verdict.status === 'pass') ? 0 : 1
693
- }