Files
OwnCord/.claude/workflows/bughunt.js
T
J3vbandClaude Opus 5 079f59d06d fix(workflows): drop hardcoded absolute repo path from bughunt prompts (#1373)
* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions

* fix(workflows): drop hardcoded absolute repo path from bughunt prompts

The bughunt and bughunt-fix agent prompts told every finder, verifier, fix
and prove agent that the repo lives at a specific absolute path from one
contributor's machine. Anywhere else - a cloud session, CI, another
checkout - that path does not exist, and the churn recon agent ran
`git -C <that path> log ...` outright, so the most-churned-files inventory
came back empty and every finder prompt lost its churn context.

Point the prompts at the agent's working directory instead, which is the
repo root on every platform.

Both harnesses pass (bughunt.harness.mjs, bughunt-fix.harness.mjs).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 09:34:50 +02:00

733 lines
39 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 || 8
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/tauri-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/tauri-client/src/lib/e2eeCrypto.ts, livekitE2EE.ts, ` +
`livekitSession.ts, identity.ts; Client/tauri-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/tauri-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/tauri-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.`,
},
]
function lensesForRound(round) {
if (CUSTOM_LENSES) return round === 1 ? CUSTOM_LENSES : buildAdaptiveLenses(round)
if (round === 1) return SURFACE_LENSES
if (round === 2) return BUGCLASS_LENSES
if (round === 3) return FLOW_LENSES
return buildAdaptiveLenses(round)
}
function familyName(round) {
if (CUSTOM_LENSES) return round === 1 ? 'custom' : 'adaptive'
return ['surfaces', 'bug-classes', 'flows'][round - 1] || 'adaptive'
}
// 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')
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 files = pool
.filter((f) => !exploreConsumed.has(f) && !seen.some((s) => s.file === f))
.slice(0, EXPLORE_FILES_PER_LENS)
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 and hunt for real bugs of any class:\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 hotspotQuota = round <= 5 ? 2 : 1
const exploreQuota = 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 ((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) {
const verdict = converged
? `CONVERGED after ${stats.length} round(s).`
: 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/tauri-client/src/**/*.ts (non-test) containing "addEventListener", "setInterval", "setTimeout", or "new AbortController"\n` +
` (c) every Client/tauri-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
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)}`
)
}
while (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) break // nothing to hunt != everything demoted
round++
const spentBefore = budget.spent()
const counts = { suppressedLedger: 0, suppressedRun: 0, finderNull: 0, finderEmpty: 0, verifierNull: 0 }
const lenses = family.filter((l) => (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.finderFailed && r.lens.files) {
// a dead finder read nothing: un-consume its draw so later rounds can re-offer the
// files and the session does not record never-examined files as explored-clean
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)
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)'}`)
}
const converged = 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)
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 },
spentTotal: budget.spent(),
rounds: roundStats.length,
converged,
stoppedOnBudget,
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).`
: 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.`,
'',
)
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, rounds: roundStats, confirmed: confirmedSorted, unverified: unverifiedFinal, runStats, exploredFiles: [...exploreConsumed], report }