@lythos/skill-arena 0.15.7 → 0.16.1

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.
@@ -1,9 +1,9 @@
1
- import { describe, test, expect } from 'bun:test'
2
- import { buildArenaPrompt, formatPlanOutput } from './runner'
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { buildArenaPrompt, formatPlanOutput, runArenaFromToml, type ArenaIO } from './runner'
3
3
  import { buildExecutionPlan, parseArenaToml } from './arena-toml'
4
4
 
5
5
  describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
6
- test('includes CWD, Deck, and output directory', () => {
6
+ it('includes CWD, Deck, and output directory', () => {
7
7
  const prompt = buildArenaPrompt({
8
8
  brief: 'Write a hello world function',
9
9
  cwd: '/tmp/arena-cell',
@@ -14,7 +14,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
14
14
  expect(prompt).toContain('Produce output to: /tmp/arena-cell/')
15
15
  })
16
16
 
17
- test('includes decision-log.jsonl mandatory instructions', () => {
17
+ it('includes decision-log.jsonl mandatory instructions', () => {
18
18
  const prompt = buildArenaPrompt({
19
19
  brief: 'test',
20
20
  cwd: '/tmp/arena-cell',
@@ -27,7 +27,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
27
27
  expect(prompt).toContain('reason (why)')
28
28
  })
29
29
 
30
- test('includes robustness and tools instructions', () => {
30
+ it('includes robustness and tools instructions', () => {
31
31
  const prompt = buildArenaPrompt({
32
32
  brief: 'test',
33
33
  cwd: '/tmp/arena-cell',
@@ -38,7 +38,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
38
38
  expect(prompt).toContain('.claude/skills/')
39
39
  })
40
40
 
41
- test('task brief appears at the end', () => {
41
+ it('task brief appears at the end', () => {
42
42
  const prompt = buildArenaPrompt({
43
43
  brief: 'Generate a dark-mode CSS theme',
44
44
  cwd: '/tmp/arena-cell',
@@ -54,7 +54,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
54
54
  expect(briefIdx).toBeGreaterThan(toolsIdx)
55
55
  })
56
56
 
57
- test('outputDir overrides default output path', () => {
57
+ it('outputDir overrides default output path', () => {
58
58
  const prompt = buildArenaPrompt({
59
59
  brief: 'test',
60
60
  cwd: '/tmp/arena-cell',
@@ -64,7 +64,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
64
64
  expect(prompt).toContain('Produce output to: /custom/output/')
65
65
  })
66
66
 
67
- test('preflightReport included when provided', () => {
67
+ it('preflightReport included when provided', () => {
68
68
  const prompt = buildArenaPrompt({
69
69
  brief: 'test',
70
70
  cwd: '/tmp/arena-cell',
@@ -75,7 +75,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
75
75
  expect(prompt).toContain('✅ 3 skills linked, 0 missing')
76
76
  })
77
77
 
78
- test('no preflight section when report not provided', () => {
78
+ it('no preflight section when report not provided', () => {
79
79
  const prompt = buildArenaPrompt({
80
80
  brief: 'test',
81
81
  cwd: '/tmp/arena-cell',
@@ -84,8 +84,267 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
84
84
  expect(prompt).not.toContain('Preflight:')
85
85
  })
86
86
 
87
- test('prompt is deterministic — same inputs = same output', () => {
87
+ it('prompt is deterministic — same inputs = same output', () => {
88
88
  const opts = { brief: 'test', cwd: '/tmp/a', deckPath: '/tmp/d.toml' }
89
89
  expect(buildArenaPrompt(opts)).toBe(buildArenaPrompt(opts))
90
90
  })
91
91
  })
92
+
93
+ describe('runArenaFromToml — dry-run mode (no fs/spawn/agent calls)', () => {
94
+ it('returns plan without executing cells', async () => {
95
+ const toml = parseArenaToml(`
96
+ [arena]
97
+ task = "Write a hello world function"
98
+ criteria = ["completeness", "correctness"]
99
+ runs_per_side = 1
100
+
101
+ [[side]]
102
+ name = "claude"
103
+ player = "claude"
104
+ deck = "/tmp/deck.toml"
105
+
106
+ [[side]]
107
+ name = "kimi"
108
+ player = "kimi"
109
+ deck = "/tmp/deck.toml"
110
+ `)
111
+
112
+ const logs: string[] = []
113
+ const result = await runArenaFromToml({
114
+ toml,
115
+ taskPath: '/tmp/task.md',
116
+ dryRun: true,
117
+ log: (msg) => logs.push(msg),
118
+ })
119
+
120
+ expect('plan' in result).toBe(true)
121
+ const plan = (result as { plan: ReturnType<typeof buildExecutionPlan> }).plan
122
+ expect(plan.total_runs).toBe(2)
123
+ expect(plan.cells.length).toBe(2)
124
+ expect(logs.length).toBeGreaterThan(0)
125
+ expect(logs.some(l => l.includes('Dry-run'))).toBe(true)
126
+ })
127
+
128
+ it('dry-run does not call io.mkdir or io.agentSpawn', async () => {
129
+ const toml = parseArenaToml(`
130
+ [arena]
131
+ task = "Write a hello world function"
132
+ criteria = ["completeness"]
133
+ runs_per_side = 1
134
+
135
+ [[side]]
136
+ name = "claude"
137
+ player = "claude"
138
+ deck = "/tmp/deck.toml"
139
+
140
+ [[side]]
141
+ name = "kimi"
142
+ player = "kimi"
143
+ deck = "/tmp/deck.toml"
144
+ `)
145
+
146
+ let mkdirCalled = false
147
+ let agentSpawnCalled = false
148
+
149
+ const mockIO: ArenaIO = {
150
+ mkdir: () => { mkdirCalled = true },
151
+ agentSpawn: async () => { agentSpawnCalled = true; return { stdout: '', stderr: '', durationMs: 0 } },
152
+ }
153
+
154
+ await runArenaFromToml({
155
+ toml,
156
+ taskPath: '/tmp/task.md',
157
+ dryRun: true,
158
+ io: mockIO,
159
+ })
160
+
161
+ expect(mkdirCalled).toBe(false)
162
+ expect(agentSpawnCalled).toBe(false)
163
+ })
164
+ })
165
+
166
+ describe('runArenaFromToml — single cell execution with mock agentSpawn', () => {
167
+ it('executes one cell and writes outputs via io', async () => {
168
+ const toml = parseArenaToml(`
169
+ [arena]
170
+ task = "Write a hello world function"
171
+ criteria = ["completeness"]
172
+ runs_per_side = 1
173
+
174
+ [[side]]
175
+ name = "claude"
176
+ player = "claude"
177
+ deck = "/tmp/deck.toml"
178
+
179
+ [[side]]
180
+ name = "kimi"
181
+ player = "kimi"
182
+ deck = "/tmp/deck.toml"
183
+ `)
184
+
185
+ const files = new Map<string, string>()
186
+ const dirs = new Set<string>()
187
+
188
+ const mockIO: ArenaIO = {
189
+ log: () => {},
190
+ mkdir: (path: string) => { dirs.add(path) },
191
+ writeFile: (path: string, data: string) => { files.set(path, data) },
192
+ readFile: (path: string) => {
193
+ if (path === '/tmp/deck.toml') return 'skills = ["test"]'
194
+ throw new Error(`Unexpected read: ${path}`)
195
+ },
196
+ readdir: () => ['decision-log.jsonl'],
197
+ cp: () => {},
198
+ spawn: async () => ({ exitCode: 0, stderr: '' }),
199
+ agentSpawn: async () => ({
200
+ stdout: 'I wrote hello world',
201
+ stderr: '',
202
+ durationMs: 1234,
203
+ }),
204
+ exists: (path: string) => path === '/tmp/deck.toml',
205
+ chdir: () => {},
206
+ }
207
+
208
+ const result = await runArenaFromToml({
209
+ toml,
210
+ taskPath: '/tmp/task.md',
211
+ outDir: '/tmp/arena-out',
212
+ io: mockIO,
213
+ })
214
+
215
+ expect('manifest' in result).toBe(true)
216
+ const r = result as { manifest: { id: string; status: string }; artifactsDir: string }
217
+ expect(r.manifest.status).toBe('completed')
218
+ expect(r.artifactsDir).toBe('/tmp/arena-out')
219
+
220
+ // arena.json written twice (initial + final)
221
+ expect(files.has('/tmp/arena-out/arena.json')).toBe(true)
222
+ // agent stdout persisted
223
+ expect(files.has('/tmp/arena-out/runs/claude/run-1/agent-stdout.txt')).toBe(true)
224
+ expect(files.get('/tmp/arena-out/runs/claude/run-1/agent-stdout.txt')).toContain('I wrote hello world')
225
+ // judge-verdict.json written
226
+ expect(files.has('/tmp/arena-out/runs/claude/run-1/judge-verdict.json')).toBe(true)
227
+ // report.md written
228
+ expect(files.has('/tmp/arena-out/report.md')).toBe(true)
229
+ // directories created
230
+ expect(dirs.has('/tmp/arena-out')).toBe(true)
231
+ expect(dirs.has('/tmp/arena-out/runs/claude/run-1')).toBe(true)
232
+ expect(dirs.has('/tmp/arena-out/work/claude')).toBe(true)
233
+ })
234
+ })
235
+
236
+ describe('runArenaFromToml — error recovery path', () => {
237
+ it('cell exception produces ERROR verdict and continues', async () => {
238
+ const toml = parseArenaToml(`
239
+ [arena]
240
+ task = "Write a hello world function"
241
+ criteria = ["completeness"]
242
+ runs_per_side = 1
243
+
244
+ [[side]]
245
+ name = "claude"
246
+ player = "claude"
247
+ deck = "/tmp/deck.toml"
248
+
249
+ [[side]]
250
+ name = "kimi"
251
+ player = "kimi"
252
+ deck = "/tmp/deck.toml"
253
+ `)
254
+
255
+ const files = new Map<string, string>()
256
+
257
+ let spawnCount = 0
258
+ const mockIO: ArenaIO = {
259
+ log: () => {},
260
+ mkdir: () => {},
261
+ writeFile: (path: string, data: string) => { files.set(path, data) },
262
+ readFile: (path: string) => {
263
+ if (path === '/tmp/deck.toml') return 'skills = ["test"]'
264
+ throw new Error(`Unexpected read: ${path}`)
265
+ },
266
+ readdir: () => [],
267
+ cp: () => {},
268
+ spawn: async () => ({ exitCode: 0, stderr: '' }),
269
+ agentSpawn: async () => {
270
+ spawnCount++
271
+ if (spawnCount === 1) throw new Error('Agent spawn failed')
272
+ return { stdout: 'ok', stderr: '', durationMs: 100 }
273
+ },
274
+ exists: (path: string) => path === '/tmp/deck.toml',
275
+ chdir: () => {},
276
+ }
277
+
278
+ const result = await runArenaFromToml({
279
+ toml,
280
+ taskPath: '/tmp/task.md',
281
+ outDir: '/tmp/arena-out',
282
+ io: mockIO,
283
+ })
284
+
285
+ expect('manifest' in result).toBe(true)
286
+ const r = result as { manifest: { status: string }; stats: { sideName: string; errorRate: number }[] }
287
+ expect(r.manifest.status).toBe('completed')
288
+ expect(r.stats.length).toBe(2)
289
+ const claudeStats = r.stats.find(s => s.sideName === 'claude')
290
+ expect(claudeStats!.errorRate).toBe(1)
291
+
292
+ const verdictPath = '/tmp/arena-out/runs/claude/run-1/judge-verdict.json'
293
+ expect(files.has(verdictPath)).toBe(true)
294
+ const verdict = JSON.parse(files.get(verdictPath)!)
295
+ expect(verdict.verdict).toBe('ERROR')
296
+ expect(verdict.reason).toContain('Agent spawn failed')
297
+ })
298
+
299
+ it('spawn non-zero exit logs warning but continues', async () => {
300
+ const toml = parseArenaToml(`
301
+ [arena]
302
+ task = "Write a hello world function"
303
+ criteria = ["completeness"]
304
+ runs_per_side = 1
305
+
306
+ [[side]]
307
+ name = "claude"
308
+ player = "claude"
309
+ deck = "/tmp/deck.toml"
310
+
311
+ [[side]]
312
+ name = "kimi"
313
+ player = "kimi"
314
+ deck = "/tmp/deck.toml"
315
+ `)
316
+
317
+ const logs: string[] = []
318
+ const files = new Map<string, string>()
319
+
320
+ const mockIO: ArenaIO = {
321
+ log: (msg: string) => logs.push(msg),
322
+ mkdir: () => {},
323
+ writeFile: (path: string, data: string) => { files.set(path, data) },
324
+ readFile: (path: string) => {
325
+ if (path === '/tmp/deck.toml') return 'skills = ["test"]'
326
+ throw new Error(`Unexpected read: ${path}`)
327
+ },
328
+ readdir: () => [],
329
+ cp: () => {},
330
+ spawn: async () => ({ exitCode: 1, stderr: 'link failed' }),
331
+ agentSpawn: async () => ({
332
+ stdout: 'done',
333
+ stderr: '',
334
+ durationMs: 100,
335
+ }),
336
+ exists: (path: string) => path === '/tmp/deck.toml',
337
+ chdir: () => {},
338
+ }
339
+
340
+ const result = await runArenaFromToml({
341
+ toml,
342
+ taskPath: '/tmp/task.md',
343
+ outDir: '/tmp/arena-out',
344
+ io: mockIO,
345
+ })
346
+
347
+ expect('manifest' in result).toBe(true)
348
+ expect(logs.some(l => l.includes('deck link') && l.includes('exit 1'))).toBe(true)
349
+ })
350
+ })
package/src/runner.ts CHANGED
@@ -17,6 +17,42 @@ import { aggregateAllStats } from './stats'
17
17
  import type { SideStats } from './stats'
18
18
  import { buildCopyPlan } from './preflight'
19
19
 
20
+ // ── ArenaIO interface (Intent/Plan/Execute fractal pattern) ───────────────
21
+
22
+ export interface ArenaIO {
23
+ log?: (msg: string) => void
24
+ mkdir?: (path: string, opts?: { recursive?: boolean }) => void
25
+ writeFile?: (path: string, data: string) => void
26
+ readFile?: (path: string) => string
27
+ readdir?: (path: string) => string[]
28
+ cp?: (src: string, dest: string, opts?: { recursive?: boolean }) => void
29
+ spawn?: (cmd: string[], opts: { cwd: string; env?: Record<string, string> }) => Promise<{ exitCode: number | null; stderr: string }>
30
+ agentSpawn?: (opts: { player: string; cwd: string; brief: string; timeoutMs: number }) => Promise<{ stdout: string; stderr: string; durationMs: number }>
31
+ exists?: (path: string) => boolean
32
+ chdir?: (cwd: string) => void
33
+ }
34
+
35
+ export const defaultArenaIO: ArenaIO = {
36
+ log: (msg: string) => { console.log(msg) },
37
+ mkdir: (path: string, opts?: { recursive?: boolean }) => mkdirSync(path, opts),
38
+ writeFile: (path: string, data: string) => writeFileSync(path, data, 'utf-8'),
39
+ readFile: (path: string) => readFileSync(path, 'utf-8'),
40
+ readdir: (path: string) => readdirSync(path),
41
+ cp: (src: string, dest: string, opts?: { recursive?: boolean }) => cpSync(src, dest, opts),
42
+ spawn: async (cmd: string[], opts: { cwd: string; env?: Record<string, string> }) => {
43
+ const proc = Bun.spawn(cmd, { cwd: opts.cwd, env: opts.env })
44
+ await proc.exited
45
+ const stderr = await new Response(proc.stderr).text()
46
+ return { exitCode: proc.exitCode, stderr }
47
+ },
48
+ agentSpawn: async (opts: { player: string; cwd: string; brief: string; timeoutMs: number }) => {
49
+ const agent = useAgent(opts.player)
50
+ return agent.spawn({ cwd: opts.cwd, brief: opts.brief, timeoutMs: opts.timeoutMs })
51
+ },
52
+ exists: (path: string) => existsSync(path),
53
+ chdir: (cwd: string) => { process.chdir(cwd) },
54
+ }
55
+
20
56
  // ── Helpers ───────────────────────────────────────────────────────────────
21
57
 
22
58
  function stamp(): string {
@@ -33,18 +69,18 @@ export interface ArenaResult {
33
69
 
34
70
  // ── Task + judge text resolution (no parsing — natural language) ──────────
35
71
 
36
- function resolveTaskText(toml: ArenaToml, configDir?: string): string {
72
+ function resolveTaskText(toml: ArenaToml, io: Required<Pick<ArenaIO, 'exists' | 'readFile'>>, configDir?: string): string {
37
73
  const p = toml.arena.task
38
74
  const candidate = configDir ? resolve(configDir, p) : resolve(p)
39
- if (existsSync(candidate)) return readFileSync(candidate, 'utf-8')
75
+ if (io.exists(candidate)) return io.readFile(candidate)
40
76
  return p
41
77
  }
42
78
 
43
- function resolveJudgeText(toml: ArenaToml, configDir?: string): string | null {
79
+ function resolveJudgeText(toml: ArenaToml, io: Required<Pick<ArenaIO, 'exists' | 'readFile'>>, configDir?: string): string | null {
44
80
  if (toml.arena.judge) {
45
81
  const p = toml.arena.judge
46
82
  const candidate = configDir ? resolve(configDir, p) : resolve(p)
47
- if (existsSync(candidate)) return readFileSync(candidate, 'utf-8')
83
+ if (io.exists(candidate)) return io.readFile(candidate)
48
84
  return p
49
85
  }
50
86
  if (toml.arena.criteria && toml.arena.criteria.length > 0) {
@@ -116,8 +152,14 @@ export async function runArenaFromToml(opts: {
116
152
  dryRun?: boolean
117
153
  log?: (msg: string) => void
118
154
  configDir?: string
155
+ io?: ArenaIO
119
156
  }): Promise<ArenaResult | { plan: ReturnType<typeof buildExecutionPlan> }> {
120
157
  const { toml, taskPath, outDir, dryRun, log, configDir } = opts
158
+ const io: ArenaIO = {
159
+ ...defaultArenaIO,
160
+ ...opts.io,
161
+ log: log ?? opts.io?.log ?? defaultArenaIO.log,
162
+ }
121
163
 
122
164
  const resolvePath = (p: string) => {
123
165
  if (p.startsWith('/')) return p
@@ -125,7 +167,19 @@ export async function runArenaFromToml(opts: {
125
167
  return resolve(p)
126
168
  }
127
169
 
128
- const taskText = resolveTaskText(toml, configDir)
170
+ const ioWithDefaults = {
171
+ exists: io.exists!,
172
+ readFile: io.readFile!,
173
+ writeFile: io.writeFile!,
174
+ mkdir: io.mkdir!,
175
+ readdir: io.readdir!,
176
+ cp: io.cp!,
177
+ spawn: io.spawn!,
178
+ agentSpawn: io.agentSpawn!,
179
+ chdir: io.chdir!,
180
+ }
181
+
182
+ const taskText = resolveTaskText(toml, ioWithDefaults, configDir)
129
183
  const resolvedToml: ArenaToml = {
130
184
  ...toml,
131
185
  side: toml.side.map(s => ({ ...s, deck: resolvePath(s.deck) })),
@@ -134,7 +188,7 @@ export async function runArenaFromToml(opts: {
134
188
  const plan = buildExecutionPlan(resolvedToml)
135
189
 
136
190
  if (dryRun) {
137
- for (const line of formatPlanOutput(plan)) log?.(line)
191
+ for (const line of formatPlanOutput(plan)) io.log?.(line)
138
192
  return { plan }
139
193
  }
140
194
 
@@ -158,29 +212,29 @@ export async function runArenaFromToml(opts: {
158
212
  status: 'running',
159
213
  })
160
214
 
161
- mkdirSync(artifactsDir, { recursive: true })
162
- writeFileSync(join(artifactsDir, 'arena.json'), JSON.stringify(manifest, null, 2) + '\n')
215
+ ioWithDefaults.mkdir(artifactsDir, { recursive: true })
216
+ ioWithDefaults.writeFile(join(artifactsDir, 'arena.json'), JSON.stringify(manifest, null, 2) + '\n')
163
217
 
164
- const judgeText = resolveJudgeText(resolvedToml, configDir)
218
+ const judgeText = resolveJudgeText(resolvedToml, ioWithDefaults, configDir)
165
219
  const judgeInput: JudgeInput | undefined = judgeText
166
220
  ? { criteria: judgeText, task_context: taskText.slice(0, 500) }
167
221
  : undefined
168
222
 
169
- // ── Per-cell: agent.spawn directly, no AgentScenario/parseAgentMd ────
223
+ // ── Per-cell: agent.spawn via IO injection ─────────────────────────────
170
224
  const verdictsBySide = new Map<string, JudgeVerdict[]>()
171
225
 
172
226
  for (const cell of plan.cells) {
173
227
  const cellDir = join(artifactsDir, 'runs', cell.side, `run-${cell.run}`)
174
- mkdirSync(cellDir, { recursive: true })
228
+ ioWithDefaults.mkdir(cellDir, { recursive: true })
175
229
 
176
230
  const workDir = join(artifactsDir, 'work', cell.side)
177
- mkdirSync(workDir, { recursive: true })
231
+ ioWithDefaults.mkdir(workDir, { recursive: true })
178
232
  const originalCwd = process.cwd()
179
233
 
180
234
  try {
181
235
  // Setup: deck + AGENTS.md + link
182
- writeFileSync(join(workDir, 'skill-deck.toml'), readFileSync(cell.deck, 'utf-8'))
183
- writeFileSync(join(workDir, 'AGENTS.md'), [
236
+ ioWithDefaults.writeFile(join(workDir, 'skill-deck.toml'), ioWithDefaults.readFile(cell.deck))
237
+ ioWithDefaults.writeFile(join(workDir, 'AGENTS.md'), [
184
238
  '# Arena Test Environment',
185
239
  `**Side**: ${cell.side}`, `**Player**: ${cell.player}`, `**Run**: ${cell.run}`,
186
240
  '## How This Works',
@@ -188,55 +242,54 @@ export async function runArenaFromToml(opts: {
188
242
  '- Complete the task using available skills. Output to this directory.',
189
243
  '- MANDATORY: write decision-log.jsonl (see prompt for schema).',
190
244
  ].join('\n'))
191
- const linkProc = Bun.spawn(
245
+ const linkProc = await ioWithDefaults.spawn(
192
246
  ['bunx', '@lythos/skill-deck', 'link'],
193
247
  { cwd: workDir, env: { ...process.env, HOME: process.env.HOME! } },
194
248
  )
195
- await linkProc.exited
196
- log?.(`[arena] deck link for ${cell.side}: exit ${linkProc.exitCode}`)
249
+ io.log?.(`[arena] deck link for ${cell.side}: exit ${linkProc.exitCode}`)
197
250
 
198
- process.chdir(workDir)
251
+ ioWithDefaults.chdir(workDir)
199
252
 
200
- // Direct agent.spawn (no parseAgentMd, no AgentScenario)
201
- const agent = useAgent(resolvePlayer(cell.player))
253
+ // Agent spawn via IO injection
202
254
  const fullPrompt = buildArenaPrompt({
203
255
  brief: taskText,
204
256
  cwd: workDir,
205
257
  deckPath: cell.deck,
206
258
  outputDir: workDir,
207
259
  })
208
- const agentResult = await agent.spawn({
260
+ const agentResult = await ioWithDefaults.agentSpawn({
261
+ player: resolvePlayer(cell.player),
209
262
  cwd: workDir,
210
263
  brief: fullPrompt,
211
264
  timeoutMs: 300000,
212
265
  })
213
266
 
214
- process.chdir(originalCwd)
267
+ ioWithDefaults.chdir(originalCwd)
215
268
 
216
269
  // Persist agent output
217
270
  const sanitizer = createSanitizer({ projectRoot: process.cwd(), homeDir: homedir(), workDir })
218
- writeFileSync(join(cellDir, 'agent-stdout.txt'), sanitizer.sanitize(agentResult.stdout), 'utf-8')
219
- if (agentResult.stderr) writeFileSync(join(cellDir, 'agent-stderr.txt'), sanitizer.sanitize(agentResult.stderr), 'utf-8')
271
+ ioWithDefaults.writeFile(join(cellDir, 'agent-stdout.txt'), sanitizer.sanitize(agentResult.stdout))
272
+ if (agentResult.stderr) ioWithDefaults.writeFile(join(cellDir, 'agent-stderr.txt'), sanitizer.sanitize(agentResult.stderr))
220
273
 
221
274
  // Copy artifacts
222
275
  const skipSet = new Set(['.claude', 'skill-deck.toml', 'skill-deck.lock', 'AGENTS.md'])
223
276
  try {
224
- const entries = readdirSync(workDir)
277
+ const entries = ioWithDefaults.readdir(workDir)
225
278
  const copyPlan = buildCopyPlan(workDir, cellDir, entries, skipSet)
226
279
  for (const { src, dest, name } of copyPlan) {
227
- try { cpSync(src, dest, { recursive: true }) } catch (e) {
228
- log?.(`⚠️ Failed to copy agent output: ${name} — ${e instanceof Error ? e.message : e}`)
280
+ try { ioWithDefaults.cp(src, dest, { recursive: true }) } catch (e) {
281
+ io.log?.(`⚠️ Failed to copy agent output: ${name} — ${e instanceof Error ? e.message : e}`)
229
282
  }
230
283
  }
231
284
  } catch (e) {
232
- log?.(`⚠️ Failed to read agent workdir for copy: ${e instanceof Error ? e.message : e}`)
285
+ io.log?.(`⚠️ Failed to read agent workdir for copy: ${e instanceof Error ? e.message : e}`)
233
286
  }
234
287
 
235
288
  // Evidence
236
289
  const checkpoints = readCheckpoints(workDir)
237
290
  let artifactFiles: string[] = []
238
291
  try {
239
- for (const e of readdirSync(workDir)) {
292
+ for (const e of ioWithDefaults.readdir(workDir)) {
240
293
  if (!e.startsWith('.') && !skipSet.has(e) && e !== 'agent-stdout.txt' && e !== 'agent-stderr.txt' && e !== 'judge-verdict.json' && e !== '_checkpoints') {
241
294
  artifactFiles.push(e)
242
295
  }
@@ -259,7 +312,7 @@ export async function runArenaFromToml(opts: {
259
312
  v = { verdict: 'ERROR' as const, reason: 'No judge criteria provided', criteria: [] }
260
313
  }
261
314
 
262
- writeFileSync(join(cellDir, 'judge-verdict.json'), JSON.stringify({
315
+ ioWithDefaults.writeFile(join(cellDir, 'judge-verdict.json'), JSON.stringify({
263
316
  ...v,
264
317
  agent_stdout: agentResult.stdout.slice(0, 5000),
265
318
  agent_stderr: agentResult.stderr.slice(0, 1000),
@@ -274,7 +327,7 @@ export async function runArenaFromToml(opts: {
274
327
  reason: `Runner exception: ${e instanceof Error ? e.message : String(e)}`,
275
328
  criteria: [],
276
329
  }
277
- writeFileSync(join(cellDir, 'judge-verdict.json'), JSON.stringify(errVerdict, null, 2) + '\n')
330
+ ioWithDefaults.writeFile(join(cellDir, 'judge-verdict.json'), JSON.stringify(errVerdict, null, 2) + '\n')
278
331
  if (!verdictsBySide.has(cell.side)) verdictsBySide.set(cell.side, [])
279
332
  verdictsBySide.get(cell.side)!.push(errVerdict)
280
333
  }
@@ -288,10 +341,10 @@ export async function runArenaFromToml(opts: {
288
341
  }
289
342
  const judge = useAgent(resolved[0]?.platform ?? 'claude')
290
343
  const report = await runComparativeJudge({ manifest, verdicts: flatVerdicts, judge, workdir: artifactsDir })
291
- writeReport(artifactsDir, manifest, report, stats)
344
+ writeReport(artifactsDir, manifest, report, stats, ioWithDefaults)
292
345
 
293
346
  const finalManifest = ArenaManifest.parse({ ...manifest, status: 'completed' })
294
- writeFileSync(join(artifactsDir, 'arena.json'), JSON.stringify(finalManifest, null, 2) + '\n')
347
+ ioWithDefaults.writeFile(join(artifactsDir, 'arena.json'), JSON.stringify(finalManifest, null, 2) + '\n')
295
348
 
296
349
  return { manifest: finalManifest, report, stats, artifactsDir }
297
350
  }
@@ -319,7 +372,7 @@ export async function runArena(opts: {
319
372
 
320
373
  // ── Report ────────────────────────────────────────────────────────────────
321
374
 
322
- function writeReport(dir: string, manifest: ArenaManifestType, report: any, stats: SideStats[]): void {
375
+ function writeReport(dir: string, manifest: ArenaManifestType, report: any, stats: SideStats[], io: Required<Pick<ArenaIO, 'writeFile'>>): void {
323
376
  const lines: string[] = [
324
377
  `# Arena Report: ${manifest.id}`, '',
325
378
  `**Task**: ${manifest.task}`,
@@ -331,7 +384,7 @@ function writeReport(dir: string, manifest: ArenaManifestType, report: any, stat
331
384
  '## Key Findings', '', ...(report.key_findings ?? []).map((f: string) => `- ${f}`), '',
332
385
  '## Recommendations', '', ...(report.recommendations ?? []).map((r: any) => `- **${r.audience}**: ${r.recommendation}`),
333
386
  ]
334
- writeFileSync(join(dir, 'report.md'), lines.join('\n') + '\n')
387
+ io.writeFile(join(dir, 'report.md'), lines.join('\n') + '\n')
335
388
  }
336
389
 
337
390
  function renderStatsTable(stats: SideStats[]): string {
package/src/stats.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from 'bun:test'
1
+ import { describe, it, expect } from 'bun:test'
2
2
  import { aggregateSideStats, aggregateAllStats } from './stats'
3
3
  import type { JudgeVerdict } from '@lythos/test-utils/schema'
4
4
 
@@ -14,7 +14,7 @@ function makeVerdict(overrides?: Partial<JudgeVerdict>): JudgeVerdict {
14
14
  // ── aggregateSideStats ─────────────────────────────────────────────────────
15
15
 
16
16
  describe('aggregateSideStats', () => {
17
- test('single run: passRate=1, no variance', () => {
17
+ it('single run: passRate=1, no variance', () => {
18
18
  const stats = aggregateSideStats('test', [makeVerdict()])
19
19
  expect(stats.sideName).toBe('test')
20
20
  expect(stats.runs).toBe(1)
@@ -23,7 +23,7 @@ describe('aggregateSideStats', () => {
23
23
  expect(stats.errorRate).toBe(0)
24
24
  })
25
25
 
26
- test('3 runs: 2 PASS, 1 FAIL', () => {
26
+ it('3 runs: 2 PASS, 1 FAIL', () => {
27
27
  const verdicts = [
28
28
  makeVerdict(),
29
29
  makeVerdict(),
@@ -34,7 +34,7 @@ describe('aggregateSideStats', () => {
34
34
  expect(stats.failRate).toBeCloseTo(1 / 3)
35
35
  })
36
36
 
37
- test('confidence: mean across runs', () => {
37
+ it('confidence: mean across runs', () => {
38
38
  const verdicts = [
39
39
  makeVerdict({ confidence: 90 }),
40
40
  makeVerdict({ confidence: 80 }),
@@ -45,13 +45,13 @@ describe('aggregateSideStats', () => {
45
45
  expect(stats.confidenceVariance).toBeCloseTo(100) // (100+0+100)/2 = 100
46
46
  })
47
47
 
48
- test('confidence: null when no verdict has it', () => {
48
+ it('confidence: null when no verdict has it', () => {
49
49
  const stats = aggregateSideStats('test', [makeVerdict(), makeVerdict()])
50
50
  expect(stats.meanConfidence).toBeNull()
51
51
  expect(stats.confidenceVariance).toBeNull()
52
52
  })
53
53
 
54
- test('per-criterion pass rate', () => {
54
+ it('per-criterion pass rate', () => {
55
55
  const verdicts = [
56
56
  makeVerdict({ criteria: [{ name: 'accuracy', passed: true }] }),
57
57
  makeVerdict({ criteria: [{ name: 'accuracy', passed: false }] }),
@@ -63,7 +63,7 @@ describe('aggregateSideStats', () => {
63
63
  expect(stats.criteria[0].mean).toBeCloseTo(2 / 3)
64
64
  })
65
65
 
66
- test('per-criterion scores: mean and variance', () => {
66
+ it('per-criterion scores: mean and variance', () => {
67
67
  const verdicts = [
68
68
  makeVerdict({ scores: { coverage: 5, relevance: 4 } }),
69
69
  makeVerdict({ scores: { coverage: 3, relevance: 4 } }),
@@ -75,14 +75,14 @@ describe('aggregateSideStats', () => {
75
75
  expect(stats.scoreByCriterion.relevance.variance).toBe(0) // all 4s
76
76
  })
77
77
 
78
- test('zero runs: all zeros', () => {
78
+ it('zero runs: all zeros', () => {
79
79
  const stats = aggregateSideStats('empty', [])
80
80
  expect(stats.runs).toBe(0)
81
81
  expect(stats.passRate).toBe(0)
82
82
  expect(stats.meanConfidence).toBeNull()
83
83
  })
84
84
 
85
- test('handles ERROR verdicts correctly', () => {
85
+ it('handles ERROR verdicts correctly', () => {
86
86
  const verdicts = [
87
87
  makeVerdict(),
88
88
  makeVerdict({ verdict: 'ERROR', reason: 'parse failed' }),
@@ -96,7 +96,7 @@ describe('aggregateSideStats', () => {
96
96
  // ── aggregateAllStats ──────────────────────────────────────────────────────
97
97
 
98
98
  describe('aggregateAllStats', () => {
99
- test('aggregates multiple sides', () => {
99
+ it('aggregates multiple sides', () => {
100
100
  const map = new Map<string, JudgeVerdict[]>()
101
101
  map.set('side-a', [makeVerdict(), makeVerdict()])
102
102
  map.set('side-b', [makeVerdict({ verdict: 'FAIL', reason: 'nope' })])