Competitive ruliology for Ruflo swarms — arenas, tournaments, and adaptive co-evolution of program strategies (ADR-147/148). Strategies-as-programs compete…

Competitive ruliology for Ruflo swarms — arenas, tournaments, and adaptive co-evolution of program strategies. Implements the first executable slice of Ruflo ADR-147/148/150, following Stephen Wolfram's *Games Between Programs: The Ruliology of Competition*.
Strategies are programs (deterministic finite-state machines reading opponent history). They compete under payoff games; tournaments produce Wolfram's competitive array; hill-climb and mutual co-evolution discover winners empirically — because, per computational irreducibility, you have to run the competition to find out.
cd plugins/ruflo-arena
npm install # light — runtime dep is just zod
npm run build # tsc -> dist/
npm test # vitest (engine + MCP tools + persistence)
npm run lint # eslint (typescript-eslint)
node dist/cli.js demo # tournament + evolution + co-evolution
node dist/cli.js tournament --game pd --rounds 200 --seed 1
node dist/cli.js arena --a tit-for-tat --b always-defect
node dist/cli.js evolve --game pd --generations 300 --seed 42
node dist/cli.js coevolve --game pd --generations 400 --seed 7
Sample PD ranking (mean-vs-field): grim ≈ 2.99, always-defect ≈ 2.76, tit-for-tat ≈ 2.50, … always-cooperate ≈ 1.88. The evolution run climbs from a random FSM (~2.78) to ~3.00 with the characteristic plateau→breakthrough fitness curve.
ruflo-arena/mcp-tools)| Tool | Purpose |
|---|---|
arena/run | one deterministic match between two named strategies |
tournament/run | round-robin → competitive array + mean-vs-field ranking |
evolve/run | hill-climb an FSM vs the field; returns program + fitness curve |
coevolve/run | mutual co-evolution (arms race) trace |
run/get, run/list | fetch/list persisted run records |
All return { success, result | error } and validate inputs with Zod. See commands/arena.md and docs/adrs/0001-arena-contract.md.
Full run artifacts are written to .ruflo/arena/<runId>.json (exact replay). Each tool result also carries an agentdb payload so the command layer can store a searchable summary via mcp__plugin_ruflo-core_ruflo__memory_store (namespace arena) — the local stand-in for the RuVector data layer (ADR-196/197), enabling queries like "tournaments where grim dominated".
v1 is intentionally Ruflo-only and core-untouched:
src/
domain/ types (+ Zod schemas) · games · strategies (FSM programs, library, mutation)
engine/ rng · arena (match) · tournament (competitive array) · evolution (hill-climb, co-evolution)
persistence/ RunStore (File + InMemory) + AgentDB record builder
report/ competitive-array tables · ASCII heatmaps · fitness sparklines
mcp-tools/ arenaTools: MCPTool[] (the 6 tools above)
index.ts export surface + default { tools }
cli.ts human-facing CLI
Arena also ships as a function-hook mod (ADR-445 pattern; hooks in hooks/, loaded with the plugin). No network, no process, no model call.
/arena-mod: answered locally. /arena-mod status, /arena-mod strategies..claude-flow/arena-mod/status.json (version, updatedMs, counters), written at session start; the console reads it.Test: claude plugin validate plugins/ruflo-arena, claude plugin test plugins/ruflo-arena (its vitest suites are *.spec.ts, so the kit collects only tests/mod.test.ts), and bash plugins/ruflo-arena/scripts/smoke.sh.
hooks/register.ts 45 lines1import type { Hook, Register } from 'claude-code'
2
3import { answer } from './command'
4import { readOptions } from './options'
5import { newStats, STATUS_PATH, statusText, type Stats } from './status'
6import type { ModOptions } from './options'
7
8type Dollar = Parameters<Hook<'session.start'>>[0]
9
10/** Everything one session of the mod keeps: its settings, counters and the project root. */
11type Session = { readonly opts: ModOptions; readonly stats: Stats; root?: string }
12
13async function flush($: Dollar, s: Session): Promise<void> {
14 if (s.root === undefined) return
15 try {
16 await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, s.opts, await $.clock.now()))
17 } catch {
18 /* the status file is a courtesy */
19 }
20}
21
22/**
23 * Arena as a mod (ADR-445 pattern): `/arena-mod`, and a status file the console reads.
24 * No network, no process: only the hooks API.
25 */
26export const register: Register = (on, options) => {
27 const s: Session = { opts: readOptions(options), stats: newStats() }
28
29 on('session.start', async ($, e, next) => {
30 const result = await next(e)
31 s.root = (await $.session.root()) as string | undefined
32 s.stats.startedMs = await $.clock.now()
33 try {
34 await $.command.register({ name: 'arena-mod', description: 'Arena mod: status, strategies' })
35 } catch {
36 /* a name taken by another plugin must not stop the mod */
37 }
38 await flush($, s)
39 return result
40 })
41
42 /** `/arena-mod` (a markdown command of the plugin cannot be answered by a hook, so the mod owns this name). */
43 on('command.run', { command: 'arena-mod' }, async (_$, e) => ({ text: answer(typeof e.args === 'string' ? e.args : '', { opts: s.opts, stats: s.stats }) }))
44}
45hooks/command.ts 22 lines1import type { ModOptions } from './options'
2import type { Stats } from './status'
3
4/** `/arena-mod` is answered locally and takes no model turn. */
5export type CommandDeps = { readonly opts: ModOptions; readonly stats: Stats }
6
7const HELP = ['/arena-mod status', '/arena-mod strategies'].join('\n')
8
9export function answer(args: string, deps: CommandDeps): string {
10 const [verb = ''] = args.trim().split(/\s+/)
11
12 if (verb === '' || verb === 'help') return HELP
13
14 if (verb === 'status') {
15 return `Arena mod active · no tool is guarded`
16 }
17
18 if (verb === 'strategies') return 'Classic roster: tit-for-tat, always-cooperate, always-defect, grim, pavlov, alternate, random. Run one with /arena run --a <strategy> --b <strategy>, or /arena tournament.'
19
20 return `Unknown: ${verb}\n${HELP}`
21}
22hooks/options.ts 19 lines1import type { PluginOptions } from 'claude-code'
2
3/** The plugin's `userConfig`, validated: a bad value is the default (guard absent). */
4export type ModOptions = {
5 readonly guard: boolean
6}
7
8// BEGIN SHARED FLAG (generated by scripts/sync-mod-screen.mjs; edit plugins/ruflo-agentdb/hooks/options.ts)
9export const flag = (value: unknown, fallback: boolean) =>
10 value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
11// END SHARED FLAG
12
13export function readOptions(options: PluginOptions | undefined): ModOptions {
14 const o = options ?? {}
15 return {
16 guard: flag(o.guard, false),
17 }
18}
19hooks/status.ts 12 lines1/** Counters the mod keeps for the session and writes to `.claude-flow/arena-mod/status.json` for the console. */
2export type Stats = { calls: number; blocked: number; startedMs: number }
3
4export const newStats = (): Stats => ({ calls: 0, blocked: 0, startedMs: 0 })
5
6export const STATUS_PATH = '.claude-flow/arena-mod/status.json'
7
8/** The file's text; `version` lets the console refuse a shape it does not know. */
9export function statusText(stats: Stats, mode: { guard: boolean }, nowMs: number): string {
10 return `${JSON.stringify({ version: 1, updatedMs: nowMs, mod: 'arena', guard: mode.guard, ...stats }, null, 2)}\n`
11}
12