@lythos/skill-arena 0.17.10 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,20 +16,20 @@
16
16
  ```bash
17
17
  bun add -d @lythos/skill-arena
18
18
  # or use directly
19
- bunx @lythos/skill-arena@0.17.10 <command>
19
+ bunx @lythos/skill-arena@0.18.0 <command>
20
20
  ```
21
21
 
22
22
  ## Quick Start
23
23
 
24
24
  ```bash
25
25
  # single — test one deck (most common)
26
- bunx @lythos/skill-arena@0.17.10 single \
26
+ bunx @lythos/skill-arena@0.18.0 single \
27
27
  --deck ./examples/decks/scout.toml \
28
28
  --brief "Generate auth flow diagram" \
29
29
  --out ./output
30
30
 
31
31
  # single with explicit player
32
- bunx @lythos/skill-arena@0.17.10 single \
32
+ bunx @lythos/skill-arena@0.18.0 single \
33
33
  --deck ./examples/decks/scout.toml \
34
34
  --brief "Generate auth flow diagram" \
35
35
  --player kimi \
@@ -37,10 +37,10 @@ bunx @lythos/skill-arena@0.17.10 single \
37
37
 
38
38
  # cross-deck vs — compare two decks (agent-orchestrated)
39
39
  # Create arena.toml declaring sides with different decks, then:
40
- bunx @lythos/skill-arena@0.17.10 vs --config ./arena.toml
40
+ bunx @lythos/skill-arena@0.18.0 vs --config ./arena.toml
41
41
 
42
42
  # cross-player vs — compare kimi vs codex (CLI only)
43
- bunx @lythos/skill-arena@0.17.10 vs --config ./arena.toml --player kimi
43
+ bunx @lythos/skill-arena@0.18.0 vs --config ./arena.toml --player kimi
44
44
  ```
45
45
 
46
46
  **What happens**: Agent creates isolated `/tmp` workdir per side, `deck link` skills, spawns parallel subagents, collects artifacts, judge scores outputs. Parent deck restored after.
@@ -50,14 +50,14 @@ bunx @lythos/skill-arena@0.17.10 vs --config ./arena.toml --player kimi
50
50
  ### `single` — one deck, one task
51
51
 
52
52
  ```bash
53
- bunx @lythos/skill-arena@0.17.10 single \
53
+ bunx @lythos/skill-arena@0.18.0 single \
54
54
  --deck ./deck.toml \
55
55
  --brief "Produce a .docx report with radar chart" \
56
56
  --timeout 600000 \
57
57
  --out ./output
58
58
 
59
59
  # with explicit player
60
- bunx @lythos/skill-arena@0.17.10 single \
60
+ bunx @lythos/skill-arena@0.18.0 single \
61
61
  --deck ./deck.toml \
62
62
  --brief "Produce a .docx report with radar chart" \
63
63
  --player kimi \
@@ -67,14 +67,14 @@ bunx @lythos/skill-arena@0.17.10 single \
67
67
  ### `vs` — multi-deck comparison
68
68
 
69
69
  ```bash
70
- bunx @lythos/skill-arena@0.17.10 vs --config ./arena.toml
71
- bunx @lythos/skill-arena@0.17.10 vs --config ./arena.toml --dry-run
70
+ bunx @lythos/skill-arena@0.18.0 vs --config ./arena.toml
71
+ bunx @lythos/skill-arena@0.18.0 vs --config ./arena.toml --dry-run
72
72
  ```
73
73
 
74
74
  ### `prepare-workdir` — isolate + link skills (agent-orchestrated)
75
75
 
76
76
  ```bash
77
- bunx @lythos/skill-arena@0.17.10 prepare-workdir \
77
+ bunx @lythos/skill-arena@0.18.0 prepare-workdir \
78
78
  --deck ./skill-deck.toml \
79
79
  --out /tmp/arena-side-a \
80
80
  --brief "task description"
@@ -85,7 +85,7 @@ Creates `/tmp`-isolated workdir with deck copied, AGENTS.md written, and `deck l
85
85
  ### `archive` — collect agent outputs (agent-orchestrated)
86
86
 
87
87
  ```bash
