@lythos/skill-arena 0.14.0 → 0.14.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.
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  ```bash
17
17
  bun add -d @lythos/skill-arena
18
18
  # or use directly
19
- bunx @lythos/skill-arena@0.14.0 <command>
19
+ bunx @lythos/skill-arena@0.14.2 <command>
20
20
  ```
21
21
 
22
22
  ## Quick Start
@@ -65,6 +65,42 @@ bunx @lythos/skill-arena@latest scaffold \
65
65
  --decks "./decks/minimal.toml,./decks/rich.toml"
66
66
  ```
67
67
 
68
+ ### `prepare-workdir` — isolate + link skills (agent-orchestrated)
69
+
70
+ ```bash
71
+ bunx @lythos/skill-arena@latest prepare-workdir \
72
+ --deck ./skill-deck.toml \
73
+ --out /tmp/arena-side-a \
74
+ --brief "task description"
75
+
76
+ # Plan-first: review before executing
77
+ bunx @lythos/skill-arena@latest prepare-workdir \
78
+ --deck ./skill-deck.toml \
79
+ --out /tmp/arena-side-a \
80
+ --brief "task" \
81
+ --dry-run
82
+ ```
83
+
84
+ Creates `/tmp`-isolated workdir with deck copied, AGENTS.md written, and `deck link` run. `--dry-run` prints the plan (skills, workdir path, link needed) without creating anything.
85
+
86
+ ### `archive` — collect agent outputs (agent-orchestrated)
87
+
88
+ ```bash
89
+ bunx @lythos/skill-arena@latest archive \
90
+ --from /tmp/arena-side-a \
91
+ --to ./playground/output \
92
+ --sides side-a
93
+
94
+ # Plan-first: review what would be copied
95
+ bunx @lythos/skill-arena@latest archive \
96
+ --from /tmp/arena-side-a \
97
+ --to ./playground/output \
98
+ --sides side-a \
99
+ --dry-run
100
+ ```
101
+
102
+ Copies agent artifacts from workdir(s) to output, skipping internal files (`.claude`, `skill-deck.toml`, `skill-deck.lock`, `AGENTS.md`). Single-side archives fall back to workdir root when the named side subdirectory doesn't exist. `--dry-run` shows the per-side plan before copying.
103
+
68
104
  ### `viz` — render results
69
105
 
70
106
  ```bash
@@ -79,9 +115,12 @@ bunx @lythos/skill-arena@latest viz runs/arena-<id>/
79
115
  | `--deck <path\|url>` | single | Deck file (URL auto-fetched) |
80
116
  | `--player <name>` | single, vs | Only for cross-player: kimi\|codex\|deepseek\|claude |
81
117
  | `--timeout <ms>` | single | Subagent timeout (300000–600000 for complex tasks) |
82
- | `--out <dir>` | single, vs | Output directory |
118
+ | `--from <dir>` | archive | Source workdir |
119
+ | `--to <dir>` | archive | Output directory |
120
+ | `--sides <names>` | archive | Comma-separated side names (default: `.`) |
121
+ | `--out <dir>` | single, vs, prepare-workdir | Output / workdir directory |
83
122
  | `--config <path>` | vs | arena.toml |
84
- | `--dry-run` | vs | Print plan without execution |
123
+ | `--dry-run` | vs, prepare-workdir, archive | Print plan without execution |
85
124
 
86
125
  ## Prerequisites (cross-player only)
87
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-arena",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Skill Arena — benchmark skill effectiveness with controlled-variable comparison",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -42,13 +42,13 @@
42
42
  "bun": ">=1.0.0"
43
43
  },
44
44
  "dependencies": {
45
- "@lythos/cold-pool": "^0.14.0",
46
- "@lythos/infra": "^0.14.0",
47
- "@lythos/test-utils": "^0.14.0",
45
+ "@lythos/cold-pool": "^0.14.2",
46
+ "@lythos/infra": "^0.14.2",
47
+ "@lythos/test-utils": "^0.14.2",
48
48
  "zod": "^3.24.0",
49
49
  "zod-to-json-schema": "^3.25.2"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@lythos/agent-adapter-claude-sdk": "^0.14.0"
52
+ "@lythos/agent-adapter-claude-sdk": "^0.14.2"
53
53
  }
