@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 +42 -3
- package/package.json +5 -5
- package/src/cli.ts +65 -48
- package/src/preflight.test.ts +189 -0
- package/src/preflight.ts +98 -0
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.
|
|
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
|
-
| `--
|
|
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.
|
|
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.
|
|
46
|
-
"@lythos/infra": "^0.14.
|
|
47
|
-
"@lythos/test-utils": "^0.14.
|
|
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.
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
//
|
|
468
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
''
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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(
|
|
521
|
-
const
|
|
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
|
-
|
|
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
|
|
570
|
-
|
|
571
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|
package/src/preflight.test.ts
CHANGED
|
@@ -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
|
/**
|