SLOPSHOPPER

signal-lance

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

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-07FluKanuck/Signal-Lance/mods/signal-lance
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · signal-lance
│ ┃ Signal Lance runner ✕ › fix the failing auth test and add an audit log call │ ┃ Signal Lance runner │ ┃ No run yet. Try: ⏺ Read(src/auth.ts) │ ┃ /sl-balance item ⎿ Read 6 lines │ ┃ mortar.mortar.shells=4,6,8 ⏺ Update(src/auth.ts) │ ┃ /sl-balance fit scout,line,brawler ⎿ Added 2 lines, removed 1 line │ ┃ /sl-balance tune MORTAR_DMG=1,2 --mission ⏺ Bash(bun test) │ ┃ bounty ⎿ 3 pass, 1 fail │ ┃ /sl-sim --contracts 60 │ ┃ Or ask Claude to balance something. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sl │ ⎿ signal-lance: No Signal Lance repo in this session. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Signal Lance runner
Signal Lance runner No run yet. Try: /sl-balance item mortar.mortar.shells=4,6,8 /sl-balance fit scout,line,brawler /sl-balance tune MORTAR_DMG=1,2 --mission bounty /sl-sim --contracts 60 Or ask Claude to balance something.
README

Signal Lance mod (Claude Code)

A band above the prompt, a live runner pane and headless balance sweeps, for Claude Code sessions opened in this repo.

Install

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.

What you get

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

CommandWhat it does
/slRefresh the band and open the runner pane
/sl-balance item mortar.mortar.shells=4,6,8Base vs each value of an item row field (--item)
/sl-balance tune MORTAR_DMG=1,2Base vs each value of a TUNE key (--set)
/sl-balance fit scout,line,brawlerHangar templates or build codes side by side (--fit)
/sl-sim --fit scout --mission bountyOne 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.

Needs

npm ci done in signal-lance/, and the runner flags --json, --item and --from (in scripts/sim.ts since 2026-10-06).

Source 3 files
hooks/register.tsx 278 lines
1import { 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}
278
hooks/logic.ts 182 lines
1// 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}
182
types/index.d.ts 20 lines
1export 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