54
54
  }
package/src/cli.ts CHANGED
@@ -5,7 +5,7 @@ import { homedir, tmpdir } from 'node:os'
5
5
  import { ZodError } from 'zod'
6
6
  import { formatPlanOutput, type ArenaResult, buildArenaPrompt } from './runner'
7
7
  import { parseArenaToml, buildExecutionPlan } from './arena-toml'
8
- import { buildCopyPlan, parseDeckSkills } from './preflight'
8
+ import { buildArchiveSidePlan, buildCopyPlan, buildPreparePlan, parseDeckSkills } from './preflight'
9
9
  import { checkSkillExistence, formatSkillWarnings, resolveColdPoolDir } from './preflight'
10
10
 
11
11
  // ─── fetchWithProxy (infra dependency, no package boundary) ─────────────────
@@ -441,10 +441,12 @@ async function vizRun(args: string[]) {
441
441
 
442
442
  async function prepareWorkdir(args: string[]) {
443
443
  const opts: Record<string, string | undefined> = {}
444
+ let dryRun = false
444
445
  for (let i = 0; i < args.length; i++) {
445
446
  if (args[i] === '--deck' || args[i] === '-d') opts.deck = args[++i]
446
447
  else if (args[i] === '--out' || args[i] === '-o') opts.out = args[++i]
447
448
  else if (args[i] === '--brief' || args[i] === '-b') opts.brief = args[++i]
449
+ else if (args[i] === '--dry-run') dryRun = true
448
450
  }
449
451
 
450
452
  if (!opts.deck) {
@@ -462,39 +464,36 @@ async function prepareWorkdir(args: string[]) {
462
464
  const workDir = opts.out
463
465
  ? resolve(opts.out)
464
466
  : join(tmpdir(), `arena-${Date.now()}`)
465
- mkdirSync(workDir, { recursive: true })
466
-
467
- // Copy deck into workdir
468
- writeFileSync(join(workDir, 'skill-deck.toml'), readFileSync(deckPath, 'utf-8'))
467
+ const deckContent = readFileSync(deckPath, 'utf-8')
468
+
469
+ // ── Plan (pure computation — what WOULD be created) ────────────────────
470
+ const plan = buildPreparePlan({
471
+ deckPath,
472
+ deckContent,
473
+ workDir,
474
+ skillCount: 0, // computed inside from deckContent
475
+ brief: opts.brief,
476
+ })
469
477
 
470
- // Write AGENTS.md (same contract as CLI singleRun)
471
- writeFileSync(join(workDir, 'AGENTS.md'), [
472
- '# Arena Test Environment',
473
- '**Mode**: agent-orchestrated cell',
474
- '',
475
- '## Setup Order (why this sequence)',
476
- '1. `skill-deck.toml` copied here → declares which skills you can use',
477
- '2. `deck link` runs → cold pool skills become visible in `.claude/skills/`',
478
- '3. Skill existence checked → warns if any declared skill is missing from cold pool',
479
- '4. `AGENTS.md` written last confirms setup succeeded before agent starts',
480
- 'If setup fails mid-sequence, the workdir is incomplete and nothing runs.',
481
- '',
482
- '## How This Works',
483
- '- Write ALL output files to this directory (CWD).',
484
- '- Use available skills — check `ls .claude/skills/`.',
485
- '',
486
- '## Output Contract',
487
- '- MANDATORY: `decision-log.jsonl` — one JSON line per decision:',
488
- ' `{"t":<seconds>,"phase":"setup|content|design|output","decision":"...","reason":"..."}`',
489
- ].join('\n'))
478
+ console.log('📋 Prepare plan:')
479
+ console.log(` deck: ${plan.deckPath}`)
480
+ console.log(` workdir: ${plan.workDir}`)
481
+ console.log(` skills: ${plan.skills.length} declared (${plan.skills.map(s => s.name).join(', ') || 'none'})`)
482
+ console.log(` link: ${plan.hasSkills ? 'Bun.spawn deck link' : 'skip (no skills)'}`)
483
+ console.log(` AGENTS.md: write (${plan.agentsMd.split('\n').length} lines)`)
484
+ if (opts.brief) console.log(` brief: ${opts.brief!.slice(0, 60)}...`)
485
+
486
+ if (dryRun) {
487
+ console.log(`\n🏁 Dry-run complete (no files created). Remove --dry-run to execute.`)
488
+ return
489
+ }
490
490
 
491
- // Parse deck for link + checks
492
- const deckRaw = readFileSync(join(workDir, 'skill-deck.toml'), 'utf-8')
493
- let deckParsed: Record<string, any> = {}
494
- try { deckParsed = Bun.TOML.parse(deckRaw) as Record<string, any> } catch {}
495
- const hasSkills = parseDeckSkills(deckParsed).length > 0
491
+ // ── Execute: create workdir ──────────────────────────────────────────
492
+ mkdirSync(workDir, { recursive: true })
493
+ writeFileSync(join(workDir, 'skill-deck.toml'), deckContent)
494
+ writeFileSync(join(workDir, 'AGENTS.md'), plan.agentsMd)
496
495
 
497
- if (hasSkills) {
496
+ if (plan.hasSkills) {
498
497
  const { existsSync: es2 } = await import('node:fs')
499
498
  const localDeckCli = join(import.meta.dir, '..', '..', 'lythoskill-deck', 'src', 'cli.ts')
500
499
  const linkCmd = es2(localDeckCli)
@@ -517,9 +516,8 @@ async function prepareWorkdir(args: string[]) {
517
516
  // Skill existence check
518
517
  try {
519
518
  const coldPoolDefault = join(homedir(), '.agents', 'skill-repos')
520
- const coldPoolDir = resolveColdPoolDir(deckParsed?.deck?.cold_pool, homedir(), coldPoolDefault)
521
- const skills = parseDeckSkills(deckParsed)
522
- const checks = checkSkillExistence(skills, coldPoolDir, existsSync)
519
+ const coldPoolDir = resolveColdPoolDir(Bun.TOML.parse(deckContent)?.deck?.cold_pool, homedir(), coldPoolDefault)
520
+ const checks = checkSkillExistence(plan.skills, coldPoolDir, existsSync)
523
521
  for (const warning of formatSkillWarnings(checks)) {
524
522
  console.warn(`⚠️ ${warning}`)
525
523
  }
@@ -538,11 +536,13 @@ async function prepareWorkdir(args: string[]) {
538
536
 
539
537
  async function archiveRun(args: string[]) {
540
538
  const opts: Record<string, string | undefined> = {}
539
+ let dryRun = false
541
540
  for (let i = 0; i < args.length; i++) {
542
541
  if (args[i] === '--from' || args[i] === '-f') opts.from = args[++i]
543
542
  else if (args[i] === '--to' || args[i] === '-o') opts.to = args[++i]
544
543
  else if (args[i] === '--sides') opts.sides = args[++i]
545
544
  else if (args[i] === '--report') opts.report = args[++i]
545
+ else if (args[i] === '--dry-run') dryRun = true
546
546
  }
547
547
 
548
548
  if (!opts.from || !opts.to) {
@@ -553,40 +553,57 @@ async function archiveRun(args: string[]) {
553
553
 
554
554
  const fromDir = resolve(opts.from)
555
555
  const outDir = resolve(opts.to)
556
+
557
+ const sides = opts.sides ? opts.sides.split(',') : ['.']
558
+ const plan = buildArchiveSidePlan(fromDir, sides, existsSync)
559
+
560
+ // ── Plan output (always shown, also serves as dry-run) ──────────────────
561
+ console.log('📋 Archive plan:')
562
+ for (const pe of plan) {
563
+ if (!pe.found) {
564
+ console.log(` ⚠️ ${pe.side}: not found (${pe.sourceDir}) — will skip`)
565
+ } else if (pe.sourceDir === fromDir && pe.side !== '.') {
566
+ console.log(` ${pe.side}: ${pe.sourceDir} (fallback → root) → ${join(outDir, pe.side)}`)
567
+ } else {
568
+ console.log(` ${pe.side}: ${pe.sourceDir} → ${join(outDir, pe.side)}`)
569
+ }
570
+ }
571
+ if (dryRun) {
572
+ console.log(`\n🏁 Dry-run complete (no files copied). Remove --dry-run to execute.`)
573
+ return
574
+ }
575
+
576
+ // ── Execute: copy files ───────────────────────────────────────────────
556
577
  mkdirSync(outDir, { recursive: true })
557
578
 
558
- // Copy report if provided
559
579
  if (opts.report && existsSync(resolve(opts.report))) {
560
- const { cpSync } = await import('node:fs')
561
- cpSync(resolve(opts.report), join(outDir, 'report.md'))
580
+ const { cpSync: cpR } = await import('node:fs')
581
+ cpR(resolve(opts.report), join(outDir, 'report.md'))
562
582
  console.log(`📄 report.md → ${outDir}/report.md`)
563
583
  }
564
584
 
565
- // Copy per-side outputs (same skipSet as CLI singleRun)
566
585
  const { cpSync, readdirSync } = await import('node:fs')
567
586
  const skipSet = new Set(['.claude', 'skill-deck.toml', 'skill-deck.lock', 'AGENTS.md'])
568
587
 
569
- const sides = opts.sides ? opts.sides.split(',') : ['.']
570
- for (const side of sides) {
571
- const sideWorkDir = side === '.' ? fromDir : join(fromDir, side)
572
- if (!existsSync(sideWorkDir)) {
573
- console.warn(`⚠️ Side workdir not found: ${sideWorkDir}`)
588
+ for (const planEntry of plan) {
589
+ if (!planEntry.found) {
590
+ console.warn(`⚠️ Side workdir not found: ${planEntry.sourceDir}`)
574
591
  continue
575
592
  }
576
593
 
577
- const sideOutDir = join(outDir, side)
594
+ const sideOutDir = join(outDir, planEntry.side)
578
595
  mkdirSync(sideOutDir, { recursive: true })
579
596
 
580
- const entries = readdirSync(sideWorkDir, { withFileTypes: true })
597
+ const entries = readdirSync(planEntry.sourceDir, { withFileTypes: true })
581
598
  for (const entry of entries) {
582
599
  if (skipSet.has(entry.name)) continue
583
- const src = join(sideWorkDir, entry.name)
600
+ const src = join(planEntry.sourceDir, entry.name)
584
601
  const dest = join(sideOutDir, entry.name)
585
602
  try {
586
603
  cpSync(src, dest, { recursive: entry.isDirectory() })
587
- console.log(` ${side}/${entry.name} → ${dest}`)
604
+ console.log(` ${planEntry.side}/${entry.name} → ${dest}`)
588
605
  } catch (e) {
589
- console.warn(`⚠️ Failed to copy ${side}/${entry.name}: ${e instanceof Error ? e.message : e}`)
606
+ console.warn(`⚠️ Failed to copy ${planEntry.side}/${entry.name}: ${e instanceof Error ? e.message : e}`)
590
607
  }
591
608
  }
592
609
  }
@@ -393,3 +393,192 @@ describe('formatSkillWarnings', () => {
393
393
  expect(formatSkillWarnings(checks)[0]).toContain('[transient]')
394
394
  })
395
395
  })
396
+
397
+
398
+ // ═══════════════════════════════════════════════════════════════════════════
399
+ // buildArchiveSidePlan
400
+ // ═══════════════════════════════════════════════════════════════════════════
401
+
402
+
403
+ // ═══════════════════════════════════════════════════════════════════════════
404
+ // buildArchiveSidePlan
405
+ // ═══════════════════════════════════════════════════════════════════════════
406
+
407
+ import { buildArchiveSidePlan } from './preflight'
408
+ import { join as pathJoin } from 'node:path'
409
+
410
+ const TMP = '/tmp/arena-test'
411
+
412
+ describe('buildArchiveSidePlan', () => {
413
+
414
+ test('default: sides=["."] maps to fromDir', () => {
415
+ const plan = buildArchiveSidePlan(TMP, ['.'], _p => true)
416
+ expect(plan).toEqual([
417
+ { side: '.', sourceDir: TMP, found: true },
418
+ ])
419
+ })
420
+
421
+ test('single side, subdirectory exists → source = fromDir/side', () => {
422
+ const exists = (p: string) => p === pathJoin(TMP, 'side-a')
423
+ const plan = buildArchiveSidePlan(TMP, ['side-a'], exists)
424
+ expect(plan).toEqual([
425
+ { side: 'side-a', sourceDir: pathJoin(TMP, 'side-a'), found: true },
426
+ ])
427
+ })
428
+
429
+ test('single side, subdirectory MISSING → fallback to fromDir root', () => {
430
+ const plan = buildArchiveSidePlan(TMP, ['side-a'], _p => false)
431
+ expect(plan).toEqual([
432
+ { side: 'side-a', sourceDir: TMP, found: true },
433
+ ])
434
+ })
435
+
436
+ test('multi side, all subdirectories exist', () => {
437
+ const exists = (p: string) =>
438
+ p === pathJoin(TMP, 'side-a') || p === pathJoin(TMP, 'side-b')
439
+ const plan = buildArchiveSidePlan(TMP, ['side-a', 'side-b'], exists)
440
+ expect(plan).toEqual([
441
+ { side: 'side-a', sourceDir: pathJoin(TMP, 'side-a'), found: true },
442
+ { side: 'side-b', sourceDir: pathJoin(TMP, 'side-b'), found: true },
443
+ ])
444
+ })
445
+
446
+ test('multi side, one missing → found=false (caller handles warn+skip)', () => {
447
+ const exists = (p: string) => p === pathJoin(TMP, 'side-a')
448
+ const plan = buildArchiveSidePlan(TMP, ['side-a', 'side-b'], exists)
449
+ expect(plan).toEqual([
450
+ { side: 'side-a', sourceDir: pathJoin(TMP, 'side-a'), found: true },
451
+ { side: 'side-b', sourceDir: pathJoin(TMP, 'side-b'), found: false },
452
+ ])
453
+ })
454
+
455
+ test('"." side does NOT trigger fallback when missing (found=false)', () => {
456
+ const plan = buildArchiveSidePlan(TMP, ['.'], _p => false)
457
+ expect(plan).toEqual([
458
+ { side: '.', sourceDir: TMP, found: false },
459
+ ])
460
+ })
461
+
462
+ test('empty sides array → empty plan', () => {
463
+ const plan = buildArchiveSidePlan(TMP, [], _p => true)
464
+ expect(plan).toEqual([])
465
+ })
466
+
467
+ test('three sides, middle missing', () => {
468
+ const exists = (p: string) =>
469
+ p === pathJoin(TMP, 'side-a') || p === pathJoin(TMP, 'side-c')
470
+ const plan = buildArchiveSidePlan(TMP, ['side-a', 'side-b', 'side-c'], exists)
471
+ expect(plan).toEqual([
472
+ { side: 'side-a', sourceDir: pathJoin(TMP, 'side-a'), found: true },
473
+ { side: 'side-b', sourceDir: pathJoin(TMP, 'side-b'), found: false },
474
+ { side: 'side-c', sourceDir: pathJoin(TMP, 'side-c'), found: true },
475
+ ])
476
+ })
477
+ })
478
+
479
+ // ═══════════════════════════════════════════════════════════════════════════
480
+ // buildPreparePlan
481
+ // ═══════════════════════════════════════════════════════════════════════════
482
+
483
+ import { buildPreparePlan } from './preflight'
484
+
485
+ const DECK_ONE_SKILL = `
486
+ [deck]
487
+ max_cards = 10
488
+ cold_pool = "~/.agents/skill-repos"
489
+
490
+ [tool.skills.pdf]
491
+ path = "github.com/anthropics/skills/skills/pdf"
492
+ `
493
+
494
+ const DECK_EMPTY = `
495
+ [deck]
496
+ max_cards = 5
497
+ `
498
+
499
+ const DECK_TWO_SKILLS = `
500
+ [deck]
501
+ max_cards = 10
502
+
503
+ [innate.skills.deck]
504
+ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
505
+
506
+ [tool.skills.pdf]
507
+ path = "github.com/anthropics/skills/skills/pdf"
508
+ `
509
+
510
+ describe('buildPreparePlan', () => {
511
+
512
+ test('single skill deck → plan with 1 skill, hasSkills=true', () => {
513
+ const plan = buildPreparePlan({
514
+ deckPath: '/tmp/test-deck.toml',
515
+ deckContent: DECK_ONE_SKILL,
516
+ workDir: '/tmp/arena-test',
517
+ skillCount: 0,
518
+ })
519
+ expect(plan.skills).toHaveLength(1)
520
+ expect(plan.skills[0].name).toBe('pdf')
521
+ expect(plan.skills[0].section).toBe('tool')
522
+ expect(plan.hasSkills).toBe(true)
523
+ expect(plan.workDir).toBe('/tmp/arena-test')
524
+ expect(plan.deckPath).toBe('/tmp/test-deck.toml')
525
+ })
526
+
527
+ test('empty deck → skills=[], hasSkills=false', () => {
528
+ const plan = buildPreparePlan({
529
+ deckPath: '/tmp/empty.toml',
530
+ deckContent: DECK_EMPTY,
531
+ workDir: '/tmp/arena-empty',
532
+ skillCount: 0,
533
+ })
534
+ expect(plan.skills).toEqual([])
535
+ expect(plan.hasSkills).toBe(false)
536
+ })
537
+
538
+ test('two skills (innate + tool) → both parsed with correct sections', () => {
539
+ const plan = buildPreparePlan({
540
+ deckPath: '/tmp/two.toml',
541
+ deckContent: DECK_TWO_SKILLS,
542
+ workDir: '/tmp/arena-two',
543
+ skillCount: 0,
544
+ })
545
+ expect(plan.skills).toHaveLength(2)
546
+ expect(plan.skills[0]).toEqual({ name: 'deck', path: 'github.com/lythos-labs/lythoskill/skills/lythoskill-deck', section: 'innate' })
547
+ expect(plan.skills[1]).toEqual({ name: 'pdf', path: 'github.com/anthropics/skills/skills/pdf', section: 'tool' })
548
+ expect(plan.hasSkills).toBe(true)
549
+ })
550
+
551
+ test('AGENTS.md contains mandatory sections', () => {
552
+ const plan = buildPreparePlan({
553
+ deckPath: '/tmp/d.toml',
554
+ deckContent: DECK_ONE_SKILL,
555
+ workDir: '/tmp/arena-md',
556
+ skillCount: 0,
557
+ })
558
+ expect(plan.agentsMd).toContain('Arena Test Environment')
559
+ expect(plan.agentsMd).toContain('Setup Order')
560
+ expect(plan.agentsMd).toContain('decision-log.jsonl')
561
+ expect(plan.agentsMd).toContain('skill-deck.toml')
562
+ })
563
+
564
+ test('invalid TOML → skills=[], hasSkills=false (no crash)', () => {
565
+ const plan = buildPreparePlan({
566
+ deckPath: '/tmp/bad.toml',
567
+ deckContent: 'this is not toml {{{',
568
+ workDir: '/tmp/arena-bad',
569
+ skillCount: 0,
570
+ })
571
+ expect(plan.skills).toEqual([])
572
+ expect(plan.hasSkills).toBe(false)
573
+ })
574
+
575
+ test('deckContent is preserved in plan', () => {
576
+ const plan = buildPreparePlan({
577
+ deckPath: '/tmp/d.toml',
578
+ deckContent: DECK_ONE_SKILL,
579
+ workDir: '/tmp/arena-preserve',
580
+ skillCount: 0,
581
+ })
582
+ expect(plan.deckContent).toBe(DECK_ONE_SKILL)
583
+ })
584
+ })
package/src/preflight.ts CHANGED
@@ -195,6 +195,104 @@ export function resolveColdPoolDir(
195
195
  return raw.startsWith('~') ? `${homeDir}${raw.slice(1)}` : raw
196
196
  }
197
197
 
198
+ // ── buildArchiveSidePlan ──────────────────────────────────────────────────
199
+
200
+ /**
201
+ * A single side's source mapping in an archive plan.
202
+ * Pure data — no IO, no console.
203
+ */
204
+ export interface ArchiveSideEntry {
205
+ side: string
206
+ sourceDir: string
207
+ found: boolean
208
+ }
209
+
210
+ /**
211
+ * Build the per-side source directory plan for archive.
212
+ *
213
+ * Pure: strings + existence function → ArchiveSideEntry[].
214
+ * IO (`existsSync`) is injected via `existsFn` — test with mock, run with real.
215
+ *
216
+ * Single-side fallback: when --sides specifies exactly one named side and its
217
+ * subdirectory doesn't exist (agent put files in workdir root, prepare-workdir
218
+ * didn't create per-side dirs), fall back to `fromDir` as source (found=true).
219
+ *
220
+ * Default (no --sides): sides = ['.'] → sourceDir = fromDir.
221
+ */
222
+ export function buildArchiveSidePlan(
223
+ fromDir: string,
224
+ sides: string[],
225
+ existsFn: (path: string) => boolean
226
+ ): ArchiveSideEntry[] {
227
+ const plan: ArchiveSideEntry[] = []
228
+ for (const side of sides) {
229
+ let sourceDir = side === '.' ? fromDir : join(fromDir, side)
230
+ let found = existsFn(sourceDir)
231
+ if (!found && sides.length === 1 && side !== '.') {
232
+ sourceDir = fromDir
233
+ found = true
234
+ }
235
+ plan.push({ side, sourceDir, found })
236
+ }
237
+ return plan
238
+ }
239
+
240
+ // ── buildPreparePlan ─────────────────────────────────────────────────────
241
+
242
+ /**
243
+ * Plan-only result for prepare-workdir — what WOULD be created.
244
+ * Pure data, no IO. Caller renders this before executing.
245
+ */
246
+ export interface PreparePlan {
247
+ deckPath: string
248
+ deckContent: string
249
+ workDir: string
250
+ skills: SkillDecl[]
251
+ hasSkills: boolean
252
+ agentsMd: string
253
+ }
254
+
255
+ /**
256
+ * Build the prepare-workdir plan from raw inputs.
257
+ *
258
+ * Pure computation: deck path + content → what workdir would contain.
259
+ * Caller does IO (reading deck, computing timestamp) and injects results.
260
+ */
261
+ export function buildPreparePlan(params: {
262
+ deckPath: string
263
+ deckContent: string
264
+ workDir: string
265
+ skillCount: number
266
+ brief?: string
267
+ }): PreparePlan {
268
+ let deckParsed: Record<string, any> = {}
269
+ try { deckParsed = Bun.TOML.parse(params.deckContent) as Record<string, any> } catch {}
270
+ const skills = parseDeckSkills(deckParsed)
271
+ const hasSkills = skills.length > 0
272
+
273
+ const agentsMd = [
274
+ '# Arena Test Environment',
275
+ '**Mode**: agent-orchestrated cell',
276
+ '',
277
+ '## Setup Order (why this sequence)',
278
+ '1. `skill-deck.toml` copied here → declares which skills you can use',
279
+ '2. `deck link` runs → cold pool skills become visible in `.claude/skills/`',
280
+ '3. Skill existence checked → warns if any declared skill is missing from cold pool',
281
+ '4. `AGENTS.md` written last → confirms setup succeeded before agent starts',
282
+ 'If setup fails mid-sequence, the workdir is incomplete and nothing runs.',
283
+ '',
284
+ '## How This Works',
285
+ '- Write ALL output files to this directory (CWD).',
286
+ '- Use available skills — check `ls .claude/skills/`.',
287
+ '',
288
+ '## Output Contract',
289
+ '- MANDATORY: `decision-log.jsonl` — one JSON line per decision:',
290
+ ' `{"t":<seconds>,"phase":"setup|content|design|output","decision":"...","reason":"..."}`',
291
+ ].join('\n')
292
+
293
+ return { deckPath: params.deckPath, deckContent: params.deckContent, workDir: params.workDir, skills, hasSkills, agentsMd }
294
+ }
295
+
198
296
  // ── formatSkillWarnings ──────────────────────────────────────────────────
199
297
 
200
298
  /**