88
- bunx @lythos/skill-arena@0.17.10 archive \
88
+ bunx @lythos/skill-arena@0.18.0 archive \
89
89
  --from /tmp/arena-side-a \
90
90
  --to ./playground/output \
91
91
  --sides side-a
@@ -96,7 +96,7 @@ Copies agent artifacts from workdir(s) to output, skipping internal files (`.cla
96
96
  ### `viz` — render results (WIP — HTML report generation pending)
97
97
 
98
98
  ```bash
99
- bunx @lythos/skill-arena@0.17.10 viz runs/arena-<id>/
99
+ bunx @lythos/skill-arena@0.18.0 viz runs/arena-<id>/
100
100
  ```
101
101
 
102
102
  ## Parameters
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-arena",
3
- "version": "0.17.10",
3
+ "version": "0.18.0",
4
4
  "description": "Skill Arena — benchmark skill effectiveness with controlled-variable comparison",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -42,15 +42,15 @@
42
42
  "bun": ">=1.0.0"
43
43
  },
44
44
  "dependencies": {
45
- "@lythos/cold-pool": "^0.17.10",
46
- "@lythos/infra": "^0.17.10",
47
- "@lythos/test-utils": "^0.17.10",
45
+ "@lythos/cold-pool": "^0.18.0",
46
+ "@lythos/infra": "^0.18.0",
47
+ "@lythos/test-utils": "^0.18.0",
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.17.10",
53
- "@lythos/agent-adapter-deepseek-serve": "^0.17.10",
54
- "@lythos/agent-adapter-codex": "^0.17.10"
52
+ "@lythos/agent-adapter-claude-sdk": "^0.18.0",
53
+ "@lythos/agent-adapter-deepseek-serve": "^0.18.0",
54
+ "@lythos/agent-adapter-codex": "^0.18.0"
55
55
  }
56
56
  }
package/src/cli.test.ts CHANGED
@@ -74,10 +74,14 @@ describe('main', () => {
74
74
  })
75
75
 
76
76
  describe('singleRun', () => {
77
+ // Validation-order tests pin --player to force external mode: since
78
+ // TASK-20260828141622671, mode resolution (host handoff vs no-player error)
79
+ // precedes flag validation, and the host env of the machine running the
80
+ // tests would otherwise change which error fires (CI has no host markers).
77
81
  it('errors when --deck is missing', async () => {
78
82
  const { io, logs, errors } = mockIO()
79
83
  const exitCode = await catchExitAsync(async () => {
80
- await main(['single', '--brief', 'test task'], io)
84
+ await main(['single', '--brief', 'test task', '--player', 'kimi'], io)
81
85
  })
82
86
  expect(exitCode).toBe(1)
83
87
  expect(errors.length).toBeGreaterThan(0)
@@ -88,7 +92,7 @@ describe('singleRun', () => {
88
92
  it('errors when both --task and --brief are missing', async () => {
89
93
  const { io, logs, errors } = mockIO()
90
94
  const exitCode = await catchExitAsync(async () => {
91
- await main(['single', '--deck', './some-deck.toml'], io)
95
+ await main(['single', '--deck', './some-deck.toml', '--player', 'kimi'], io)
92
96
  })
93
97
  expect(exitCode).toBe(1)
94
98
  expect(errors.length).toBeGreaterThan(0)
@@ -99,7 +103,7 @@ describe('singleRun', () => {
99
103
  it('errors when --brief is empty string', async () => {
100
104
  const { io, logs, errors } = mockIO()
101
105
  const exitCode = await catchExitAsync(async () => {
102
- await main(['single', '--deck', './some-deck.toml', '--brief', ''], io)
106
+ await main(['single', '--deck', './some-deck.toml', '--brief', '', '--player', 'kimi'], io)
103
107
  })
104
108
  expect(exitCode).toBe(1)
105
109
  expect(errors.length).toBeGreaterThan(0)
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ import { formatPlanOutput, type ArenaResult, buildArenaPrompt } from './runner'
7
7
  import { parseArenaToml, buildExecutionPlan } from './arena-toml'
8
8
  import { buildArchiveSidePlan, buildCopyPlan, buildPreparePlan, parseDeckSkills } from './preflight'
9
9
  import { checkSkillExistence, formatSkillWarnings, resolveColdPoolDir } from './preflight'
10
+ import { resolveSingleMode } from './host'
10
11
 
11
12
  // ─── ArenaCliIO interface (injected for testability) ───────────────────────
12
13
 
@@ -111,6 +112,10 @@ Commands:
111
112
  vs Compare decks via arena.toml (declarative, Pareto-optimal)
112
113
  viz Visualize a completed arena run (HTML + chart)
113
114
 
115
+ Modes (single):
116
+ Inside an agent session → host-handoff guidance (default; no external spawn)
117
+ Anywhere else → pass --player <name> (kimi|kimi-code|codex|claude|deepseek)
118
+
114
119
  Examples:
115
120
  lythoskill-arena single --brief "find and research" --deck ./decks/scout.toml
116
121
  lythoskill-arena single --brief "find and research" --deck https://raw.githubusercontent.com/lythos-labs/lythoskill/main/examples/decks/scout.toml
@@ -154,6 +159,24 @@ async function singleRun(args: string[], io: ArenaCliIO) {
154
159
  else if (args[i] === '--timeout') opts.timeout = args[++i]
155
160
  }
156
161
 
162
+ if (opts.player !== undefined && !opts.player) {
163
+ error(`❌ --player requires a non-empty name (e.g. --player kimi).
164
+ Player setup: skills/lythoskill-arena/references/player-setup.md`)
165
+ exit(1)
166
+ }
167
+
168
+ // Mode resolution comes FIRST (ADR-20260828004129143): with no --player, the
169
+ // relevant failure/guidance is about execution mode, not about flags.
170
+ const mode = resolveSingleMode(opts.player, process.env)
171
+ if (mode.mode === 'no-player') {
172
+ error(`❌ No --player given and no agent host detected.
173
+ arena single runs in one of two modes:
174
+ 1. Inside an agent session → host-handoff is the default (the host orchestrates the run).
175
+ 2. Anywhere else → pass --player <name> to spawn an external CLI.
176
+ Player setup: skills/lythoskill-arena/references/player-setup.md`)
177
+ exit(1)
178
+ }
179
+
157
180
  if (!opts.deck) {
158
181
  error(`❌ --deck <path|url> is required.
159
182
  --deck accepts local paths and http/https URLs (auto-fetched).
@@ -218,6 +241,30 @@ async function singleRun(args: string[], io: ArenaCliIO) {
218
241
  }
219
242
  }
