Files
OwnCord/.claude/workflows/bughunt.js
T

825 lines
45 KiB
JavaScript

export const meta = {
name: 'bughunt',
description: 'Converging multi-round bug hunt: rotating lens families, single opus finder, opus refute-by-default verification, dry-threshold stop',
whenToUse: 'Hunting real bugs across the Go server, Tauri Rust backend, and TS client until consecutive rounds go dry. Not a security-only scan.',
phases: [
{ title: 'Recon', detail: 'haiku: churn + concurrency-surface inventory' },
],
}
// ---------- config ----------
// args may arrive JSON-stringified (observed in run wf_9199e623-b83: maxRounds:1 never took) - coerce
const ARGS = (() => {
if (typeof args === 'string') {
try { return JSON.parse(args) || {} } catch { return {} }
}
return args || {}
})()
const MAX_ROUNDS = ARGS.maxRounds || 30
const DRY_THRESHOLD = ARGS.dryThreshold || 2
// A scoped hunt (args.lenses) replaces the round-1 family outright; later rounds still go
// adaptive, so hotspot and explore coverage - and therefore convergence - still work.
const CUSTOM_LENSES = Array.isArray(ARGS.lenses) && ARGS.lenses.length ? ARGS.lenses : null
// Floor for one round. The single opus finder (sonnet retired 2026-08-12) costs ~100-260k per
// round, measured across the 8-round 2026-08-13 run. The old 2M floor was a dual-finder-era
// anchor (~2.6M/round) that would zero-out any hunt launched with a budget under 2M - now that
// budgetTotal is a first-class arg, that cliff is a foot-gun. 600k is ~3x a measured round.
const ROUND_BUDGET_FLOOR = 600000
// The turn directive failed to arm budget.total on the 2026-08-13 live run (+25M present,
// total still null), so args.budgetTotal is the deterministic fallback. budget.spent()
// works even when total is null; budget.remaining() stays authoritative when the
// directive DID arm, because stubs (and the runtime) may track it statefully.
const BUDGET_TOTAL = budget.total || Number(ARGS.budgetTotal) || null
const remainingBudget = () => (budget.total ? budget.remaining() : BUDGET_TOTAL ? Math.max(0, BUDGET_TOTAL - budget.spent()) : Infinity)
// The args channel has already been observed delivering something the script
// could not read; an unnoticed fallback here is an 8x cost surprise, so say out
// loud what the run is actually going to do.
log(`config: maxRounds=${MAX_ROUNDS} dryThreshold=${DRY_THRESHOLD}${CUSTOM_LENSES ? ` lenses=custom(${CUSTOM_LENSES.length})` : ''} budget=${BUDGET_TOTAL ? Math.round(BUDGET_TOTAL / 1e6) + 'M' : 'NONE - cost ceiling disarmed'}`)
// ---------- schemas: copied VERBATIM from the current bughunt.js ----------
const FINDINGS = {
type: 'object',
required: ['findings'],
properties: {
findings: {
type: 'array',
items: {
type: 'object',
required: ['title', 'file', 'line', 'severity', 'why', 'repro'],
properties: {
title: { type: 'string' },
file: { type: 'string', description: 'repo-relative path' },
line: { type: 'integer' },
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
why: { type: 'string', description: 'the defect, one or two sentences' },
repro: { type: 'string', description: 'concrete inputs/interleaving -> wrong behavior' },
evidence: { type: 'string', description: 'the code lines that prove it' },
},
},
},
},
}
const VERDICTS = {
type: 'object',
required: ['verdicts'],
properties: {
verdicts: {
type: 'array',
items: {
type: 'object',
required: ['title', 'file', 'line', 'refuted', 'reason', 'confidence', 'severity'],
properties: {
title: { type: 'string' },
file: { type: 'string' },
line: { type: 'integer' },
refuted: { type: 'boolean' },
reason: { type: 'string', description: 'what refutes it, or what confirms it in the code' },
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
fix: { type: 'string', description: 'smallest correct fix, if confirmed' },
},
},
},
},
}
// ---------- rules ----------
const RULES = `
Repo: OwnCord, checked out at your current working directory (the repo root - do not assume any absolute
path; run every command from there and use repo-relative paths). Go 1.26 server in Server/, Tauri v2 client in Client/
(Rust in src-tauri/src/, TypeScript in src/lib/ and src/stores/).
You are hunting REAL BUGS: wrong behavior, not style. In scope:
- logic errors, off-by-one, wrong operator, inverted condition, wrong default
- concurrency: data races, deadlocks, lock-order inversion, missed wakeups, goroutine leaks, TOCTOU
- lifecycle: use-after-close, double-close, nil deref on error paths, leaked resources/listeners/timers
- state machines that can reach an unintended state, or desync between two sources of truth
- error paths that silently swallow, lose data, or leave partial writes
- auth/authz checks reading stale state, or missing on one path while present on siblings
Out of scope, do not report: naming, formatting, missing tests, "consider adding", speculative hardening,
performance that is not a hang, anything you cannot point at specific lines for.
Method:
1. Read the actual files. Never report from a filename, a grep hit, or a graph edge alone - a
graphify edge is structural evidence of coupling, not of a bug; open the cited file and confirm.
2. For every candidate, grep for ALL callers before judging - a guard may already live upstream.
3. Check whether an existing test already locks the behavior you think is wrong. If a test asserts it,
it is intended behavior, not a bug. Test files are *_test.go and tests/unit/*.test.ts.
4. Report EVERY finding you can prove - there is no cap. The quality bar stays: zero findings is a
valid, respectable answer, and each finding needs file, line, and a concrete repro.
You may run read-only shell commands (grep, git log, go doc, graphify path, graphify explain).
Do not modify any file. Do not run the test suite.
`
// ---------- lens catalog ----------
// keys must match /^[a-z0-9-]+$/ - they are embedded in agent labels the harness parses.
const SURFACE_LENSES = [
{
key: 'ws-hub',
prompt:
`Surface: the WebSocket hub and its client lifecycle. Files: Server/ws/*.go (skip *_test.go) - start with ` +
`client.go, hub*.go, emit.go, event.go, event_persister.go, event_pruner.go, handlers*.go, command.go.\n\n` +
`Hunt specifically for: send on closed channel; write to a client after unregister; hub map mutated without ` +
`the right lock held; lock ordering between hub and client; a goroutine that outlives its client; ` +
`read-pump/write-pump shutdown races; events emitted to a client mid-unregister; event ordering that can ` +
`invert under concurrent publish; pruner racing the persister over the same rows.\n` +
`Trace at least one full connect -> subscribe -> emit -> disconnect path end to end before reporting anything.`,
},
{
key: 'voice-e2ee',
prompt:
`Surface: voice/video E2EE key lifecycle, spanning three languages. Files: Server/ws/handler_v2_voice*.go and ` +
`any Server/ws/*voice*.go or *e2ee*.go; Client/src/lib/e2eeCrypto.ts, livekitE2EE.ts, ` +
`livekitSession.ts, identity.ts; Client/src-tauri/src/tofu.rs, secret_store.rs, fallback_crypto.rs, dpapi.rs.\n\n` +
`Hunt specifically for: a key-rotation window where a participant can decrypt after they should be excluded; ` +
`TOFU pin re-check that reads state captured before a rotation (time-of-check/time-of-use); a participant ` +
`joining mid-rotation getting the wrong epoch key; key material outliving the session; an error path that ` +
`falls back to unencrypted or to a zeroed/default key; sender/receiver epoch disagreement after reconnect.\n` +
`This area was hardened before - check git log for the relevant commits and do NOT re-report anything already fixed.`,
},
{
key: 'api-authz',
prompt:
`Surface: REST API auth and authorization. Files: Server/api/*.go (skip *_test.go), Server/auth/*.go, ` +
`Server/permissions/*.go.\n\n` +
`Hunt specifically for: a permission checked against a snapshot that can go stale before it is used; ` +
`a handler that checks channel access but not server/guild access (or vice versa); an ID taken from the ` +
`request body when it should come from the session; sibling handlers where one path has a guard and a ` +
`near-identical one does not; rate limiter keyed on something the caller controls; role/override resolution ` +
`that returns allow on error instead of deny.\n` +
`Compare handlers against each other - the strongest signal here is inconsistency between siblings.`,
},
{
key: 'db-storage',
prompt:
`Surface: persistence. Files: Server/db/*.go (NOT db/dbgen/, that is generated), Server/db/queries/*.sql, ` +
`Server/migrations/*.sql, Server/storage/*.go, Server/service/*.go.\n\n` +
`Hunt specifically for: a multi-statement operation that is not in one transaction and can leave partial state; ` +
`a tx that can be committed twice or leaked without rollback on an early return; sql.ErrNoRows treated as a ` +
`real error or swallowed as success; a query whose SQL semantics disagree with what the caller assumes ` +
`(LIMIT, ordering, NULL handling, JOIN dropping rows); a migration that is not idempotent or that breaks ` +
`an older row shape; unbounded result sets read fully into memory.\n` +
`Read the .sql alongside its Go caller - the bug is usually the gap between them.`,
},
{
key: 'tauri-rust',
prompt:
`Surface: the Tauri Rust backend. Files: Client/src-tauri/src/*.rs.\n\n` +
`Hunt specifically for: a panic reachable from a Tauri command (unwrap/expect on attacker- or ` +
`environment-controlled input) - a panic here can take down the app; a lock held across .await; ` +
`state in tauri::State mutated from two commands without coordination; the http_proxy / livekit_proxy / ` +
`ws_proxy forwarding a header, URL, or origin it should filter; credentials/secret_store material logged, ` +
`left in memory, or written unencrypted on a fallback path; ptt.rs global hook not released on shutdown.\n` +
`For each panic you find, state exactly which input reaches it.`,
},
{
key: 'client-state',
prompt:
`Surface: TypeScript client state and event handling. Files: Client/src/lib/*.ts and ` +
`src/stores/*.ts - prioritize dispatcher.ts, reconcile.ts, read-state.ts, router.ts, roomEventHandlers.ts, ` +
`navigation-guard.ts, rate-limiter.ts, channel-navigation.ts, and whatever the churn recon flagged.\n\n` +
`Hunt specifically for: a listener/interval/observer registered without a matching teardown (check ` +
`disposable.ts for the intended pattern and find who bypasses it); reconcile logic that drops or duplicates ` +
`an entity when events arrive out of order; read-state that can mark unread messages read, or lose an unread ` +
`count, across a reconnect; an async handler whose await lets stale state be written after a newer update ` +
`(last-write-wins race); a route guard bypassable by a rapid navigation sequence.\n` +
`Check tests/unit/ before reporting - much of this behavior is already test-locked.`,
},
]
const BUGCLASS_LENSES = [
{
key: 'concurrency',
prompt:
`Bug class: concurrency and interleaving - sweep the whole repo for THIS CLASS ONLY.\n` +
`Go (Server/): data races on maps/slices/fields shared between goroutines; lock-order inversion; ` +
`missed wakeups; TOCTOU between a check and its use; goroutines racing shutdown; send on closed channel.\n` +
`Rust (src-tauri/src/): a lock held across .await; tauri::State mutated from two commands without ` +
`coordination; Arc<Mutex<_>> cloned into tasks that outlive their owner.\n` +
`TS (src/lib/, src/stores/): two async handlers interleaving on the same store (last-write-wins after ` +
`an await); a stale closure writing state after a newer update already landed.\n` +
`Use the recon concurrency-surface inventory to pick files. For every candidate, name the exact interleaving.`,
},
{
key: 'lifecycle',
prompt:
`Bug class: lifecycle and teardown - sweep the whole repo for THIS CLASS ONLY.\n` +
`Every acquire must have a matching release on EVERY exit path: goroutines outliving their owner; ` +
`timers/intervals/listeners/workers registered without removal (client disposable.ts is the intended ` +
`pattern - find who bypasses it); double-close and use-after-close; teardown-order mistakes; ` +
`Rust Drop not running (mem::forget, leaked handles, the ptt.rs global hook); ` +
`partial teardown when an error interrupts the happy path halfway.`,
},
{
key: 'state-desync',
prompt:
`Bug class: two sources of truth drifting - sweep the whole repo for THIS CLASS ONLY.\n` +
`Pairs to audit: hub client maps vs pubsub registrations; server voice state vs LiveKit vs client ` +
`stores; client read-state vs server acked sequence numbers; DB rows vs in-memory caches; ` +
`any two structures updated by different code paths. Find the path that updates one and not the ` +
`other - reconnect, replacement, and error paths are where they diverge.`,
},
{
key: 'error-paths',
prompt:
`Bug class: error-path data loss - sweep the whole repo for THIS CLASS ONLY.\n` +
`Swallowed errors (err assigned and ignored, empty catch, unwrap_or(default) hiding failure); ` +
`partial writes left behind on early return; fallbacks that silently degrade to wrong behavior; ` +
`an error mapped to success upstream; cleanup skipped when the happy path is interrupted mid-way. ` +
`Read every 'if err != nil', catch block, and .catch in the hot files from recon.`,
},
{
key: 'ordering-boundary',
prompt:
`Bug class: ordering and boundaries - sweep the whole repo for THIS CLASS ONLY.\n` +
`Off-by-one and fence-post errors; LIMIT/pagination silently truncating; sequence-number gaps, ` +
`duplication, or inversion between assignment and delivery; sort-stability and tie assumptions; ` +
`first/last/empty-collection special cases; inclusive-vs-exclusive range disagreements between a ` +
`caller and its callee (read the SQL alongside its Go caller).`,
},
]
const FLOW_LENSES = [
{
key: 'flow-reconnect',
prompt:
`Flow: WebSocket drop -> reconnect -> resume. Trace it END TO END across all three languages before ` +
`reporting anything. Server: the serve handshake/resume path, hub client replacement and state ` +
`transfer (this transfer has needed four separate fixes: unsubscribe identity, VoiceTopic+E2EE key ` +
`transfer, focused-channel transfer, closeSend ordering - hunt for what it STILL misses), topic ` +
`re-subscription, cold/warm replay tiers. Client: the reconnect loop, seq ack tracking, store ` +
`reconcile after resume. Report any state that exists on the old connection and does not provably ` +
`reach the new one.`,
},
{
key: 'flow-voice',
prompt:
`Flow: voice join -> E2EE key announce/offer -> key-holder election -> rotation -> participant ` +
`leave -> LiveKit webhook -> cleanup. Trace it END TO END: Server/ws/*voice*, livekit_webhook.go, ` +
`client livekitE2EE.ts and livekitSession.ts, Rust livekit_proxy.rs. Hunt for: a participant who can ` +
`still decrypt after they should be excluded; holder-election stalls; epoch/key disagreement after ` +
`reconnect; the three take-out-of-voice paths (webhook, sweep, voice_leave) diverging.`,
},
{
key: 'flow-message',
prompt:
`Flow: message send -> permission gate -> persist -> sequence assign -> fan-out -> replay tiers -> ` +
`client store -> read-state/unread counts. Trace it END TO END and hunt the gaps BETWEEN layers: ` +
`persisted but never fanned out; delivered but sequence-skipped; acked via max(seq) while a lower ` +
`seq was dropped; unread counts drifting from actual unread messages across reconnect or channel switch.`,
},
{
key: 'flow-session',
prompt:
`Flow: login -> session/token issue -> per-connection auth -> revocation/sweep -> kick -> API-token ` +
`paths. Trace it END TO END and hunt stale-authorization windows: state checked at connect but not ` +
`re-checked at use; revocation that kicks the WS but leaves another surface authorized; the sweep ` +
`racing an in-flight request; API tokens diverging from session-token semantics on any path.`,
},
]
let riskySweepDone = false
function riskySweepLenses() {
if (riskySweepDone || !HAS_INVENTORY || !RISKY_FILES.length || uncoveredCount() > 0) return null
riskySweepDone = true // consumed even if this round's finders die: same at-most-once semantics as cooldown
const list = RISKY_FILES.map((f) => ` - ${f}`).join('\n')
return BUGCLASS_LENSES.map((l) => ({
key: `risky-${l.key}`,
prompt: `${l.prompt}\n\nScope this sweep to ONLY these highest-risk files (read each one in full):\n${list}`,
}))
}
let currentFamilyName = 'surfaces'
function lensesForRound(round) {
const pick = (name, lenses) => { currentFamilyName = name; return lenses }
if (CUSTOM_LENSES) {
if (round === 1) return pick('custom', CUSTOM_LENSES)
} else {
if (round === 1) return pick('surfaces', SURFACE_LENSES)
if (round === 2) return pick('bug-classes', BUGCLASS_LENSES)
if (round === 3) return pick('flows', FLOW_LENSES)
}
const risky = riskySweepLenses()
if (risky) return pick('risky-sweep', risky)
return pick('adaptive', buildAdaptiveLenses(round))
}
function familyName() { return currentFamilyName }
// Directory granularity: the old two/three-segment cluster collapsed the whole TS client into
// one bucket (35 of 82 findings), so the "top cluster" never changed for five straight rounds.
function clusterOf(file) {
const parts = String(file).split('/')
return parts.length > 1 ? parts.slice(0, -1).join('/') : parts[0]
}
// ---------- explore targeting ----------
// args.graph: session-computed coupling ranking (rank-explore.mjs). The workflow only reads
// .file - scoring already happened outside, where the filesystem is.
const GRAPH_ROWS = (Array.isArray(ARGS.graph) ? ARGS.graph : []).filter((r) => r && typeof r.file === 'string')
// ---------- coverage mode (spec 2026-08-20) ----------
// Arms only when rows carry the `examined` flag (full inventory from rank-explore.mjs).
// Legacy rows and the churn fallback leave all of this inert: covered stays empty,
// uncoveredCount() is 0, and the loop condition reduces to the old dry-threshold rule.
const HAS_INVENTORY = GRAPH_ROWS.some((r) => 'examined' in r)
const INVENTORY = HAS_INVENTORY ? GRAPH_ROWS.map((r) => r.file) : []
const covered = new Set(HAS_INVENTORY ? GRAPH_ROWS.filter((r) => r.examined).map((r) => r.file) : [])
const PRE_COVERED = covered.size
const RISKY_FILES = HAS_INVENTORY ? GRAPH_ROWS.filter((r) => r.risky).map((r) => r.file) : []
const uncoveredCount = () => (HAS_INVENTORY ? INVENTORY.reduce((n, f) => n + (covered.has(f) ? 0 : 1), 0) : 0)
if (HAS_INVENTORY) log(`coverage: inventory=${INVENTORY.length} preCovered=${PRE_COVERED} risky=${RISKY_FILES.length}`)
const EXPLORE_FILES_PER_LENS = 10
const exploreConsumed = new Set() // within-run consumption: never re-offer a file to a later round
let exploreFallbackLogged = false
function drawExploreFiles() {
let pool
if (GRAPH_ROWS.length) pool = GRAPH_ROWS.map((r) => r.file)
else {
if (!exploreFallbackLogged) {
log('explore: args.graph absent/empty - falling back to churn-based fresh eyes')
exploreFallbackLogged = true
}
pool = churnFiles
}
const avail = pool.filter((f) => !exploreConsumed.has(f) && !covered.has(f) && !seen.some((s) => s.file === f))
const files = []
while (files.length < EXPLORE_FILES_PER_LENS && avail.length) {
const head = avail.shift()
files.push(head)
const dir = clusterOf(head)
// pull same-directory siblings forward: one lens reading one module beats ten strangers
for (let i = 0; i < avail.length && files.length < EXPLORE_FILES_PER_LENS; ) {
if (clusterOf(avail[i]) === dir) files.push(avail.splice(i, 1)[0])
else i++
}
}
for (const f of files) exploreConsumed.add(f)
return files
}
function exploreLens(i) {
const files = drawExploreFiles()
if (!files.length) return null
const src = GRAPH_ROWS.length
? `These files are heavily coupled (per the code graph) to files where confirmed bugs live, yet no ` +
`hunt has confirmed or refuted a single finding in them - either they are clean or every lens so ` +
`far walked past them.`
: `These files churned heavily in the last 8 weeks, yet no hunt round has confirmed or refuted a ` +
`single finding in them - either they are clean or every lens so far walked past them.`
return {
key: `explore-${i}`,
prompt: `${src} Read each one IN FULL with fresh eyes. Hunt every class: concurrency and ` +
`interleaving (races, TOCTOU, lock ordering, stale-closure writes after await); lifecycle and ` +
`teardown (unreleased acquires, use-after-close, missing disposal on error paths); state desync ` +
`(two sources of truth updated by different code paths); error-path data loss (swallowed errors, ` +
`partial writes, silent fallbacks); ordering and boundaries (off-by-one, pagination truncation, ` +
`sequence gaps, inclusive/exclusive disagreements).\n` +
files.map((f) => ` - ${f}`).join('\n'),
files,
}
}
let cooldownCluster = null // the top-ranked cluster hunted in round N sits out round N+1
function buildAdaptiveLenses(round) {
const byCluster = {}
for (const c of confirmedAll) {
const cl = clusterOf(c.file)
if (!byCluster[cl]) byCluster[cl] = []
byCluster[cl].push(c)
}
// Explore-heavy schedule: measured hotspot yield flattened to 0.25 high+med/agent by round 6.
const sweeping = uncoveredCount() > 0
const hotspotQuota = round <= 5 ? 2 : 1
// sweep pace: 4 explore lenses x 10 files while inventory files remain uncovered
const exploreQuota = sweeping ? 4 : round <= 5 ? 2 : 3
const hotKey = (cl) => ('hotspot ' + cl).toLowerCase().replace(/[^a-z0-9]+/g, '-')
const picked = Object.entries(byCluster)
.sort((a, b) => b[1].length - a[1].length)
.filter(([cl]) => cl !== cooldownCluster)
.filter(([cl]) => (cleanStreak[hotKey(cl)] || 0) < 2) // pre-filter so backfill sees the real shortfall
.slice(0, hotspotQuota)
cooldownCluster = picked.length ? picked[0][0] : null
const hotspots = picked.map(([cluster, items]) => ({
key: hotKey(cluster),
prompt:
`Bugs cluster. Confirmed findings so far in ${cluster}:\n` +
items.map((i) => ` - ${i.file}:${i.line} ${i.title}`).join('\n') +
`\nHunt ADJACENT to these: the same functions' siblings, every caller, the counterpart operations ` +
`(subscribe/unsubscribe, open/close, register/transfer, acquire/release), and the paths a past fix ` +
`here did NOT cover. Do not re-report the findings listed above - they are already known.`,
}))
const shortfall = hotspotQuota - hotspots.length
if (shortfall > 0) log(`adaptive: hotspot pool short by ${shortfall} - trying explore backfill`)
const explores = []
for (let i = 1; i <= exploreQuota + shortfall; i++) {
if (!sweeping && (cleanStreak[`explore-${i}`] || 0) >= 2) continue // demoted slot: no substitution, that IS demotion
const lens = exploreLens(i)
if (!lens) {
log(`adaptive: explore pool exhausted after ${explores.length} lens(es)`)
break
}
explores.push(lens)
}
return [...hotspots, ...explores]
}
// ---------- dedupe + ledger helpers ----------
function normTitle(t) {
return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter((w) => w.length > 2)
}
// Dedupe is permanent: a candidate merged into an existing entry never comes
// back, so an over-eager match silently loses a real bug rather than deferring
// it. The title-word branch therefore only applies near the prior's location -
// two distinct bugs in one file often share half their title words ("hub client
// map race on register" vs "...on unregister"), and without a window the second
// one is suppressed forever, sometimes by a merely REFUTED namesake.
const TITLE_MATCH_WINDOW = 60
function isDup(a, b) {
if (a.file !== b.file) return false
const delta = Math.abs((a.line || 0) - (b.line || 0))
if (delta <= 10) return true
if (delta > TITLE_MATCH_WINDOW) return false
const aw = normTitle(a.title)
if (!aw.length) return false
const bw = new Set(normTitle(b.title))
const hits = aw.filter((w) => bw.has(w)).length
return hits * 2 >= aw.length
}
function dedupe(cands, priors, counts) {
const kept = []
for (const c of cands) {
const prior = priors.find((p) => isDup(c, p))
if (prior) {
if (counts) counts[prior.fromLedger ? 'suppressedLedger' : 'suppressedRun']++
continue
}
if (kept.some((k) => isDup(c, k))) {
if (counts) counts.suppressedRun++
continue
}
kept.push(c)
}
return kept
}
function seenBlock(seen) {
if (!seen.length) return ''
const lines = seen.map((s) => ` - ${s.file}:${s.line} [${s.status}] ${s.title}`)
return `\n--- KNOWN FINDINGS (already investigated - do NOT re-report; refuted means examined and rejected) ---\n${lines.join('\n')}\n`
}
function convergenceTable(stats, converged, stoppedOnBudget, stalled) {
const verdict = converged
? `CONVERGED after ${stats.length} round(s).`
: stalled
? 'NOT converged - coverage stalled.'
: stoppedOnBudget
? 'NOT converged - stopped on budget.'
: 'NOT converged - hit the round backstop.'
const rows = stats.map(
(s) =>
`| ${s.round} | ${s.family} | ${s.lenses} | ${s.candidates} | ${s.fresh} | ${s.confirmed} | ${s.refuted} | ${s.dryEligible ? 'yes' : 'NO'} | ${s.dryAfter} |`,
)
return [
'## Convergence',
'',
verdict,
'',
'| round | family | lenses | candidates | fresh | confirmed | refuted | dry-eligible | dry after |',
'|---|---|---|---|---|---|---|---|---|',
...rows,
].join('\n')
}
// ---------- recon (verbatim from the current script, including both prompts) ----------
phase('Recon')
const recon = await parallel([
() =>
agent(
`${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` +
`Run, from the repo root: git log --since="8 weeks ago" --name-only --pretty=format: -- Server Client\n` +
`Count how often each non-test source file changed. Return the 25 most-churned files with their counts, ` +
`plus any file that changed in more than 6 distinct commits. High churn = where bugs concentrate.\n` +
`Return plain text: one "path count" per line, most-churned first. No commentary.`,
{ label: 'recon:churn', phase: 'Recon', model: 'haiku', effort: 'xhigh' },
),
() =>
agent(
`${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` +
`Inventory the concurrency and lifecycle surface so the finders know where to look. Report:\n` +
` (a) every Server/ non-test .go file containing "go func", "sync.", "chan ", "select {", or "context.WithCancel"\n` +
` (b) every Client/src/**/*.ts (non-test) containing "addEventListener", "setInterval", "setTimeout", or "new AbortController"\n` +
` (c) every Client/src-tauri/src/*.rs containing "unsafe", "Mutex", "RwLock", "spawn", or "unwrap()"\n` +
`For each file give the path and a rough hit count. Return plain text grouped under (a)/(b)/(c). No commentary, no analysis.`,
{ label: 'recon:surface', phase: 'Recon', model: 'haiku', effort: 'xhigh' },
),
])
const CONTEXT = `\n\n--- RECON: most-churned files (last 8 weeks) ---\n${recon[0] || 'unavailable'}\n\n--- RECON: concurrency & lifecycle surface ---\n${recon[1] || 'unavailable'}\n`
const churnFiles = String(recon[0] || '')
.split('\n')
.map((l) => l.trim().split(/\s+/)[0])
.filter((p) => p.includes('/'))
log('Recon complete - starting converging rounds')
// ---------- round loop ----------
// Cross-run memory: the calling session passes the findings ledger in as args.known.
// Seeding `seen` is all it takes - finderPrompt() already interpolates seenBlock(seen),
// and each round already dedupes fresh candidates against it, so one assignment buys both
// prompt-level suppression ("do not re-derive this") and mechanical dedupe.
const seen = (ARGS.known || []).map((k) => ({
file: k.file,
line: k.line,
title: k.title,
status: k.status || 'known',
fromLedger: true, // telemetry: distinguishes ledger suppression from same-run suppression
}))
const confirmedAll = []
const unverified = []
const roundStats = []
const cleanStreak = {}
let dry = 0
let round = 0
let stoppedOnBudget = false
let coverageStall = 0
let stalledCoverage = false
function finderPrompt(lens, rnd) {
return (
`${RULES}${CONTEXT}${seenBlock(seen)}\n\nThis is round ${rnd} of a converging hunt. Everything under ` +
`KNOWN FINDINGS has already been investigated - spend zero effort re-deriving those; hunt for what is ` +
`NOT on that list.\n\n${lens.prompt}`
)
}
function verifyPrompt(lensKey, candidates) {
return (
`${RULES}\n\nYou are an ADVERSARIAL VERIFIER. Another model hunted the "${lensKey}" lens of this repo and ` +
`produced the candidate findings below. Your job is to REFUTE them, not to agree with them.\n\n` +
`For each candidate, independently: open the cited file, read the surrounding function in full, grep every ` +
`caller, and look for an existing test that locks the current behavior. Then ask, in order:\n` +
` 1. Does the cited code actually say what the finding claims? (Misread code is the most common failure.)\n` +
` 2. Is the bad state actually reachable, or does an upstream guard/type/lock make it impossible?\n` +
` 3. Is the described repro real - can you name the concrete inputs or the exact interleaving?\n` +
` 4. Is this intended behavior that a test already asserts?\n\n` +
`Set refuted=true if ANY of those kills it. DEFAULT TO refuted=true when you are uncertain - a false ` +
`positive costs more than a miss here. Only set refuted=false when you can point at the specific lines ` +
`that prove the bug and describe how it fires.\n` +
`Re-rate severity yourself; do not inherit the hunter's rating. For each survivor, give the smallest ` +
`correct fix - one guard in the shared function beats a guard in every caller.\n\n` +
`Return one verdict per candidate, keeping title/file/line so they can be matched up.\n\n` +
// Strip the panel attribution here rather than at the call sites: the prompt above says
// "another model" on purpose, and naming it is an authority cue that erodes refute-by-default.
`--- CANDIDATES ---\n${JSON.stringify(candidates.map(({ finder, ...c }) => c), null, 2)}`
)
}
const riskySweepPending = () => HAS_INVENTORY && RISKY_FILES.length > 0 && !riskySweepDone
while ((uncoveredCount() > 0 || riskySweepPending() || dry < DRY_THRESHOLD) && round < MAX_ROUNDS) {
if (BUDGET_TOTAL && remainingBudget() < ROUND_BUDGET_FLOOR) {
stoppedOnBudget = true
log(`Budget floor reached (${Math.round(remainingBudget() / 1000)}k left) - stopping before round ${round + 1}`)
break
}
const family = lensesForRound(round + 1)
if (!family || !family.length) {
// Coverage mode with the pool drained and the risky sweep done: an empty family means
// hotspots are demoted/cooled and there is genuinely nothing left to hunt - that IS
// quietness. Count it as a dry round so a late confirm cannot strand a fully-covered
// run one dry round short of its earned convergence. Legacy mode keeps the hard stop:
// an empty family there means "nothing targetable" (no churn, no graph), not "done".
if (HAS_INVENTORY && uncoveredCount() === 0 && !riskySweepPending()) {
round++
dry++
roundStats.push({ round, family: 'exhausted', lenses: 0, candidates: 0, fresh: 0, confirmed: 0, refuted: 0, dryEligible: true, dryAfter: dry, severity: { critical: 0, high: 0, medium: 0, low: 0 }, perLens: {}, filesTouched: 0, filesNew: 0, suppressedLedger: 0, suppressedRun: 0, finderNull: 0, finderEmpty: 0, verifierNull: 0, spentBefore: budget.spent(), spentAfter: budget.spent() })
log(`Round ${round}: nothing left to hunt - counts as a dry round (dry=${dry})`)
continue
}
break // nothing to hunt != everything demoted
}
round++
const uncBefore = uncoveredCount()
const spentBefore = budget.spent()
const counts = { suppressedLedger: 0, suppressedRun: 0, finderNull: 0, finderEmpty: 0, verifierNull: 0 }
const sweepingNow = uncoveredCount() > 0
const lenses = family.filter((l) => (sweepingNow && /^explore-/.test(l.key)) || (cleanStreak[l.key] || 0) < 2)
if (!lenses.length) {
dry++
roundStats.push({ round, family: familyName(round), lenses: 0, candidates: 0, fresh: 0, confirmed: 0, refuted: 0, dryEligible: true, dryAfter: dry, severity: { critical: 0, high: 0, medium: 0, low: 0 }, perLens: {}, filesTouched: 0, filesNew: 0, ...counts, spentBefore, spentAfter: budget.spent() })
log(`Round ${round}: every lens demoted - counts as a dry round (dry=${dry})`)
continue
}
const rnd = round
const seenAtStart = seen.slice()
const lensResults = await pipeline(
lenses,
(lens) =>
agent(finderPrompt(lens, rnd), { label: `r${rnd}:hunt:${lens.key}:opus`, phase: `Round ${rnd}`, model: 'opus', effort: 'high', schema: FINDINGS })
.then((res) => ({ lens, res })),
async (r) => {
const { lens, res } = r
// agent() returns null on failure; a thrown stage instead nulls the whole lens result,
// which the eligibility check catches separately. Both checks are needed.
const finderFailed = res === null
if (finderFailed) counts.finderNull++
else if (!(res.findings || []).length) counts.finderEmpty++
// finder is constant now; kept on the record for ledger continuity across hunts
const union = res ? (res.findings || []).map((f) => ({ ...f, finder: 'opus' })) : []
const fresh = dedupe(union, seenAtStart, counts)
if (!fresh.length) return { lens, finderFailed, unionCount: union.length, fresh: [], matched: [], unmatched: [] }
log(`r${rnd} ${lens.key}: ${fresh.length} fresh candidate(s) -> verification`)
// opus, not fable: fable verify agents hit usage limits and nulled out en masse on
// the 2026-08-13 live run (and were the dominant cost even when they worked)
const vopts = { phase: `Round ${rnd}`, model: 'opus', effort: 'high', schema: VERDICTS }
// Pair verdicts to candidates as they arrive, then retry ONLY what got no usable verdict.
// Retrying the whole batch re-burned every verdict on a partial return, and the old
// count-based trigger let N unmatched garbage verdicts skip the retry entirely.
const matched = []
const unmatched = fresh.slice()
const absorb = (vs) => {
for (const v of vs || []) {
const vRec = { file: v.file, line: v.line, title: v.title }
const idx = unmatched.findIndex((f) => isDup(vRec, f) || isDup(f, vRec))
if (idx === -1) {
const claimed = matched.some(({ cand }) => isDup(vRec, cand) || isDup(cand, vRec))
log(`r${rnd} ${lens.key}: verifier verdict "${v.title}" (${v.file}:${v.line}) ${claimed ? 'duplicates an already-claimed candidate' : 'matched no candidate'} - dropped`)
continue
}
const [cand] = unmatched.splice(idx, 1)
matched.push({ v, cand })
}
}
const v1 = await agent(verifyPrompt(lens.key, fresh), { ...vopts, label: `r${rnd}:verify:${lens.key}` })
absorb(v1 && v1.verdicts)
if (!v1) counts.verifierNull++
if (unmatched.length) {
const v2 = await agent(verifyPrompt(lens.key, unmatched.slice()), { ...vopts, label: `r${rnd}:verify:${lens.key}:retry` })
absorb(v2 && v2.verdicts)
if (!v2) counts.verifierNull++
}
return { lens, finderFailed, unionCount: union.length, fresh, matched, unmatched }
},
)
// a thrown stage nulls the whole lens result - rewind its explore draw too, or the
// session records never-read files as explored-clean (the same poison as a null finder)
lensResults.forEach((r, i) => {
if (!r && lenses[i].files) for (const f of lenses[i].files) exploreConsumed.delete(f)
})
let eligible = !lensResults.some((r) => !r)
let newConfirmed = 0
let newRefuted = 0
let candCount = 0
let freshCount = 0
const perLens = {}
const sevMix = { critical: 0, high: 0, medium: 0, low: 0 }
const filesTouched = new Set()
const filesNew = new Set()
for (const r of lensResults.filter(Boolean)) {
candCount += r.unionCount
freshCount += r.fresh.length
if (r.finderFailed) eligible = false
if (r.lens.files) {
// coverage credit (spec: only explicit-file lenses that ran to completion). A lens
// denied credit - dead finder OR candidates left unverified - returns its whole draw
// to the pool: consumed-but-uncovered files would otherwise strand uncoveredCount()
// above zero forever, and a partially-verified draw is not evidence of cleanliness.
if (!r.finderFailed && !r.unmatched.length) for (const f of r.lens.files) covered.add(f)
else for (const f of r.lens.files) exploreConsumed.delete(f)
}
let lensConfirmed = 0
let lensRefuted = 0
for (const { v, cand } of r.matched) {
// Keep the matched candidate: the verdict schema has no why/repro/evidence, and the
// ledger needs them. Verdict fields are spread last so the verifier's re-rated severity
// and its corrected title/file/line win over the finder's.
const rec = { file: v.file, line: v.line, title: v.title, status: v.refuted ? 'refuted' : 'confirmed' }
if (seen.some((p) => isDup(rec, p))) { counts.suppressedRun++; continue } // cross-lens same-round duplicate
seen.push(rec)
covered.add(rec.file) // any verdict proves the file was read (inert in legacy mode: seen already blocks re-draws)
if (v.refuted) { newRefuted++; lensRefuted++ }
else {
newConfirmed++
lensConfirmed++
sevMix[v.severity] = (sevMix[v.severity] || 0) + 1
confirmedAll.push({ ...cand, ...v, lens: r.lens.key, round })
}
}
if (r.unmatched.length) {
eligible = false // partial verifier failure: some candidates got no verdict at all
for (const f of r.unmatched) unverified.push({ ...f, lens: r.lens.key, round })
}
// a lens hunted at partial panel strength, or whose candidates never got a verdict, is not evidence of cleanliness
if (!r.finderFailed && !r.unmatched.length) cleanStreak[r.lens.key] = lensConfirmed > 0 ? 0 : (cleanStreak[r.lens.key] || 0) + 1
perLens[r.lens.key] = { candidates: r.unionCount, fresh: r.fresh.length, confirmed: lensConfirmed, refuted: lensRefuted, unverified: r.unmatched.length }
// coverage proxy: files that produced fresh candidates this round (finder reading is unobservable)
for (const f of r.fresh) {
filesTouched.add(f.file)
if (!seenAtStart.some((s) => s.file === f.file)) filesNew.add(f.file)
}
}
if (newConfirmed > 0) dry = 0
else if (eligible) dry++
// ineligible zero-confirm round: dry unchanged - "we didn't fully look" is not "it's clean"
roundStats.push({ round, family: familyName(round), lenses: lenses.length, candidates: candCount, fresh: freshCount, confirmed: newConfirmed, refuted: newRefuted, dryEligible: eligible, dryAfter: dry, severity: sevMix, perLens, filesTouched: filesTouched.size, filesNew: filesNew.size, ...counts, spentBefore, spentAfter: budget.spent() })
log(`Round ${round} (${familyName(round)}): ${newConfirmed} confirmed, ${newRefuted} refuted, dry=${dry}${eligible ? '' : ' (ineligible)'}`)
// stalled coverage: an adaptive round that failed to shrink a non-empty uncovered pool
// AND confirmed nothing. Only adaptive rounds count - rounds 1-3 never draw explore
// files by design - and a round that confirmed a bug is never a stall: hotspot yield
// does not shrink the pool, and cutting off a still-productive hunt is the one thing
// a bug-finding tool must not do.
const uncAfter = uncoveredCount()
if (HAS_INVENTORY && familyName() === 'adaptive' && uncAfter > 0 && newConfirmed === 0) {
coverageStall = uncAfter < uncBefore ? 0 : coverageStall + 1
if (coverageStall >= 2) {
stalledCoverage = true
log(`Coverage stalled: uncovered=${uncAfter} did not shrink for 2 adaptive rounds - stopping`)
break
}
} else coverageStall = 0
}
const converged = uncoveredCount() === 0 && !riskySweepPending() && dry >= DRY_THRESHOLD
// ---------- report (deterministic) ----------
// A report agent silently dropped findings (79 sections for 82 confirmed on 2026-08-12), so the
// markdown is assembled in-script from confirmedSorted. Coordinate spot-checking moved to the
// calling session, which validates EVERY finding's file/line after return (see bughunt-run).
const RANK = { critical: 0, high: 1, medium: 2, low: 3 }
const confirmedSorted = confirmedAll.slice().sort((a, b) => RANK[a.severity] - RANK[b.severity])
const unverifiedFinal = unverified.filter((u) => !seen.some((p) => isDup(u, p)))
const table = convergenceTable(roundStats, converged, stoppedOnBudget, stalledCoverage)
const sum = (k) => roundStats.reduce((n, r) => n + (r[k] || 0), 0)
const runStats = {
config: { maxRounds: MAX_ROUNDS, dryThreshold: DRY_THRESHOLD, customLenses: !!CUSTOM_LENSES, knownCount: (ARGS.known || []).length, graphRows: GRAPH_ROWS.length, budgetTotal: BUDGET_TOTAL },
coverage: HAS_INVENTORY ? { inventory: INVENTORY.length, preCovered: PRE_COVERED, covered: INVENTORY.length - uncoveredCount(), uncoveredAtStop: uncoveredCount() } : null,
spentTotal: budget.spent(),
rounds: roundStats.length,
converged,
stoppedOnBudget,
stalledCoverage,
confirmed: confirmedSorted.length,
refuted: sum('refuted'),
unverified: unverifiedFinal.length,
suppressedLedger: sum('suppressedLedger'),
suppressedRun: sum('suppressedRun'),
finderNull: sum('finderNull'),
finderEmpty: sum('finderEmpty'),
verifierNull: sum('verifierNull'),
}
function buildReport() {
const outcome = converged
? `CONVERGED after ${round} round(s).`
: stalledCoverage
? `NOT converged - coverage stalled after ${round} round(s).`
: stoppedOnBudget
? `NOT converged - stopped on budget after ${round} round(s).`
: `NOT converged - hit the round backstop after ${round} round(s).`
const sev = { critical: 0, high: 0, medium: 0, low: 0 }
for (const f of confirmedSorted) sev[f.severity] = (sev[f.severity] || 0) + 1
const lines = ['# Bug hunt report', '']
lines.push(
`${confirmedSorted.length} confirmed finding(s) - ${sev.critical} critical, ${sev.high} high, ` +
`${sev.medium} medium, ${sev.low} low. ${outcome}` +
(confirmedSorted.length
? ` Fix first: ${confirmedSorted[0].title} (\`${confirmedSorted[0].file}:${confirmedSorted[0].line}\`).`
: ''),
'',
)
for (const f of confirmedSorted) {
lines.push(`### ${f.severity} - ${f.title}`, '')
lines.push(`\`${f.file}:${f.line}\` - lens \`${f.lens}\`, round ${f.round}, confidence ${f.confidence}`, '')
if (f.why) lines.push(f.why, '')
if (f.repro) lines.push(`**Repro:** ${f.repro}`, '')
if (f.evidence) lines.push(`**Evidence:** ${f.evidence}`, '')
if (f.fix) lines.push(`**Fix:** ${f.fix}`, '')
}
if (unverifiedFinal.length) {
lines.push('## Unverified - re-run', '')
for (const u of unverifiedFinal) lines.push(`- \`${u.file}:${u.line}\` ${u.title} (lens \`${u.lens}\`, round ${u.round})`)
lines.push('')
}
lines.push(table)
lines.push('', '## Run stats', '')
lines.push(
`Total spent: ${runStats.spentTotal} output tokens across ${runStats.rounds} round(s). ` +
`Suppressed by dedupe: ${runStats.suppressedLedger} ledger-known, ${runStats.suppressedRun} same-run. ` +
`Agent failures: ${runStats.finderNull} finder null, ${runStats.finderEmpty} finder empty, ${runStats.verifierNull} verifier null.`,
'',
)
if (runStats.coverage)
lines.push(
`Coverage: ${runStats.coverage.covered}/${runStats.coverage.inventory} files ` +
`(${runStats.coverage.preCovered} pre-covered from ledger + live explored-clean); ` +
`${runStats.coverage.uncoveredAtStop} uncovered at stop.`,
'',
)
lines.push('| round | spent | files (new) | suppressed ledger/run | finder null/empty | verifier null |')
lines.push('|---|---|---|---|---|---|')
for (const s of roundStats)
lines.push(`| ${s.round} | ${s.spentAfter - s.spentBefore} | ${s.filesTouched} (${s.filesNew}) | ${s.suppressedLedger}/${s.suppressedRun} | ${s.finderNull}/${s.finderEmpty} | ${s.verifierNull} |`)
return lines.join('\n')
}
const report = buildReport()
return { converged, stoppedOnBudget, stalledCoverage, rounds: roundStats, confirmed: confirmedSorted, unverified: unverifiedFinal, runStats, exploredFiles: [...exploreConsumed], report }