Signal Lance: round band above the prompt, live runner pane, and headless balance sweeps

A band above the prompt, a live runner pane and headless balance sweeps, for Claude Code sessions opened in this repo.
In a Claude Code terminal session:
/plugin install signal-lance --marketplace FluKanuck/Signal-Lance
Answer y to add the marketplace, then pick the user scope. It also loads in the desktop app's Code tab once installed.
The band (above the prompt, refreshed after every turn): ◆ R18 Fit for the job ●●○ r18-s2 · next: THERMAL · main ✓ · ⚙ 120/240 [Runner] Round and title from the newest brief, checkpoint dots, the BUILD tag, the next step, branch and uncommitted files, and any run in progress.
Commands
| Command | What it does |
|---|---|
/sl | Refresh the band and open the runner pane |
/sl-balance item mortar.mortar.shells=4,6,8 | Base vs each value of an item row field (--item) |
/sl-balance tune MORTAR_DMG=1,2 | Base vs each value of a TUNE key (--set) |
/sl-balance fit scout,line,brawler | Hangar templates or build codes side by side (--fit) |
/sl-sim --fit scout --mission bounty | One live run with any runner flags |
Add --contracts N (default 60, about 0.3 s each) and any other runner flags to the end. Every variant plays the same seeds, three at a time.
The runner pane fills in as each contract finishes: win-rate bar per variant (green ≥60%, amber 40–60%, red below), its ±95% margin, the change against the base row (~ = within noise), contracts completed, suits lost per hunt, what found the lance first, and any runner FLAG lines. Stop, Run again and Copy summary buttons.
For Claude: balance starts a run and balance_results reads it, so you can just ask "is the Hot core too strong?" and Claude can run the sweep itself.
npm ci done in signal-lance/, and the runner flags --json, --item and --from (in scripts/sim.ts since 2026-10-06).
hooks/register.tsx 278 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { BandInfo, Job, Variant } from '../types'
5import {
6 argvFor, bandFrom, bar, delta, foldContract, margin, newJob, planFrom, simPlanFrom,
7 splitLines, summary, topChannels, winRate,
8} from './logic.ts'
9import type { Plan } from './logic.ts'
10
11const band = atom({ plugin: 'signal-lance', key: 'band' } as const, null)
12const job = atom({ plugin: 'signal-lance', key: 'job' } as const, null)
13const lastRun = atom({ plugin: 'signal-lance', key: 'lastRun' } as const, '')
14
15const PANE = 'sl-runner'
16const PARALLEL = 3 // runner processes at once
17const DEFAULT_CONTRACTS = 60
18
19let stop = false // module level: set by the Stop button; each variant's loop checks it and leaves (which kills its child)
20let running = false
21
22// ---------- where's the repo, and where does the round stand ----------
23
24async function findRoot($: any): Promise<string | null> {
25 const cwd: string = String(await $.session.cwd()).replace(/\\/g, '/') // Windows paths too
26 for (const dir of [cwd, cwd.replace(/\/signal-lance\/?$/i, ''), cwd.replace(/\/[^/]+\/?$/, '')]) {
27 if (await $.fs.exists(`${dir}/signal-lance/scripts/sim.ts`)) return dir
28 }
29 return null
30}
31
32async function git($: any, root: string, args: string[]) {
33 try { const r = await $.process.run(['git', '-C', root, ...args], { timeoutMs: 5000 }); return r.exitCode === 0 ? r.stdout.trim() : '' }
34 catch { return '' }
35}
36
37async function refreshBand($: any): Promise<BandInfo | null> {
38 const root = await findRoot($)
39 if (!root) { await update($, band, () => null); return null }
40 const screens = await $.fs.read(`${root}/signal-lance/src/view/screens.ts`).catch(() => '')
41 const docs = ((await $.fs.list(`${root}/claude`).catch(() => [])) as any[]).map(d => d.name as string)
42 const nums = docs.map(d => /^signal-lance-round(\d+)-brief\.md$/.exec(d)).filter(Boolean).map(x => Number(x![1]))
43 const brief = nums.length ? await $.fs.read(`${root}/claude/signal-lance-round${Math.max(...nums)}-brief.md`).catch(() => '') : ''
44 const [branch, status, head] = await Promise.all([
45 git($, root, ['rev-parse', '--abbrev-ref', 'HEAD']),
46 git($, root, ['status', '--porcelain']),
47 git($, root, ['log', '-1', '--format=%s']),
48 ])
49 const info = bandFrom({ screens, docs, brief, branch: branch || '?', dirty: status ? status.split('\n').length : 0, head, root })
50 await update($, band, () => info)
51 return info
52}
53
54// ---------- running the headless runner ----------
55
56async function setVariant($: any, i: number, fn: (v: Variant) => Variant) {
57 await update($, job, j => (j ? { ...j, variants: j.variants.map((v, k) => (k === i ? fn(v) : v)) } : j))
58}
59
60async function runVariant($: any, root: string, plan: Plan, i: number) {
61 await setVariant($, i, v => ({ ...v, status: 'running' }))
62 let carry = '', errText = ''
63 try {
64 const spec = plan.variants[i]
65 if (!spec) return
66 const child = $.process.spawn({ argv: argvFor(plan, spec), cwd: `${root}/signal-lance` })
67 for await (const { stream, text } of child) {
68 if (stop) break
69 if (stream === 'stderr') { errText = (errText + text).slice(-400); continue }
70 const cut = splitLines(carry, text); carry = cut.carry
71 const flags: string[] = [], tail: string[] = []
72 let v = (await read($, job))?.variants[i]
73 if (!v) return
74 for (const line of cut.lines) {
75 const folded = foldContract(v, line)
76 if (folded) { v = folded; continue }
77 if (/FLAG:|WARNING:/.test(line)) flags.push(`${spec.label}: ${line.trim()}`)
78 else if (line.trim() && !line.startsWith('>')) tail.push(`${spec.label} │ ${line.trim()}`)
79 }
80 const now = v
81 await update($, job, j => (j ? {
82 ...j,
83 variants: j.variants.map((x, k) => (k === i ? now : x)),
84 flags: [...j.flags, ...flags].slice(-12),
85 tail: [...j.tail, ...tail].slice(-40),
86 } : j))
87 }
88 await setVariant($, i, v => ({ ...v, status: stop ? 'stopped' : v.done ? 'done' : 'error', err: v.done ? undefined : errText.trim().split('\n').pop() }))
89 } catch (err: any) {
90 await setVariant($, i, v => ({ ...v, status: 'error', err: String(err?.message ?? err).slice(0, 200) }))
91 }
92}
93
94async function startJob($: any, plan: Plan): Promise<string> {
95 if (running) return 'A run is already going: watch the Signal Lance runner pane, or press Stop there first.'
96 const root = await findRoot($)
97 if (!root) return 'No Signal Lance repo here: open the session in the Signal-Lance clone (the folder that holds signal-lance/scripts/sim.ts).'
98 running = true; stop = false
99 await update($, job, () => newJob(plan, Date.now()))
100 await update($, lastRun, () => JSON.stringify(plan))
101 void $.ui.open({ id: PANE, title: 'Signal Lance runner' })
102 void (async () => {
103 try {
104 let nextI = 0
105 const worker = async () => { while (!stop && nextI < plan.variants.length) await runVariant($, root, plan, nextI++) }
106 await Promise.all(Array.from({ length: Math.min(PARALLEL, plan.variants.length) }, worker))
107 await update($, job, j => (j ? { ...j, finishedAt: Date.now(), variants: j.variants.map(v => (v.status === 'queued' ? { ...v, status: 'stopped' as const } : v)) } : j))
108 const j = await read($, job)
109 if (j) $.ui.toast(stop ? 'Signal Lance run stopped.' : `Signal Lance run done: ${j.title}`)
110 } finally { running = false }
111 })()
112 return `Started: ${plan.title} · ${plan.variants.length} variant${plan.variants.length > 1 ? 's' : ''} × ${plan.contracts} contracts. Results stream into the Signal Lance runner pane.`
113}
114
115
116export const register: Register = on => {
117 // ---------- commands and tools ----------
118
119 on('session.start', async ($, e, next) => {
120 await $.command.register({ name: 'sl', description: 'Signal Lance: refresh the round band and open the runner pane' })
121 await $.command.register({ name: 'sl-sim', description: 'Signal Lance: run the headless runner live (any runner flags)', argumentHint: '[--contracts 60] [--fit scout] [--mission bounty] …' })
122 await $.command.register({ name: 'sl-balance', description: 'Signal Lance: compare variants side by side, live', argumentHint: 'item mortar.mortar.shells=4,6,8 | tune KEY=a,b | fit scout,line,brawler [--contracts N]' })
123 await $.tool.register({
124 name: 'balance',
125 description: 'Start a Signal Lance headless balance run that streams live into the runner pane, then returns at once. kind "item" overrides an item row value (spec "row.path=a,b,c", e.g. "mortar.mortar.shells=4,6,8"); "tune" a TUNE value (spec "KEY=a,b"); "fit" compares hangar templates or build codes (spec "scout,line,brawler"); "sim" runs once with the extra flags. Item and tune runs include an unchanged base row. Same seeds per variant. Read results with balance_results.',
126 inputSchema: {
127 type: 'object',
128 properties: {
129 kind: { type: 'string', enum: ['item', 'tune', 'fit', 'sim'] },
130 spec: { type: 'string' },
131 contracts: { type: 'number', description: 'contracts per variant (default 60; ~0.3 s each)' },
132 extra: { type: 'array', items: { type: 'string' }, description: 'other runner flags, e.g. ["--mission", "bounty"]' },
133 },
134 required: ['kind'],
135 },
136 })
137 await $.tool.register({
138 name: 'balance_results',
139 description: 'The Signal Lance runner pane\'s current results: per variant win rate with its 95% margin, change against the base row and whether it clears the noise, contracts completed, suits lost per hunt, what found the lance first, runner flags, and whether it is still running.',
140 inputSchema: { type: 'object', properties: {} },
141 })
142 void refreshBand($)
143 return next(e)
144 })
145
146 on('command.run', { command: 'sl' }, async $ => {
147 const info = await refreshBand($)
148 await $.ui.open({ id: PANE, title: 'Signal Lance runner' })
149 return { text: info ? `R${info.round} ${info.title} · ${info.build} · next: ${info.next}` : 'No Signal Lance repo in this session.' }
150 })
151
152 on('command.run', { command: 'sl-sim' }, async ($, e) => {
153 return { text: await startJob($, simPlanFrom(e.args, DEFAULT_CONTRACTS)) }
154 })
155
156 on('command.run', { command: 'sl-balance' }, async ($, e) => {
157 const plan = planFrom(e.args, DEFAULT_CONTRACTS)
158 return { text: typeof plan === 'string' ? plan : await startJob($, plan) }
159 })
160
161 on('tool.call', { tool: 'mcp__signal-lance__balance' }, async ($, e) => {
162 const i = (e.input ?? {}) as any
163 const extra = [...(i.contracts ? ['--contracts', String(i.contracts)] : []), ...(Array.isArray(i.extra) ? i.extra.map(String) : [])].join(' ')
164 const plan = i.kind === 'sim' ? simPlanFrom(`${i.spec ?? ''} ${extra}`, DEFAULT_CONTRACTS) : planFrom(`${i.kind} ${i.spec ?? ''} ${extra}`, DEFAULT_CONTRACTS)
165 return { result: typeof plan === 'string' ? plan : await startJob($, plan) }
166 })
167
168 on('tool.call', { tool: 'mcp__signal-lance__balance_results' }, async $ => {
169 const j = await read($, job)
170 if (!j) return { result: 'No run yet.', isReadOnly: true as const }
171 return { result: summary(j) + (running ? '\n(still running)' : ''), isReadOnly: true as const }
172 })
173
174 // keep the band honest after each turn (git, BUILD and docs may have moved)
175 on('turn.complete', async ($, e, next) => {
176 void refreshBand($)
177 return next(e)
178 })
179
180 // ---------- the band above the prompt ----------
181
182 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
183 const info = await read($, band)
184 if (!info || e.props.hasSurvey) return next(e)
185 const j = await read($, job)
186 const { Box, Text, Button } = $.ui.resolve(e)
187 const wide = (e.props.bodyColumns ?? 100) >= 90
188 const dots = info.cpTotal ? '●'.repeat(info.cp) + '○'.repeat(Math.max(0, info.cpTotal - info.cp)) : ''
189 const prog = j ? j.variants.reduce((a, v) => a + v.done, 0) : 0
190 const total = j ? j.variants.reduce((a, v) => a + v.n, 0) : 0
191 const isLive = !!j && !j.finishedAt
192 return (
193 <Box flexDirection="row" flexWrap="wrap">
194 <Text color="claude" bold>◆ R{info.round}</Text>
195 {wide && info.title ? <Text> {info.title}</Text> : null}
196 {dots ? <Text color="success"> {dots}</Text> : null}
197 <Text dimColor> {info.build}</Text>
198 <Text> · next: </Text><Text color="warning">{info.next}</Text>
199 <Text dimColor> · {info.branch}</Text>
200 {info.dirty ? <Text color="warning"> ±{info.dirty}</Text> : <Text color="success"> ✓</Text>}
201 {j ? <Text color={isLive ? 'suggestion' : 'inactive'}> · {isLive ? '⚙' : '✓'} {prog}/{total}</Text> : null}
202 <Text> </Text>
203 <Button key="runner" label="Runner" plain onPress={() => { void $.ui.open({ id: PANE, title: 'Signal Lance runner' }) }} />
204 </Box>
205 )
206 })
207
208 // ---------- the runner pane ----------
209
210 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
211 const { Box, Text, Button } = $.ui.resolve(e)
212 const j = await read($, job)
213 const cols = e.props.bodyColumns ?? 60
214 if (!j) {
215 return (
216 <Box flexDirection="column">
217 <Text bold>Signal Lance runner</Text>
218 <Text dimColor>No run yet. Try:</Text>
219 <Text> /sl-balance item mortar.mortar.shells=4,6,8</Text>
220 <Text> /sl-balance fit scout,line,brawler</Text>
221 <Text> /sl-balance tune MORTAR_DMG=1,2 --mission bounty</Text>
222 <Text> /sl-sim --contracts 60</Text>
223 <Text dimColor>Or ask Claude to balance something.</Text>
224 </Box>
225 )
226 }
227 const base = j.variants[0]
228 const isLive = !j.finishedAt
229 const secs = Math.round(((j.finishedAt ?? Date.now()) - j.startedAt) / 1000)
230 const barW = Math.max(6, Math.min(20, cols - 46))
231 const labelW = Math.min(16, Math.max(5, ...j.variants.map(v => v.label.length)))
232 const room = Math.max(3, (e.viewport?.rows ?? 30) - 10 - j.variants.length * 2 - j.flags.length)
233 return (
234 <Box flexDirection="column">
235 <Text bold>{j.title}</Text>
236 <Text dimColor>{j.contracts} contracts each, same seeds · {isLive ? `running ${secs}s` : `finished in ${secs}s`}</Text>
237 <Text> </Text>
238 {j.variants.map((v, k) => {
239 const w = winRate(v), d = delta(v, base)
240 const winColor = !v.hunts ? 'inactive' : w >= 0.6 ? 'success' : w >= 0.4 ? 'warning' : 'error'
241 const dColor = !d ? 'inactive' : !d.isReal ? 'inactive' : d.pts > 0 ? 'success' : 'error'
242 const st = v.status === 'running' ? '⚙' : v.status === 'done' ? '✓' : v.status === 'error' ? '✗' : v.status === 'stopped' ? '■' : '·'
243 return (
244 <Box key={`v${k}`} flexDirection="column">
245 <Box flexDirection="row">
246 <Text color={v.status === 'error' ? 'error' : v.status === 'running' ? 'suggestion' : 'inactive'}>{st} </Text>
247 <Text bold={k === 0}>{v.label.padEnd(labelW).slice(0, labelW)} </Text>
248 <Text color={winColor}>{bar(w, barW)} {String(Math.round(100 * w)).padStart(3)}%</Text>
249 <Text dimColor> ±{margin(v)}</Text>
250 {d ? <Text color={dColor}> {d.pts >= 0 ? '+' : ''}{d.pts}{d.isReal ? '' : '~'}</Text> : <Text> </Text>}
251 <Text dimColor> {v.done}/{v.n}</Text>
252 </Box>
253 <Text dimColor>
254 {' '}{v.status === 'error' ? (v.err || 'runner failed') : `complete ${v.complete}/${v.done} · lost ${(v.lost / Math.max(1, v.hunts)).toFixed(2)}/hunt${topChannels(v) ? ` · found by ${topChannels(v)}` : ''}`}
255 </Text>
256 </Box>
257 )
258 })}
259 {j.flags.length ? <Text> </Text> : null}
260 {j.flags.map((f, k) => <Text key={`f${k}`} color="warning">⚑ {f}</Text>)}
261 <Text> </Text>
262 <Text dimColor>~ = within noise (95%). Colour: win ≥60% green, 40–60% amber, below red.</Text>
263 {j.tail.slice(-room).map((t, k) => <Text key={`t${k}`} dimColor wrap="truncate-end">{t}</Text>)}
264 <Box flexDirection="row">
265 {isLive
266 ? <Button key="stop" label="Stop" onPress={() => { stop = true }} />
267 : <Button key="again" label="Run again" onPress={async () => {
268 const p = await read($, lastRun)
269 if (p) await startJob($, JSON.parse(p) as Plan)
270 }} />}
271 <Text> </Text>
272 <Button key="copy" label="Copy summary" onPress={async () => { const now = await read($, job); if (now) await $.ui.copy({ text: summary(now), surface: e.surface }) }} />
273 </Box>
274 </Box>
275 )
276 })
277}
278hooks/logic.ts 182 lines1// Pure helpers for the Signal Lance mod: no `$`, so the tests can call them directly.
2import type { BandInfo, Job, Variant } from '../types'
3
4// ---------- the band: where the round stands ----------
5
6/** Reads the round, checkpoint and next step from the BUILD tag, the newest brief and whether its report exists. */
7export function bandFrom(input: {
8 screens: string // src/view/screens.ts
9 docs: string[] // file names in claude/
10 brief: string // the newest round brief's text ('' if none)
11 branch: string
12 dirty: number
13 head: string // last commit subject
14 root: string
15}): BandInfo {
16 const m = /BUILD\s*=\s*'(r(\d+)-s(\d+))'/.exec(input.screens)
17 const build = m?.[1] ?? '?'
18 const buildRound = m ? Number(m[2]) : 0
19 const step = m ? Number(m[3]) : 0
20 const briefs = input.docs.map(d => /^signal-lance-round(\d+)-brief\.md$/.exec(d)).filter(Boolean).map(x => Number(x![1] ?? 0))
21 const round = briefs.length ? Math.max(...briefs) : buildRound
22 const hasReport = input.docs.includes(`signal-lance-round${round}.md`)
23 const title = /—\s*"([^"]+)"/.exec(input.brief.split('\n')[0] || '')?.[1] ?? ''
24 const cps = [...input.brief.matchAll(/^###\s*Checkpoint\s*(\d+):\s*(.+)$/gm)].map(x => ({ n: Number(x[1] ?? 0), name: tidy(x[2] ?? '') }))
25 const cpTotal = cps.length
26 const cp = buildRound === round ? Math.min(step, cpTotal || step) : 0
27 let next: string
28 if (hasReport) next = 'round done: design lead chat'
29 else if (buildRound < round) next = cps[0] ? `start ${cps[0].name}` : 'start the build'
30 else if (cpTotal && cp < cpTotal) next = cps.find(c => c.n === cp + 1)?.name ?? `checkpoint ${cp + 1}`
31 else next = 'debrief + report'
32 return { round, title, build, cp, cpTotal, next, branch: input.branch, dirty: input.dirty, head: input.head, root: input.root }
33}
34
35/** "THERMAL (Jamie plays)" -> "THERMAL"; "the suit budget (Jamie plays)" -> "the suit budget". */
36function tidy(s: string) { return s.replace(/\s*\(.*?\)\s*/g, ' ').trim() }
37
38// ---------- balance specs ----------
39
40export type Plan = { title: string; variants: { label: string; args: string[] }[]; contracts: number; extra: string[] }
41
42/**
43 * Turns what was typed after /sl-balance into runner variants. Forms:
44 * item mortar.mortar.shells=4,6,8 base + one variant per value (--item)
45 * tune MORTAR_DMG=1,2 base + one variant per value (--set)
46 * fit scout,line,brawler one variant per template or build code (--fit)
47 * Anything after the spec is passed to every variant (e.g. --contracts 60 --mission bounty).
48 */
49export function planFrom(text: string, defaultContracts = 40): Plan | string {
50 const words = text.trim().split(/\s+/).filter(Boolean)
51 if (words.length < 2) return 'Usage: /sl-balance item <row.path>=a,b,c | tune <KEY>=a,b | fit a,b,c [--contracts N] [other runner flags]'
52 const [kind = '', spec = '', ...rest] = words
53 let contracts = defaultContracts
54 const extra: string[] = []
55 for (let i = 0; i < rest.length; i++) {
56 const w = rest[i] ?? ''
57 if (w === '--contracts') { contracts = Number(rest[++i]) || defaultContracts; continue }
58 if (w === '--json' || w === '--check') continue
59 extra.push(w)
60 }
61 if (kind === 'fit') {
62 const fits = spec.split(',').filter(Boolean)
63 if (!fits.length) return 'fit: name at least one template or build code, e.g. fit scout,line,brawler'
64 return { title: `fits: ${fits.join(' vs ')}`, contracts, extra, variants: fits.map(f => ({ label: f, args: ['--fit', f] })) }
65 }
66 if (kind === 'item' || kind === 'tune') {
67 const eq = spec.indexOf('=')
68 if (eq < 1) return `${kind}: write it as ${kind === 'item' ? 'row.path=a,b,c (e.g. mortar.mortar.shells=4,6,8)' : 'KEY=a,b (e.g. MORTAR_DMG=1,2)'}`
69 const path = spec.slice(0, eq), values = spec.slice(eq + 1).split(',').filter(Boolean)
70 if (!values.length) return `${kind}: give at least one value after =`
71 const flag = kind === 'item' ? '--item' : '--set'
72 const short = path.split('.').pop()!
73 return {
74 title: `${kind} ${path}`, contracts, extra,
75 variants: [{ label: 'base', args: [] }, ...values.map(v => ({ label: `${short}=${v}`, args: [flag, `${path}=${v}`] }))],
76 }
77 }
78 return `Unknown kind "${kind}": use item, tune or fit.`
79}
80
81/** /sl-sim: one variant with whatever runner flags were typed. */
82export function simPlanFrom(text: string, defaultContracts = 40): Plan {
83 const words = text.trim().split(/\s+/).filter(Boolean)
84 let contracts = defaultContracts
85 const extra: string[] = []
86 for (let i = 0; i < words.length; i++) {
87 const w = words[i] ?? ''
88 if (w === '--contracts') { contracts = Number(words[++i]) || defaultContracts; continue }
89 if (w === '--json' || w === '--check') continue
90 extra.push(w)
91 }
92 return { title: `sim ${extra.join(' ') || '(defaults)'}`, contracts, extra, variants: [{ label: 'run', args: [] }] }
93}
94
95export function argvFor(plan: Plan, v: { args: string[] }) {
96 return ['node', '--experimental-strip-types', '--no-warnings', 'scripts/sim.ts',
97 '--contracts', String(plan.contracts), ...plan.extra, ...v.args, '--json']
98}
99
100export function newJob(plan: Plan, now: number): NonNullable<Job> {
101 return {
102 title: plan.title, contracts: plan.contracts, startedAt: now, tail: [], flags: [],
103 variants: plan.variants.map(v => ({ label: v.label, args: v.args, n: plan.contracts, done: 0, wins: 0, hunts: 0, complete: 0, lost: 0, chan: {}, status: 'queued' })),
104 }
105}
106
107// ---------- reading the runner's output ----------
108
109/** Splits streamed text into whole lines, keeping the unfinished end for the next piece. */
110export function splitLines(carry: string, text: string): { lines: string[]; carry: string } {
111 const all = (carry + text).split(/\r?\n/)
112 return { carry: all.pop() ?? '', lines: all }
113}
114
115/** Folds one '@@SL {...}' contract line into a variant. Returns null for any other line. */
116export function foldContract(v: Variant, line: string): Variant | null {
117 if (!line.startsWith('@@SL ')) return null
118 let c: any
119 try { c = JSON.parse(line.slice(5)) } catch { return null }
120 const hunts: any[] = Array.isArray(c.hunts) ? c.hunts : []
121 const chan = { ...v.chan }
122 for (const h of hunts) for (const f of h.found ?? []) if (f) { const k = String(f.ch).split(' ')[0] ?? '?'; chan[k] = (chan[k] ?? 0) + 1 }
123 return {
124 ...v,
125 done: v.done + 1,
126 hunts: v.hunts + hunts.length,
127 wins: v.wins + hunts.filter(h => String(h.outcome).startsWith('WIN')).length,
128 lost: v.lost + hunts.reduce((a, h) => a + (Number(h.lost) || 0), 0),
129 complete: v.complete + (c.status === 'COMPLETE' ? 1 : 0),
130 chan,
131 }
132}
133
134// ---------- numbers for the pane ----------
135
136export const winRate = (v: Variant) => (v.hunts ? v.wins / v.hunts : 0)
137
138/** 95% margin on a win rate, in points: below it, a difference is noise. */
139export function margin(v: Variant) {
140 if (!v.hunts) return 0
141 const p = winRate(v)
142 return Math.round(196 * Math.sqrt(Math.max(p * (1 - p), 0.0625) / v.hunts))
143}
144
145/** Change against the base row in points, and whether it clears both rows' noise. */
146export function delta(v: Variant, base: Variant | undefined): { pts: number; isReal: boolean } | null {
147 if (!base || base === v || !v.hunts || !base.hunts) return null
148 const pts = Math.round(100 * (winRate(v) - winRate(base)))
149 const noise = Math.sqrt(margin(v) ** 2 + margin(base) ** 2)
150 return { pts, isReal: Math.abs(pts) > noise }
151}
152
153export function bar(frac: number, width: number) {
154 const n = Math.max(0, Math.min(width, Math.round(frac * width)))
155 return '█'.repeat(n) + '░'.repeat(width - n)
156}
157
158/** Top channels that found the lance first, as "eyes 41% SND 30%". */
159export function topChannels(v: Variant, k = 2) {
160 const total = Object.values(v.chan).reduce((a, b) => a + b, 0)
161 if (!total) return ''
162 return Object.entries(v.chan).sort((a, b) => b[1] - a[1]).slice(0, k).map(([c, n]) => `${c} ${Math.round(100 * n / total)}%`).join(' ')
163}
164
165/** One-line summary per variant, for the tool's answer and the transcript. */
166export function summary(job: NonNullable<Job>) {
167 const base = job.variants[0]
168 if (!base) return job.title
169 return [
170 `${job.title} · ${job.contracts} contracts each (same seeds)`,
171 ...job.variants.map(v => {
172 const d = delta(v, base)
173 return `${v.label}: win ${Math.round(100 * winRate(v))}% ±${margin(v)} over ${v.hunts} hunts` +
174 (d ? ` (${d.pts >= 0 ? '+' : ''}${d.pts} vs ${base.label}${d.isReal ? '' : ', within noise'})` : '') +
175 ` · contracts complete ${v.complete}/${v.done} · lost ${(v.lost / Math.max(1, v.hunts)).toFixed(2)}/hunt` +
176 (topChannels(v) ? ` · first found by ${topChannels(v)}` : '') +
177 (v.status === 'error' ? ` · ERROR ${v.err ?? ''}` : v.status !== 'done' ? ` · ${v.status} ${v.done}/${v.n}` : '')
178 }),
179 ...(job.flags.length ? ['Runner flags: ' + job.flags.join(' | ')] : []),
180 ].join('\n')
181}
182types/index.d.ts 20 lines1export type BandInfo = {
2 round: number; title: string; build: string; cp: number; cpTotal: number; next: string
3 branch: string; dirty: number; head: string; root: string
4}
5export type Variant = {
6 label: string; args: string[]; n: number; done: number; wins: number; hunts: number
7 complete: number; lost: number; chan: Record<string, number>
8 status: 'queued' | 'running' | 'done' | 'error' | 'stopped'; err?: string
9}
10export type Job = {
11 title: string; contracts: number; startedAt: number; finishedAt?: number
12 variants: Variant[]; tail: string[]; flags: string[]
13} | null
14
15declare module 'claude-code' {
16 interface PluginState {
17 'signal-lance': { band: BandInfo | null; job: Job; lastRun: string }
18 }
19}
20