220
243
 
244
+ // Host-handoff: print HATEOAS guidance and return BEFORE any fetch/spawn —
245
+ // no-spawn is structural (adapter dynamic imports below are never reached,
246
+ // no agent-output-* dir is ever created).
247
+ if (mode.mode === 'handoff') {
248
+ // Fail fast on a bogus LOCAL deck path — the host agent would discover it
249
+ // later otherwise. URLs are passed through (fetch validation is the host's).
250
+ if (!/^https?:\/\//.test(opts.deck!) && !existsSync(resolve(opts.deck!))) {
251
+ error(`❌ Deck file not found: ${resolve(opts.deck!)}
252
+ Fix the path (or pass a URL) before handing off — the host agent needs a real deck.`)
253
+ exit(1)
254
+ }
255
+ log(`🤝 Host-handoff mode — agent session detected (${mode.host.host}${mode.host.marker ? ` via ${mode.host.marker}` : ''})
256
+
257
+ What: arena single is running inside an agent session and hands this run back to the host.
258
+ Why: host-orchestrated runs are the default (ADR-20260828004129143) — the host agent
259
+ spawns subagents per deck and judges outputs itself; no external player CLI needed.
260
+ How: 1. In a subagent workdir, link the deck: bunx @lythos/skill-deck link --deck ${opts.deck}
261
+ 2. Spawn a subagent with that workdir and this ${opts.task ? `task: ${opts.task}` : `brief: ${opts.brief}`}
262
+ 3. Judge the subagent output(s) against the brief.
263
+ Docs: skills/lythoskill-arena/references/arena-runtime.md
264
+ Override: pass --player <name> to force an external CLI run.`)
265
+ return
266
+ }
267
+
221
268
  const { existsSync: deckExists, writeFileSync: deckWrite } = await import('node:fs')
222
269
  let deckPath: string
223
270
  if (opts.deck.startsWith('http://') || opts.deck.startsWith('https://')) {
@@ -271,10 +318,12 @@ async function singleRun(args: string[], io: ArenaCliIO) {
271
318
  try { await import('@lythos/agent-adapter-claude-sdk') } catch { /* package not installed */ }
272
319
  try { await import('@lythos/agent-adapter-deepseek-serve') } catch { /* package not installed */ }
273
320
  try { await import('@lythos/agent-adapter-codex') } catch { /* package not installed */ }
274
- const { resolvePlayer } = await import('./player')
321
+ const { resolvePlayer, playerAliasNote } = await import('./player')
275
322
 
276
- const player = resolvePlayer(opts.player ?? 'kimi')
323
+ const player = resolvePlayer(mode.player)
277
324
  const agent = useAgent(player)
325
+ const aliasNote = playerAliasNote(mode.player)
326
+ if (aliasNote) log(`ℹ️ ${aliasNote}`)
278
327
  const outDir = opts.out ? resolve(opts.out) : join(process.cwd(), `agent-output-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`)
279
328
  mkdirSync(outDir, { recursive: true })
280
329
 
@@ -0,0 +1,174 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2
+ import { mkdtempSync, readdirSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+ import { detectHost, resolveSingleMode } from './host'
6
+ import { main, type ArenaCliIO } from './cli'
7
+
8
+ // ─── Helpers ───────────────────────────────────────────────────────────────
9
+
10
+ const HOST_VARS = ['CLAUDECODE', 'CLAUDE_CODE_SSE_PORT'] as const
11
+
12
+ // Tests run inside real agent sessions (kimi-code sets CLAUDE_CODE_SSE_PORT) —
13
+ // every test pins the host markers explicitly instead of inheriting them.
14
+ function withEnv<T>(vars: Record<string, string | undefined>, fn: () => T): T {
15
+ const saved = new Map<string, string | undefined>()
16
+ for (const k of HOST_VARS) { saved.set(k, process.env[k]); delete process.env[k] }
17
+ for (const [k, v] of Object.entries(vars)) {
18
+ if (v === undefined) delete process.env[k]
19
+ else process.env[k] = v
20
+ }
21
+ try { return fn() } finally {
22
+ for (const [k, v] of saved.entries()) {
23
+ if (v === undefined) delete process.env[k]
24
+ else process.env[k] = v
25
+ }
26
+ }
27
+ }
28
+
29
+ async function catchExitAsync(fn: () => Promise<void>): Promise<number | undefined> {
30
+ let exitCode: number | undefined
31
+ try { await fn() } catch (e: any) {
32
+ if (!String(e).includes('EXIT:')) throw e
33
+ const m = String(e).match(/EXIT:(\d+)/)
34
+ if (m) exitCode = parseInt(m[1], 10)
35
+ }
36
+ return exitCode
37
+ }
38
+
39
+ function mockIO() {
40
+ const logs: string[] = []
41
+ const errors: string[] = []
42
+ const io: Required<ArenaCliIO> = {
43
+ log: (msg: string) => logs.push(msg),
44
+ error: (msg: string) => errors.push(msg),
45
+ warn: () => {},
46
+ exit: (code: number) => { throw new Error(`EXIT:${code}`) },
47
+ }
48
+ return { io, logs, errors }
49
+ }
50
+
51
+ const NO_HOST = { CLAUDECODE: undefined, CLAUDE_CODE_SSE_PORT: undefined } as const
52
+ const QUICK_START_DECK = resolve(import.meta.dir, '..', '..', '..', 'examples', 'decks', 'quick-start.toml')
53
+
54
+ // ─── detectHost ─────────────────────────────────────────────────────────────
55
+
56
+ describe('detectHost', () => {
57
+ it('detects Claude Code via CLAUDECODE', () => {
58
+ const d = detectHost({ CLAUDECODE: '1' } as NodeJS.ProcessEnv)
59
+ expect(d.detected).toBe(true)
60
+ expect(d.host).toBe('Claude Code')
61
+ expect(d.marker).toBe('CLAUDECODE')
62
+ })
63
+
64
+ it('detects claude-code forks (incl. kimi-code) via CLAUDE_CODE_SSE_PORT as unidentified', () => {
65
+ const d = detectHost({ CLAUDE_CODE_SSE_PORT: '12345' } as NodeJS.ProcessEnv)
66
+ expect(d.detected).toBe(true)
67
+ expect(d.host).toBe('agent host (unidentified)')
68
+ expect(d.marker).toBe('CLAUDE_CODE_SSE_PORT')
69
+ })
70
+
71
+ it('prefers the specific CLAUDECODE marker when both are set', () => {
72
+ const d = detectHost({ CLAUDECODE: '1', CLAUDE_CODE_SSE_PORT: '1' } as NodeJS.ProcessEnv)
73
+ expect(d.host).toBe('Claude Code')
74
+ })
75
+
76
+ it('reports no host when no markers are present', () => {
77
+ const d = detectHost({} as NodeJS.ProcessEnv)
78
+ expect(d.detected).toBe(false)
79
+ expect(d.host).toBe('none')
80
+ })
81
+ })
82
+
83
+ // ─── resolveSingleMode: 4-cell env/flag matrix ─────────────────────────────
84
+
85
+ describe('resolveSingleMode (4-cell matrix)', () => {
86
+ it('host detected + --player given → external (explicit player always wins)', () => {
87
+ const m = resolveSingleMode('codex', { CLAUDE_CODE_SSE_PORT: '1' } as NodeJS.ProcessEnv)
88
+ expect(m).toEqual({ mode: 'external', player: 'codex' })
89
+ })
90
+
91
+ it('no host + --player given → external', () => {
92
+ const m = resolveSingleMode('kimi', {} as NodeJS.ProcessEnv)
93
+ expect(m).toEqual({ mode: 'external', player: 'kimi' })
94
+ })
95
+
96
+ it('host detected + no --player → handoff', () => {
97
+ const m = resolveSingleMode(undefined, { CLAUDECODE: '1' } as NodeJS.ProcessEnv)
98
+ expect(m.mode).toBe('handoff')
99
+ if (m.mode === 'handoff') expect(m.host.host).toBe('Claude Code')
100
+ })
101
+
102
+ it('no host + no --player → no-player (loud error upstream)', () => {
103
+ const m = resolveSingleMode(undefined, {} as NodeJS.ProcessEnv)
104
+ expect(m.mode).toBe('no-player')
105
+ })
106
+ })
107
+
108
+ // ─── CLI integration ────────────────────────────────────────────────────────
109
+
110
+ describe('singleRun host-handoff integration', () => {
111
+ let workCwd: string
112
+ let origCwd: string
113
+
114
+ beforeEach(() => {
115
+ origCwd = process.cwd()
116
+ workCwd = mkdtempSync(join(tmpdir(), 'arena-handoff-'))
117
+ process.chdir(workCwd)
118
+ })
119
+ afterEach(() => { process.chdir(origCwd) })
120
+
121
+ it('host detected, no --player → prints handoff guidance, spawns nothing, exits 0', async () => {
122
+ await withEnv({ CLAUDE_CODE_SSE_PORT: '1' }, async () => {
123
+ const { io, logs, errors } = mockIO()
124
+ const exitCode = await catchExitAsync(async () => {
125
+ await main(['single', '--deck', QUICK_START_DECK, '--brief', 'test'], io)
126
+ })
127
+ // clean return (no exit throw) = exit 0
128
+ expect(exitCode).toBeUndefined()
129
+ expect(errors.length).toBe(0)
130
+ const out = logs.join('\n')
131
+ expect(out).toContain('Host-handoff mode')
132
+ expect(out).toContain('arena-runtime.md')
133
+ expect(out).toContain(QUICK_START_DECK)
134
+ // Structural no-spawn: branch returns before outDir creation
135
+ expect(readdirSync(workCwd).filter(e => e.startsWith('agent-output-'))).toEqual([])
136
+ })
137
+ })
138
+
139
+ it('no host, no --player → loud error pointing at player-setup.md, exit 1', async () => {
140
+ await withEnv(NO_HOST, async () => {
141
+ const { io, errors } = mockIO()
142
+ const exitCode = await catchExitAsync(async () => {
143
+ await main(['single', '--brief', 'x'], io)
144
+ })
145
+ expect(exitCode).toBe(1)
146
+ expect(errors[0]).toContain('player-setup.md')
147
+ expect(errors[0]).toContain('--player')
148
+ })
149
+ })
150
+
151
+ it('handoff mode fails fast on a bogus LOCAL deck path (no guidance, exit 1)', async () => {
152
+ await withEnv({ CLAUDE_CODE_SSE_PORT: '1' }, async () => {
153
+ const { io, logs, errors } = mockIO()
154
+ const exitCode = await catchExitAsync(async () => {
155
+ await main(['single', '--deck', './no-such-deck.toml', '--brief', 'x'], io)
156
+ })
157
+ expect(exitCode).toBe(1)
158
+ expect(errors[0]).toContain('Deck file not found')
159
+ expect(logs.join('\n')).not.toContain('Host-handoff mode')
160
+ })
161
+ })
162
+
163
+ it('empty --player "" is rejected loudly, never falls into handoff', async () => {
164
+ await withEnv({ CLAUDE_CODE_SSE_PORT: '1' }, async () => {
165
+ const { io, logs, errors } = mockIO()
166
+ const exitCode = await catchExitAsync(async () => {
167
+ await main(['single', '--deck', QUICK_START_DECK, '--brief', 'x', '--player', ''], io)
168
+ })
169
+ expect(exitCode).toBe(1)
170
+ expect(errors[0]).toContain('--player requires a non-empty name')
171
+ expect(logs.join('\n')).not.toContain('Host-handoff mode')
172
+ })
173
+ })
174
+ })
package/src/host.ts ADDED
@@ -0,0 +1,52 @@
1
+ // Host detection + single-mode resolution for arena host-handoff mode.
2
+ // Implements ADR-20260828004129143 (Option B): inside an agent session,
3
+ // `arena single` without --player hands off to the host agent instead of
4
+ // shelling out to a hardcoded external player.
5
+ //
6
+ // Pure + injectable: always pass `env` explicitly in tests.
7
+
8
+ export interface HostDetection {
9
+ detected: boolean
10
+ /** Display name — a known host, or a generic label when the fork is unidentified. */
11
+ host: string
12
+ /** The env var that fired (for diagnostics). Undefined when not detected. */
13
+ marker?: string
14
+ }
15
+
16
+ // Known agent-host env markers, most specific first.
17
+ // Ground truth (verified live 2026-08-28): kimi-code exports NO KIMI* variable;
18
+ // it is a claude-code fork and only inherits CLAUDE_CODE_SSE_PORT. Claude Code
19
+ // proper sets CLAUDECODE=1 (plus CLAUDE_CODE_SSE_PORT). So SSE_PORT alone means
20
+ // "an agent host we cannot name" — and the handoff guidance is host-agnostic
21
+ // by design, so identification never blocks the handoff.
22
+ const KNOWN_MARKERS: ReadonlyArray<{ env: string; host: string }> = [
23
+ { env: 'CLAUDECODE', host: 'Claude Code' },
24
+ { env: 'CLAUDE_CODE_SSE_PORT', host: 'agent host (unidentified)' },
25
+ ]
26
+
27
+ export function detectHost(env: NodeJS.ProcessEnv = process.env): HostDetection {
28
+ for (const { env: key, host } of KNOWN_MARKERS) {
29
+ if (env[key]) return { detected: true, host, marker: key }
30
+ }
31
+ return { detected: false, host: 'none' }
32
+ }
33
+
34
+ export type SingleMode =
35
+ | { mode: 'external'; player: string }
36
+ | { mode: 'handoff'; host: HostDetection }
37
+ | { mode: 'no-player' }
38
+
39
+ /**
40
+ * Default execution-mode resolution for `arena single`:
41
+ * explicit --player → external spawn (current behavior, unchanged)
42
+ * host detected, no player → host-handoff guidance
43
+ * no host, no player → loud error (callers point at player-setup.md)
44
+ */
45
+ export function resolveSingleMode(
46
+ player: string | undefined,
47
+ env: NodeJS.ProcessEnv = process.env,
48
+ ): SingleMode {
49
+ if (player) return { mode: 'external', player }
50
+ const host = detectHost(env)
51
+ return host.detected ? { mode: 'handoff', host } : { mode: 'no-player' }
52
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'bun:test'
2
- import { resolvePlayer, resolveSides, groupBySide, totalRuns } from './player'
2
+ import { resolvePlayer, playerAliasNote, resolveSides, groupBySide, totalRuns } from './player'
3
3
  import { parseArenaToml } from './arena-toml'
4
4
 
5
5
  const toml = parseArenaToml(`
@@ -32,6 +32,22 @@ describe('resolvePlayer', () => {
32
32
  expect(resolvePlayer('kimi')).toBe('kimi')
33
33
  })
34
34
 
35
+ it('maps kimi-code → kimi (upstream rename alias)', () => {
36
+ expect(resolvePlayer('kimi-code')).toBe('kimi')
37
+ expect(resolvePlayer('Kimi-Code')).toBe('kimi')
38
+ })
39
+
40
+ it('playerAliasNote reports alias mappings only', () => {
41
+ expect(playerAliasNote('kimi-code')).toContain("'kimi-code' = 'kimi'")
42
+ expect(playerAliasNote('kimi')).toBeNull()
43
+ expect(playerAliasNote('expert-architect')).toBeNull()
44
+ })
45
+
46
+ it('playerAliasNote covers the other built-in mappings too (claude family)', () => {
47
+ expect(playerAliasNote('claude')).toContain("'claude' = 'claude-sdk'")
48
+ expect(playerAliasNote('claude-cli')).toContain("'claude-cli' = 'claude'")
49
+ })
50
+
35
51
  it('passes through unknown player names', () => {
36
52
  expect(resolvePlayer('expert-architect')).toBe('expert-architect')
37
53
  })
package/src/player.ts CHANGED
@@ -16,12 +16,26 @@ const BUILTIN_PLAYERS: Record<string, string> = {
16
16
  'claude-code': 'claude-sdk',
17
17
  'claude-cli': 'claude', // legacy CLI spawn — explicitly opt-in
18
18
  'kimi': 'kimi',
19
+ 'kimi-code': 'kimi', // renamed upstream — users guess the new name (ADR-20260828004129233)
19
20
  'deepseek': 'deepseek',
20
21
  'codex': 'codex',
21
22
  'cursor': 'cursor',
22
23
  'gemini': 'gemini',
23
24
  }
24
25
 
26
+ /**
27
+ * One-line note when a player reference resolves through a built-in mapping
28
+ * (e.g. 'kimi-code' → 'kimi'), else null. Pure — callers decide whether to print.
29
+ */
30
+ export function playerAliasNote(name: string): string | null {
31
+ const normalized = name.toLowerCase().trim()
32
+ const resolved = BUILTIN_PLAYERS[normalized]
33
+ if (resolved && resolved !== normalized) {
34
+ return `player '${normalized}' = '${resolved}' (built-in alias)`
35
+ }
36
+ return null
37
+ }
38
+
25
39
  /**
26
40
  * Resolve a player reference to its platform identifier.
27
41
  * - Built-in names (claude, kimi, cursor) map directly